12 Commits

Author SHA1 Message Date
54801d9448 Translated using Weblate (French)
Currently translated at 100.0% (499 of 499 strings)

Translated using Weblate (French)

Currently translated at 96.9% (484 of 499 strings)

Co-authored-by: Ribemont Francois <ribemont.francois+weblate@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: http://translate.droposs.org/projects/drop/drop/fr/
Translation: Drop/Drop
2025-11-09 23:36:19 +00:00
251ddb8ff8 Rearchitecture for v0.4.0 (#197)
* feat: database redist support

* feat: rearchitecture of database schemas, migration reset, and #180

* feat: import redists

* fix: giantbomb logging bug

* feat: partial user platform support + statusMessage -> message

* feat: add user platform filters to store view

* fix: sanitize svg uploads

... copilot suggested this

I feel dirty.

* feat: beginnings of platform & redist management

* feat: add server side redist patching

* fix: update drop-base commit

* feat: import of custom platforms & file extensions

* fix: redelete platform

* fix: remove platform

* feat: uninstall commands, new R UI

* checkpoint: before migrating to nuxt v4

* update to nuxt 4

* fix: fixes for Nuxt v4 update

* fix: remaining type issues

* feat: initial feedback to import other kinds of versions

* working commit

* fix: lint

* feat: redist import
2025-11-10 10:36:13 +11:00
dfa30c8a65 Admin home page #128 (#259)
* First iteration on the new PieChart component

* #128 Adds new admin home page

* Fixes code after merging conflicts

* Removes empty file

* Uses real data for admin home page, and improves style

* Reverts debugging code

* Defines missing variable

* Caches user stats data for admin home page

* Typo

* Styles improvements

* Invalidates cache on signup/signin

* Implements top 5 biggest games

* Improves styling

* Improves style

* Using generateManifest to get the proper size

* Reading data from cache

* Removes unnecessary import

* Improves caching mechanism for game sizes

* Removes lint errors

* Replaces piechart tooltip with colors in legend

* Fixes caching

* Fixes caching and slight improvement on pie chart colours

* Fixes a few bugs related to caching

* Fixes bug where app signin didn't refresh cache

* feat: style improvements

* fix: lint

---------

Co-authored-by: DecDuck <declanahofmeyr@gmail.com>
2025-11-08 09:14:45 +11:00
289034d0c8 Add manual release date editor (#262)
* add manual release date editor

* watch() releaseDate instead of relying on coreMetadata updates

* make linter happy

---------

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-11-07 09:27:37 +11:00
2a23f4d14c Fix lints 2025-10-24 09:33:39 +11:00
b20d355527 Improve igdb metadata fetching (#257)
* improve igdb metadata fetching

    * Make sure to get images with reasonable resolution.
      By default the url igdb returns is in "t_thumb" size,
      an image of size 90x90, which is good only for the icon,
      but bad for pretty much else. This commit will make sure
      covers will be of size "t_cover_big", artworks of 1080p
      height (i.e. "t_1080p") and logos will have their original
      size ("t_original"). Maybe "t_logo_med" is more appropriate?

    * Fetch screenshots as well.

    * Use a separate image for icon and for cover.
      icon needs to be a square, and can be of low
      resolution, so the "t_thmb" size is more appropriate
      for him.

    * If there is a storyline for a game use it as a short
      description.

* IDGB -> IGDB

* use the longer text between storyline and description for description

---------

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-24 09:25:54 +11:00
fa9620eac1 Use 7zip for archive backend (#264)
* feat: use 7zip for archive backend

* fix: lint
2025-10-13 13:02:27 +11:00
a201b62c04 chore(deps): bump axios from 1.11.0 to 1.12.0 (#246)
Bumps [axios](https://github.com/axios/axios) from 1.11.0 to 1.12.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.11.0...v1.12.0)

---
updated-dependencies:
- dependency-name: axios
  dependency-version: 1.12.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 11:36:59 +11:00
9bf164ab77 chore(deps): bump tar-fs from 2.1.3 to 2.1.4 (#256)
Bumps [tar-fs](https://github.com/mafintosh/tar-fs) from 2.1.3 to 2.1.4.
- [Commits](https://github.com/mafintosh/tar-fs/compare/v2.1.3...v2.1.4)

---
updated-dependencies:
- dependency-name: tar-fs
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-13 11:36:31 +11:00
97c6f3490c Add store sort options (#238) (#261)
This commit adds the option
to sort store items by name,
and to choose the sort order.

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-13 11:20:48 +11:00
f5cb856d3d Carousel UI improvements (#258)
* make carousel pagination clickable

* make carousel in game pages wrap around

* make items in store fit the row when the filter menu is visible

---------

Co-authored-by: udifogiel <udifogiel@proton.me>
2025-10-13 11:18:52 +11:00
67de1f6c02 Add Steam metadata provider (#232) (#250)
* feat(metadata): add Steam metadata provider (#232)

* style(steam): remove emojis from log messages
2025-09-21 10:43:35 +10:00
353 changed files with 6839 additions and 4839 deletions

View File

@ -5,8 +5,8 @@ on:
release:
types: [published]
# This can be used to automatically publish nightlies at UTC nighttime
schedule:
- cron: "0 2 * * *" # run at 2 AM UTC
#schedule:
# - cron: "0 2 * * *" # run at 2 AM UTC
jobs:
web:

View File

@ -1,7 +1,7 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@config "../tailwind.config.js";
@config "../../tailwind.config.js";
@layer base {
input[type="number"]::-webkit-outer-spin-button,

View File

@ -86,7 +86,7 @@
<script setup lang="ts">
import { XCircleIcon } from "@heroicons/vue/20/solid";
import type { UserModel } from "~/prisma/client/models";
import type { UserModel } from "~~/prisma/client/models";
const username = ref("");
const password = ref("");

View File

@ -4,9 +4,10 @@
v-for="(_, i) in amount"
:key="i"
:class="[
carousel.currentSlide == i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
carousel.currentSlide === i ? 'bg-blue-600 w-6' : 'bg-zinc-700 w-3',
'transition-all cursor-pointer h-2 rounded-full',
]"
@click="slideTo(i)"
/>
</div>
</template>
@ -18,8 +19,8 @@ const carousel = inject(injectCarousel)!;
const amount = carousel.maxSlide - carousel.minSlide + 1;
// function slideTo(index: number) {
// const offsetIndex = index + carousel.minSlide;
// carousel.nav.slideTo(offsetIndex);
// }
function slideTo(index: number) {
const offsetIndex = index + carousel.minSlide;
carousel.nav.slideTo(offsetIndex);
}
</script>

View File

@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import type { GameModel } from "~/prisma/client/models";
import type { GameModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
const props = defineProps<{

View File

@ -29,6 +29,23 @@
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" />
<div class="flex flex-col">
<label
for="releaseDate"
class="text-sm/6 font-medium text-zinc-100"
>
{{ $t("library.admin.game.editReleaseDate") }}
</label>
<div class="mt-2">
<input
id="releaseDate"
v-model="releaseDate"
type="date"
name="releaseDate"
class="block w-full rounded-md bg-zinc-800 px-3 py-1.5 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
/>
</div>
</div>
</div>
<!-- image carousel pick -->
@ -444,7 +461,7 @@
</template>
<script setup lang="ts">
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
import { micromark } from "micromark";
import {
CheckIcon,
@ -491,11 +508,38 @@ watch(
{ deep: true },
);
const releaseDate = ref(
game.value.mReleased
? new Date(game.value.mReleased).toISOString().substring(0, 10)
: "",
);
watch(releaseDate, async (newDate) => {
const body: PatchGameBody = {};
if (newDate) {
const parsed = new Date(newDate);
if (!isNaN(parsed.getTime())) {
body.mReleased = parsed;
}
}
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.value.id,
},
body,
failTitle: "Failed to update release date",
});
});
const { t } = useI18n();
// I don't know why I split these fields off.
const coreMetadataName = ref(game.value.mName);
const coreMetadataDescription = ref(game.value.mShortDescription);
const coreMetadataIconUrl = ref(useObject(game.value.mIconObjectId));
const coreMetadataIconFileUpload = ref<FileList | undefined>();
const coreMetadataLoading = ref(false);
@ -561,7 +605,6 @@ function coreMetadataUpdate_wrapper() {
);
})
.then((newGame) => {
console.log(newGame);
if (!newGame) return;
Object.assign(game.value, newGame);
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);

View File

@ -0,0 +1,285 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game && unimportedVersions" class="p-8">
<div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">Versions</h1>
<p class="mt-2 text-sm text-zinc-400 max-w-lg">
Versions are a collection of files that are downloaded to clients.
Each version can have multiple configurations, for different
platforms.
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
:href="canImport ? `/admin/library/g/${game.id}/import` : ''"
type="button"
:class="[
canImport ? 'bg-blue-600 hover:bg-blue-700' : 'bg-blue-800/50',
'inline-flex w-fit items-center gap-x-2 rounded-md px-3 py-1 text-sm font-semibold font-display text-white shadow-sm focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600',
]"
>
{{
canImport
? $t("library.admin.import.version.import")
: $t("library.admin.import.version.noVersions")
}}
</NuxtLink>
</div>
</div>
<div class="mt-8 rounded-xl border border-zinc-800 bg-zinc-900 shadow-sm">
<div>
<table class="min-w-full divide-y divide-zinc-800">
<thead>
<tr class="bg-zinc-800/50">
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
>
Version Name
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Imported
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
Platforms
</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-6">
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800">
<tr
v-for="version in game.versions"
:key="version.versionId"
class="transition-colors duration-150 hover:bg-zinc-800/50"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-6"
>
{{ version.versionName }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<RelativeTime :date="version.created" />
</td>
<td class="px-3 py-4">
<ul class="space-y-4">
<li
v-for="gameVersion in version.gameVersions"
:key="gameVersion.versionId"
class="px-3 py-2 border border-zinc-800 rounded-lg shadow"
>
<div>
<div
class="text-sm flex items-center gap-x-2 text-zinc-200 font-semibold"
>
<IconsPlatform
:platform="
platforms[gameVersion.platformId].platformIcon.key
"
:fallback="
platforms[gameVersion.platformId].platformIcon
.fallback
"
class="size-5 text-blue-500"
/>
<span class="block truncate">{{
platforms[gameVersion.platformId].name
}}</span>
</div>
<!-- launch commands -->
<div class="space-y-1 mt-4">
<div
v-if="gameVersion.install"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Install</span
>
<div
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700">(install dir)/</span
>{{ gameVersion.install.command }}
{{ gameVersion.install.args }}
</div>
</div>
<div>
<span class="font-semibold text-sm text-zinc-100"
>Launch options</span
>
<ul class="divide-y divide-zinc-700">
<li
v-for="launch in gameVersion.launches"
:key="launch.command"
class="ml-2 py-2 flex justify-between items-center"
>
<h1
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>
{{ launch.name }}
</h1>
<div
class="mt-1 whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700"
>(install dir)/</span
>{{ launch.command }} {{ launch.args }}
</div>
</li>
</ul>
</div>
<div
v-if="gameVersion.uninstall"
class="flex items-center justify-between"
>
<span
class="font-display text-xs text-zinc-300 font-semibold uppercase tracking-wide"
>Uninstall</span
>
<div
class="whitespace-nowrap font-mono text-xs text-zinc-300 bg-zinc-950 px-1 py-0.5 w-fit rounded"
>
<span class="text-zinc-700">(install dir)/</span
>{{ gameVersion.uninstall.command }}
{{ gameVersion.uninstall.args }}
</div>
</div>
</div>
</div>
</li>
</ul>
</td>
<td
class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-6"
>
<button
class="inline-flex items-center rounded-md bg-red-400/10 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-400/20 transition-all duration-200 hover:bg-red-400/20 hover:scale-105 active:scale-95"
@click="() => deleteVersion(version.versionId)"
>
Delete
<span class="sr-only">
{{ $t("chars.srComma", [version.versionName]) }}
</span>
</button>
</td>
</tr>
<tr v-if="game.versions.length === 0">
<td colspan="5" class="py-8 text-center text-sm text-zinc-400">
No versions
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div v-else class="grow w-full flex items-center justify-center">
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
{{ $t("library.admin.offlineTitle") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("library.admin.offline") }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SerializeObject, TypedInternalResponse } from "nitropack";
import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page
const props = defineProps<{ unimportedVersions: string[] }>();
const { t } = useI18n();
const hasDeleted = ref(false);
const canImport = computed(
() => hasDeleted.value || props.unimportedVersions.length > 0,
);
type GameFetchType = TypedInternalResponse<
"/api/v1/admin/game/:id",
unknown,
"get"
>["game"];
const game = defineModel<SerializeObject<GameFetchType>>({ required: true });
if (!game.value)
throw createError({
statusCode: 500,
message: "Game not provided to editor component",
});
const rawPlatforms = await useAdminPlatforms();
const platforms = Object.fromEntries(
renderPlatforms(rawPlatforms).map((v) => [v.param, v]),
);
async function updateVersionOrder() {
try {
const newVersions = await $dropFetch("/api/v1/admin/game/version", {
method: "PATCH",
body: {
id: game.value.id,
versions: game.value.versions.map((e) => e.versionId),
},
});
game.value.versions = newVersions;
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.version.order.title"),
description: t("errors.version.order.desc", {
error: (e as H3Error)?.message ?? t("errors.unknown"),
}),
buttonText: t("common.close"),
},
(e, c) => c(),
);
}
}
async function deleteVersion(versionId: string) {
await $dropFetch("/api/v1/admin/game/version", {
method: "DELETE",
body: {
id: versionId,
},
failTitle: "Failed to delete version.",
});
game.value.versions.splice(
game.value.versions.findIndex((e) => e.versionId === versionId),
1,
);
hasDeleted.value = true;
}
</script>

View File

@ -77,7 +77,7 @@ const {
}>
| undefined
| null;
href?: string;
href?: string | undefined;
showTitleDescription?: boolean;
animate?: boolean;
defaultPlaceholder?: boolean;

View File

@ -16,7 +16,7 @@
</template>
<script setup lang="ts">
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
const { game } = defineProps<{
game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };

View File

@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="6" y1="11" x2="10" y2="11" />
<line x1="8" y1="9" x2="8" y2="13" />
<line x1="15" y1="12" x2="15.01" y2="12" />
<line x1="18" y1="10" x2="18.01" y2="10" />
<path
d="M17.32 5H6.68a4 4 0 00-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 003 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 019.828 16h4.344a2 2 0 011.414.586L17 18c.5.5 1 1 2 1a3 3 0 003-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0017.32 5z"
/>
</svg>
</template>

View File

@ -9,14 +9,14 @@
</template>
<script setup lang="ts">
import { HardwarePlatform } from "~/prisma/client/enums";
import { HardwarePlatform } from "~~/prisma/client/enums";
import type { Component } from "vue";
import LinuxLogo from "./LinuxLogo.vue";
import WindowsLogo from "./WindowsLogo.vue";
import MacLogo from "./MacLogo.vue";
import DropLogo from "../DropLogo.vue";
const props = defineProps<{ platform: string; fallback?: string }>();
const props = defineProps<{ platform: string; fallback?: string | undefined }>();
const platformIcons: { [key in HardwarePlatform]: Component } = {
[HardwarePlatform.Linux]: LinuxLogo,

View File

@ -191,7 +191,7 @@ import {
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
const model = ref<GameMetadataSearchResult | undefined>(undefined);

View File

@ -92,7 +92,7 @@ import type { Locale } from "vue-i18n";
const { showText = true } = defineProps<{ showText?: boolean }>();
const { locales, locale: currLocale, setLocale } = useI18n();
const { locale: currLocale, setLocale, locales } = useI18n();
function changeLocale(locale: Locale) {
setLocale(locale);

View File

@ -15,7 +15,7 @@
</template>
<script setup lang="ts">
import type { TaskLog } from "~/server/internal/tasks";
import type { TaskLog } from "~~/server/internal/tasks";
defineProps<{ log: typeof TaskLog.infer; short?: boolean }>();

View File

@ -162,7 +162,7 @@
<script setup lang="ts">
import { ref } from "vue";
import type { GameModel } from "~/prisma/client/models";
import type { GameModel } from "~~/prisma/client/models";
import {
DialogTitle,
Listbox,
@ -171,7 +171,7 @@ import {
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid";
@ -208,7 +208,7 @@ const { t } = useI18n();
const open = defineModel<boolean>({ required: true });
const currentGame = ref<(typeof metadataGames.value)[number]>();
const currentGame = ref<NonNullable<(typeof metadataGames.value)[number]> | null>(null);
const developed = ref(false);
const published = ref(false);
const addGameLoading = ref(false);
@ -236,7 +236,7 @@ async function addGame() {
throw e;
}
} finally {
currentGame.value = undefined;
currentGame.value = null;
developed.value = false;
published.value = false;
addGameLoading.value = false;

View File

@ -46,7 +46,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { CollectionEntryModel, GameModel } from "~/prisma/client/models";
import type { CollectionEntryModel, GameModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
const props = defineProps<{

View File

@ -110,7 +110,7 @@
</template>
<script setup lang="ts">
import type { CompanyModel } from "~/prisma/client/models";
import type { CompanyModel } from "~~/prisma/client/models";
const open = defineModel<boolean>({ required: true });

View File

@ -45,7 +45,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { GameTagModel } from "~/prisma/client/models";
import type { GameTagModel } from "~~/prisma/client/models";
const emit = defineEmits<{
created: [tag: GameTagModel];

View File

@ -35,7 +35,7 @@
</template>
<script setup lang="ts">
import type { CollectionModel } from "~/prisma/client/models";
import type { CollectionModel } from "~~/prisma/client/models";
import { DialogTitle } from "@headlessui/vue";
const collection = defineModel<CollectionModel | undefined>();

View File

@ -36,7 +36,7 @@
<script setup lang="ts">
import { DialogTitle } from "@headlessui/vue";
import type { UserModel } from "~/prisma/client/models";
import type { UserModel } from "~~/prisma/client/models";
const user = defineModel<UserModel | undefined>();
const deleteLoading = ref(false);

View File

@ -44,7 +44,7 @@
<script setup lang="ts">
import { XMarkIcon } from "@heroicons/vue/24/solid";
import type { NotificationModel } from "~/prisma/client/models";
import type { NotificationModel } from "~~/prisma/client/models";
const props = defineProps<{ notification: NotificationModel }>();

View File

@ -0,0 +1,45 @@
<template>
<h2 v-if="title" class="text-lg mb-4 w-full">{{ title }}</h2>
<div class="flex flex-col xl:flex-row gap-4">
<div class="relative flex grow max-w-[12rem]">
<svg class="aspect-square grow relative inline" viewBox="0 0 100 100">
<PieChartPieSlice
v-for="slice in slices"
:key="`${slice.percentage}-${slice.totalPercentage}`"
:slice="slice"
/>
</svg>
<div class="absolute inset-0 bg-zinc-900 rounded-full m-12" />
</div>
<ul class="flex flex-col gap-y-1 justify-center text-left">
<li
v-for="slice in slices"
:key="slice.value"
class="text-sm inline-flex items-center gap-x-1"
>
<span
class="size-3 inline-block rounded-sm"
:class="CHART_COLOURS[slice.color].bg"
/>
{{
$t("common.labelValueColon", {
label: slice.label,
value: slice.value,
})
}}
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { generateSlices } from "~/components/PieChart/utils";
import type { SliceData } from "~/components/PieChart/types";
const { data, title = undefined } = defineProps<{
data: SliceData[];
title?: string | undefined;
}>();
const slices = generateSlices(data);
</script>

View File

@ -0,0 +1,35 @@
<template>
<path
v-if="slice.percentage !== 0 && slice.percentage !== 100"
:class="[CHART_COLOURS[slice.color].fill]"
:d="`
M ${slice.start}
A ${slice.radius},${slice.radius} 0 ${getFlags(slice.percentage)} ${polarToCartesian(slice.center, slice.radius, percent2Degrees(slice.totalPercentage))}
L ${slice.center}
z
`"
stroke-width="2"
/>
<circle
v-if="slice.percentage === 100"
:r="slice.radius"
:cx="slice.center.x"
:cy="slice.center.y"
:class="[CHART_COLOURS[slice.color].fill]"
stroke-width="2"
/>
</template>
<script setup lang="ts">
import type { Slice } from "~/components/PieChart/types";
import {
getFlags,
percent2Degrees,
polarToCartesian,
} from "~/components/PieChart/utils";
import { CHART_COLOURS } from "~/utils/colors";
const { slice } = defineProps<{
slice: Slice;
}>();
</script>

19
app/components/PieChart/types.d.ts vendored Normal file
View File

@ -0,0 +1,19 @@
import type Tuple from "~/utils/tuple";
import type { ChartColour } from "~/utils/colors";
export type Slice = {
start: Tuple;
center: Tuple;
percentage: number;
totalPercentage: number;
radius: number;
color: ChartColour;
label: string;
value: number;
};
export type SliceData = {
value: number;
color?: ChartColour;
label: string;
};

View File

@ -0,0 +1,50 @@
import Tuple from "~/utils/tuple";
import type { Slice, SliceData } from "~/components/PieChart/types";
import { sum, lastItem } from "~/utils/array";
export const START = new Tuple(50, 10);
export const CENTER = new Tuple(50, 50);
export const RADIUS = 40;
export const polarToCartesian = (
center: Tuple,
radius: number,
angleInDegrees: number,
) => {
const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180;
const x = center.x + radius * Math.cos(angleInRadians);
const y = center.y + radius * Math.sin(angleInRadians);
return new Tuple(x, y);
};
export const percent2Degrees = (percentage: number) => (360 * percentage) / 100;
export function generateSlices(data: SliceData[]): Slice[] {
return data.reduce((accumulator, currentValue, index, array) => {
const percentage =
(currentValue.value * 100) / sum(array.map((slice) => slice.value));
return [
...accumulator,
{
start: accumulator.length
? polarToCartesian(
CENTER,
RADIUS,
percent2Degrees(lastItem(accumulator).totalPercentage),
)
: START,
radius: RADIUS,
percentage: percentage,
totalPercentage:
sum(accumulator.map((element) => element.percentage)) + percentage,
center: CENTER,
color: PIE_COLOURS[index % PIE_COLOURS.length],
label: currentValue.label,
value: currentValue.value,
},
];
}, [] as Slice[]);
}
export const getFlags = (percentage: number) =>
percentage > 50 ? new Tuple(1, 1) : new Tuple(0, 1);

View File

@ -106,7 +106,7 @@ const emit = defineEmits<{
}>();
const props = defineProps<{
value?: string;
value?: string | undefined;
guesses?: Array<{ platform: PlatformRenderable; filename: string }>;
}>();

View File

@ -0,0 +1,31 @@
<template>
<div
:class="[
'relative h-5 rounded-xl overflow-hidden',
CHART_COLOURS[backgroundColor].bg,
]"
>
<div
:style="{ width: `${percentage}%` }"
:class="['transition-all h-full', CHART_COLOURS[color].bg]"
/>
<span
class="absolute inset-0 flex items-center justify-center text-blue-200 text-sm font-bold font-display"
>
{{ $t("tasks.admin.progress", [Math.round(percentage * 10) / 10]) }}
</span>
</div>
</template>
<script setup lang="ts">
import { type ChartColour, CHART_COLOURS } from "~/utils/colors";
const {
percentage,
color = "blue",
backgroundColor = "zinc",
} = defineProps<{
percentage: number;
color?: ChartColour;
backgroundColor?: ChartColour;
}>();
</script>

View File

@ -0,0 +1,43 @@
<template>
<table v-if="items.length > 0" class="w-full mt-4 space-y-6">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody class="divide-y divide-white/10">
<tr v-for="item in items" :key="`${item.rank}-${item.name}`">
<td
class="my-2 size-7 rounded-sm bg-zinc-950 ring ring-zinc-800 inline-flex items-center justify-center font-bold font-display text-blue-500"
>
{{ item.rank }}
</td>
<td class="w-full font-bold px-2">{{ item.name }}</td>
<td
class="text-right text-sm font-semibold text-zinc-500 whitespace-nowrap"
>
{{ item.value }}
</td>
</tr>
</tbody>
</table>
<p
v-else
class="w-full p-2 text-center uppercase text-sm font-display font-bold text-zinc-700"
>
{{ $t("common.noData") }}
</p>
</template>
<script lang="ts" setup>
export type RankItem = {
rank: number;
name: string;
value: string;
};
const { items } = defineProps<{
items: RankItem[];
}>();
</script>

View File

@ -4,7 +4,7 @@
<script setup lang="ts">
import type { SerializeObject } from "nitropack";
import type { RedistModel, UserPlatformModel } from "~/prisma/client/models";
import type { RedistModel, UserPlatformModel } from "~~/prisma/client/models";
type ModelType = SerializeObject<
RedistModel & { platform?: UserPlatformModel }

View File

@ -0,0 +1,193 @@
<template>
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table class="min-w-full divide-y divide-zinc-700">
<thead>
<tr>
<th
scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
>
{{ $t("common.name") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("type") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.working") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("options") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.totalSpace") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.freeSpace") }}
</th>
<th
scope="col"
class="px-3 py-3.5 text-left text-sm font-semibold text-zinc-100"
>
{{ $t("library.admin.sources.utilizationPercentage") }}
</th>
<th
v-if="editSource || deleteSource"
scope="col"
class="relative py-3.5 pl-3 pr-4 sm:pr-3"
>
<span class="sr-only">{{ $t("actions") }}</span>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(source, sourceIdx) in sources"
:key="source.id"
class="even:bg-zinc-800"
>
<td
class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ source.name }}
</td>
<td
class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400 inline-flex gap-x-1 items-center"
>
<component
:is="optionsMetadata[source.backend].icon"
class="size-5 text-zinc-400"
/>
{{ optionsMetadata[source.backend].title }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
<CheckIcon v-if="source.working" class="size-5 text-green-500" />
<XMarkIcon v-else class="size-5 text-red-500" />
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.options }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.fsStats && formatBytes(source.fsStats.totalSpace) }}
</td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-zinc-400">
{{ source.fsStats && formatBytes(source.fsStats.freeSpace) }}
</td>
<td
class="align-middle flex flex-cols-5 whitespace-nowrap px-3 py-4 text-sm text-zinc-400"
>
<div class="flex-auto content-right">
<ProgressBar
v-if="source.fsStats"
:percentage="
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace,
)
"
:color="
getBarColor(
getPercentage(
source.fsStats.freeSpace,
source.fsStats.totalSpace,
),
)
"
background-color="slate"
/>
</div>
</td>
<td
v-if="editSource || deleteSource"
class="relative whitespace-nowrap py-4 pl-3 pr-3 text-right text-sm font-medium space-x-2"
>
<button
v-if="editSource"
class="text-blue-500 hover:text-blue-400"
@click="() => editSource(sourceIdx)"
>
{{ $t("common.edit") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
<button
v-if="deleteSource"
class="text-red-500 hover:text-red-400"
@click="() => deleteSource(sourceIdx)"
>
{{ $t("delete") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import type { WorkingLibrarySource } from "~~/server/api/v1/admin/library/sources/index.get";
import type { LibraryBackend } from "~~/prisma/client/enums";
import { BackwardIcon, CheckIcon, XMarkIcon } from "@heroicons/vue/24/outline";
import { DropLogo } from "#components";
import { formatBytes } from "~~/server/internal/utils/files";
import { getBarColor } from "~/utils/colors";
const {
sources,
deleteSource = undefined,
editSource = undefined,
} = defineProps<{
sources: WorkingLibrarySource[];
summaryMode?: boolean;
deleteSource?: (id: number) => void;
editSource?: (id: number) => void;
}>();
const { t } = useI18n();
const optionsMetadata: {
[key in LibraryBackend]: {
title: string;
description: string;
docsLink: string;
icon: Component;
};
} = {
Filesystem: {
title: t("library.admin.sources.fsTitle"),
description: t("library.admin.sources.fsDesc"),
docsLink: "https://docs.droposs.org/docs/library#drop-style",
icon: DropLogo,
},
FlatFilesystem: {
title: t("library.admin.sources.fsFlatTitle"),
description: t("library.admin.sources.fsFlatDesc"),
docsLink: "https://docs.droposs.org/docs/library#flat-style-or-compat",
icon: BackwardIcon,
},
};
const getPercentage = (value: number, total: number) =>
((total - value) * 100) / total;
</script>

View File

@ -1,3 +1,12 @@
<i18n>
{
"en": {
"↓": "↓",
"↑": "↑"
}
}
</i18n>
<template>
<div>
<div>
@ -176,9 +185,12 @@
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click="() => (currentSort = option.param)"
@click.prevent="handleSortClick(option, $event)"
>
{{ option.name }}
<span v-if="currentSort === option.param">
{{ sortOrder === "asc" ? $t("↑") : $t("↓") }}
</span>
</button>
</MenuItem>
</div>
@ -298,7 +310,7 @@
<div
v-if="games?.length ?? 0 > 0"
ref="product-grid"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-7 gap-4"
class="relative lg:col-span-4 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4"
>
<!-- Your content -->
<GamePanel
@ -365,7 +377,7 @@ import {
Squares2X2Icon,
} from "@heroicons/vue/20/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import type { GameModel, GameTagModel } from "~~/prisma/client/models";
import MultiItemSelector from "./MultiItemSelector.vue";
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
@ -397,8 +409,13 @@ const sorts: Array<StoreSortOption> = [
name: "Recently Added",
param: "recent",
},
{
name: "Name",
param: "name",
},
];
const currentSort = ref(sorts[0].param);
const sortOrder = ref<"asc" | "desc">("desc");
const options: Array<StoreFilterOption> = [
...(tags.length > 0
@ -474,7 +491,7 @@ async function updateGames(query: string, resetGames: boolean) {
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}&order=${sortOrder.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
@ -491,6 +508,19 @@ watch(filterQuery, (newUrl) => {
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
watch(sortOrder, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
function handleSortClick(option: StoreSortOption, event: MouseEvent) {
event.stopPropagation();
if (currentSort.value === option.param) {
sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc";
} else {
currentSort.value = option.param;
sortOrder.value = option.param === "name" ? "asc" : "desc";
}
}
</script>

View File

@ -49,7 +49,7 @@
<script setup lang="ts">
import { CheckCircleIcon, XMarkIcon } from "@heroicons/vue/24/solid";
import type { TaskMessage } from "~/server/internal/tasks";
import type { TaskMessage } from "~~/server/internal/tasks";
defineProps<{ task: TaskMessage | undefined; active?: boolean }>();
</script>

View File

@ -0,0 +1,52 @@
<template>
<div
:class="[
'border border-zinc-800 rounded-xl h-full px-6 py-4 relative bg-zinc-950/30',
{ 'min-h-50 pb-15': link, 'lg:pb-4': !link },
]"
>
<h1
v-if="props.title"
:class="[
'font-semibold text-lg w-full',
{ 'mb-3': !props.subtitle && link },
]"
>
{{ props.title }}
<div v-if="rightTitle" class="float-right">{{ props.rightTitle }}</div>
</h1>
<h2
v-if="props.subtitle"
:class="['text-zinc-400 text-sm w-full', { 'mb-3': link }]"
>
{{ props.subtitle }}
<div v-if="rightTitle" class="float-right">{{ props.rightTitle }}</div>
</h2>
<slot />
<div v-if="props.link" class="absolute bottom-5 right-5">
<NuxtLink
:to="props.link.url"
class="transition text-sm/6 font-semibold text-zinc-400 hover:text-zinc-100 inline-flex gap-x-2 items-center duration-200 hover:scale-105"
>
{{ props.link.label }}
<ArrowRightIcon class="h-4 w-4" aria-hidden="true" />
</NuxtLink>
</div>
</div>
</template>
<script lang="ts" setup>
import { ArrowRightIcon } from "@heroicons/vue/20/solid";
const props = defineProps<{
title?: string;
subtitle?: string;
rightTitle?: string;
link?: {
url: string;
label: string;
};
}>();
</script>

View File

@ -46,7 +46,7 @@
</template>
<script setup lang="ts">
import type { NotificationModel } from "~/prisma/client/models";
import type { NotificationModel } from "~~/prisma/client/models";
const props = defineProps<{ notifications: Array<NotificationModel> }>();
</script>

View File

@ -81,8 +81,6 @@
<script setup lang="ts">
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/vue";
import { ChevronDownIcon } from "@heroicons/vue/16/solid";
import { useObject } from "~/composables/objects";
import type { NavigationItem } from "~/composables/types";
const user = useUser();

View File

@ -2,7 +2,7 @@ import type {
CollectionModel,
CollectionEntryModel,
GameModel,
} from "~/prisma/client/models";
} from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
type FullCollection = CollectionModel & {

View File

@ -1,4 +1,4 @@
import type { ArticleModel } from "~/prisma/client/models";
import type { ArticleModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
export const useNews = () =>

View File

@ -1,4 +1,4 @@
import type { NotificationModel } from "~/prisma/client/models";
import type { NotificationModel } from "~~/prisma/client/models";
const ws = new WebSocketHandler("/api/v1/notifications/ws");

View File

@ -1,5 +1,5 @@
import type { UserPlatform } from "~/prisma/client/client";
import { HardwarePlatform } from "~/prisma/client/enums";
import type { UserPlatform } from "~~/prisma/client/client";
import { HardwarePlatform } from "~~/prisma/client/enums";
export type PlatformRenderable = {
name: string;

View File

@ -1,4 +1,4 @@
import type { TaskMessage } from "~/server/internal/tasks";
import type { TaskMessage } from "~~/server/internal/tasks";
import { WebSocketHandler } from "./ws";
const websocketHandler = new WebSocketHandler("/api/v1/task");

View File

@ -1,4 +1,4 @@
import type { UserModel } from "~/prisma/client/models";
import type { UserModel } from "~~/prisma/client/models";
// undefined = haven't check
// null = check, no user

View File

@ -1,6 +1,6 @@
import type { SerializeObject } from "nitropack";
import type { UserModel } from "~/prisma/client/models";
import type { AuthMec } from "~/prisma/client/enums";
import type { UserModel } from "~~/prisma/client/models";
import type { AuthMec } from "~~/prisma/client/enums";
export const useUsers = () =>
useState<

View File

@ -166,17 +166,20 @@ import {
RectangleStackIcon,
DocumentIcon,
} from "@heroicons/vue/24/outline";
import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
import { ArrowLeftIcon } from "@heroicons/vue/16/solid";
import { XMarkIcon } from "@heroicons/vue/24/solid";
const i18nHead = useLocaleHead();
const navigation: Array<NavigationItem & { icon: Component }> = [
{ label: $t("home"), route: "/admin", prefix: "/admin", icon: HomeIcon },
{
label: $t("userHeader.links.library"),
label: $t("header.admin.home"),
route: "/admin",
prefix: "/admin",
icon: HomeIcon,
},
{
label: $t("header.admin.library"),
route: "/admin/library",
prefix: "/admin/library",
icon: ServerStackIcon,

View File

@ -1,10 +1,10 @@
<template>
<div v-if="!noWrapper" class="flex flex-col w-full min-h-screen bg-zinc-900">
<UserHeader class="z-50" hydrate-on-idle />
<LazyUserHeader class="z-50" hydrate-on-idle />
<div class="grow flex">
<NuxtPage />
</div>
<UserFooter class="z-50" hydrate-on-interaction />
<LazyUserFooter class="z-50" hydrate-on-interaction />
</div>
<div v-else class="flex w-full min-h-screen bg-zinc-900">
<NuxtPage />

View File

@ -92,7 +92,7 @@
<script setup lang="ts">
import { CheckIcon, TrashIcon } from "@heroicons/vue/24/outline";
import type { NotificationModel } from "~/prisma/client/models";
import type { NotificationModel } from "~~/prisma/client/models";
import type { SerializeObject } from "nitropack";
definePageMeta({

177
app/pages/admin/index.vue Normal file
View File

@ -0,0 +1,177 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-2xl font-semibold text-zinc-100">
{{ t("home.admin.title") }}
</h1>
<p class="mt-2 text-base text-zinc-400">
{{ t("home.admin.subheader") }}
</p>
</div>
</div>
<main
class="mx-auto max-w-md lg:max-w-none md:max-w-none w-full py-2 text-zinc-100"
>
<div class="grid grid-cols-6 gap-4">
<div class="col-span-6 lg:col-span-1 md:col-span-3 row-span-1">
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<DropLogo />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-2xl flex-1 font-bold">{{ version }}</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.version") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-1 md:col-span-3">
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<GamepadIcon />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">{{ gameCount }}</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.games") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div
class="col-span-6 lg:col-span-1 md:col-span-3 row-span-1 lg:col-start-1 lg:row-start-2"
>
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<ServerStackIcon />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">
{{ sources.length }}
</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.librarySources") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div
class="col-span-6 lg:col-span-1 md:col-span-3 row-span-1 lg:col-start-2 lg:row-start-2"
>
<TileWithLink>
<div class="h-full flex">
<div class="flex-1 my-auto">
<UserGroupIcon />
</div>
<div
class="flex-6 lg:flex-2 my-auto text-center flex lg:inline mx-4"
>
<div class="text-3xl flex-1 font-bold">
{{ userStats.userCount }}
</div>
<div class="text-xs flex-1 text-left lg:text-center">
{{ t("home.admin.users") }}
</div>
</div>
</div>
</TileWithLink>
</div>
<div class="col-span-6 row-span-1 lg:col-span-2 lg:row-span-2">
<TileWithLink
:link="{
url: '/admin/users',
label: t('home.admin.goToUsers'),
}"
:title="t('home.admin.activeInactiveUsers')"
>
<PieChart :data="pieChartData" />
</TileWithLink>
</div>
<div class="col-span-6">
<TileWithLink
title="Library"
:link="{ url: '/admin/library', label: 'Go to library' }"
>
<SourceTable :sources="sources" />
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-2">
<TileWithLink
:title="t('home.admin.biggestGamesToDownload')"
:subtitle="t('home.admin.latestVersionOnly')"
>
<RankingList :items="biggestGamesLatest.map(gameToRankItem)" />
</TileWithLink>
</div>
<div class="col-span-6 lg:col-span-2">
<TileWithLink
:title="t('home.admin.biggestGamesOnServer')"
:subtitle="t('home.admin.allVersionsCombined')"
>
<RankingList :items="biggestGamesCombined.map(gameToRankItem)" />
</TileWithLink>
</div>
</div>
</main>
</div>
</template>
<script setup lang="ts">
import { formatBytes } from "~~/server/internal/utils/files";
import GamepadIcon from "~/components/Icons/GamepadIcon.vue";
import DropLogo from "~/components/DropLogo.vue";
import { ServerStackIcon, UserGroupIcon } from "@heroicons/vue/24/outline";
import type { RankItem } from "~/components/RankingList.vue";
import type { GameSize } from "~~/server/internal/gamesize";
definePageMeta({
layout: "admin",
});
useHead({
title: "Home",
});
const { t } = useI18n();
const {
version,
gameCount,
sources,
userStats,
biggestGamesLatest,
biggestGamesCombined,
} = await $dropFetch("/api/v1/admin/home");
const gameToRankItem = (game: GameSize, rank: number): RankItem => ({
rank: rank + 1,
name: game.gameName,
value: formatBytes(game.size),
});
const pieChartData = [
{
label: t("home.admin.inactiveUsers"),
value: userStats.userCount - userStats.activeSessions,
},
{ label: t("home.admin.activeUsers"), value: userStats.activeSessions },
];
</script>

View File

@ -134,34 +134,41 @@
</div>
</div>
<!-- setup mode -->
<SwitchGroup as="div" class="max-w-lg flex items-center justify-between">
<span class="flex flex-grow flex-col">
<SwitchLabel
as="span"
class="text-sm font-medium leading-6 text-zinc-100"
passive
>{{ $t("library.admin.import.version.setupMode") }}</SwitchLabel
<fieldset class="max-w-lg">
<legend class="text-sm/6 font-semibold text-white">
Select an import mode
</legend>
<div class="mt-2 grid grid-cols-1 gap-y-6 sm:grid-cols-2 sm:gap-x-4">
<label
v-for="mode in setupModes"
:key="mode.id"
:aria-label="mode.title"
:aria-description="mode.description"
class="cursor-pointer group relative flex rounded-lg border border-white/10 bg-zinc-800/50 p-4 has-checked:bg-blue-500/10 has-checked:outline-2 has-checked:-outline-offset-2 has-checked:outline-blue-500 has-focus-visible:outline-3 has-focus-visible:-outline-offset-1 has-disabled:bg-gray-800 has-disabled:opacity-25"
>
<SwitchDescription as="span" class="text-sm text-zinc-400">{{
$t("library.admin.import.version.setupModeDesc")
}}</SwitchDescription>
</span>
<Switch
v-model="versionSettings.onlySetup"
:class="[
versionSettings.onlySetup ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
]"
>
<span
aria-hidden="true"
:class="[
versionSettings.onlySetup ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
]"
/>
</Switch>
</SwitchGroup>
<input
type="radio"
name="mode"
:value="mode.id"
:checked="versionSettings.onlySetup === mode.value"
class="absolute inset-0 appearance-none opacity-0 focus:outline-none"
@click="versionSettings.onlySetup = mode.value"
/>
<div class="flex-1">
<span class="block text-sm font-medium text-white">{{
mode.title
}}</span>
<span class="mt-1 block text-xs text-zinc-400">{{
mode.description
}}</span>
</div>
<CheckCircleIcon
class="invisible size-5 text-blue-500 group-has-checked:visible"
aria-hidden="true"
/>
</label>
</div>
</fieldset>
<!-- launch commands -->
<div class="relative max-w-3xl">
<label
@ -252,7 +259,8 @@
>Uninstall command</label
>
<p class="text-zinc-400 text-xs">
Executable to be run on uninstalling a game. Useful for installer-only games.
Executable to be run on uninstalling a game. Useful for installer-only
games.
</p>
<div class="mt-2">
<div
@ -301,7 +309,8 @@
</SwitchDescription>
</span>
<Switch
v-model="versionSettings.delta"
:model-value="versionSettings.delta || false"
@update:model-value="(v) => (versionSettings.delta = v)"
:class="[
versionSettings.delta ? 'bg-blue-600' : 'bg-zinc-800',
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2',
@ -460,10 +469,14 @@ import {
} from "@headlessui/vue";
import { XCircleIcon } from "@heroicons/vue/16/solid";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { PlusIcon, TrashIcon } from "@heroicons/vue/24/outline";
import {
CheckCircleIcon,
PlusIcon,
TrashIcon,
} from "@heroicons/vue/24/outline";
import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { ImportVersion } from "~/server/api/v1/admin/import/version/index.post";
import type { ImportGameVersion } from "~~/server/api/v1/admin/import/version/index.post";
definePageMeta({
layout: "admin",
@ -474,14 +487,15 @@ const { t } = useI18n();
const route = useRoute();
const gameId = route.params.id.toString();
const versions = await $dropFetch(
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}`,
`/api/v1/admin/import/version?id=${encodeURIComponent(gameId)}&mode=game`,
);
const userPlatforms = await useAdminPlatforms();
const allPlatforms = renderPlatforms(userPlatforms);
const currentlySelectedVersion = ref(-1);
const versionSettings = ref<Partial<typeof ImportVersion.infer>>({
id: gameId,
const versionSettings = ref<Partial<ImportGameVersion>>({
launches: [],
onlySetup: false,
});
const versionGuesses =
@ -489,7 +503,6 @@ const versionGuesses =
Array<SerializeObject<{ platform: PlatformRenderable; filename: string }>>
>();
function updateLaunchCommand(idx: number, value: string) {
versionSettings.value.launches![idx].launchCommand = value;
autosetPlatform(value);
@ -539,7 +552,7 @@ async function updateCurrentlySelectedVersion(value: number) {
const options = await $dropFetch(
`/api/v1/admin/import/version/preload?id=${encodeURIComponent(
gameId,
)}&version=${encodeURIComponent(version)}`,
)}&version=${encodeURIComponent(version)}&mode=game`,
);
versionGuesses.value = options.map((e) => ({
...e,
@ -555,6 +568,7 @@ async function startImport() {
body: {
id: gameId,
version: versions[currentlySelectedVersion.value],
mode: "game",
...versionSettings.value,
},
});
@ -571,4 +585,26 @@ function startImport_wrapper() {
importLoading.value = false;
});
}
const setupModes: Array<{
id: string;
value: boolean;
title: string;
description: string;
}> = [
{
id: "portable",
value: false,
title: "Portable",
description:
"This mode is for games that are designed to be launched directly from the install directory. Drop works best with these.",
},
{
id: "setup",
value: true,
title: "Installer",
description:
"Also known as 'setup-only', this mode is for installers that modify the system directly, and install to directories like Program Files.",
},
];
</script>

View File

@ -219,7 +219,7 @@ import {
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/vue/20/solid";
import { PuzzlePieceIcon, ArchiveBoxIcon } from "@heroicons/vue/24/solid";
import type { FetchError } from "ofetch";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import type { GameMetadataSearchResult } from "~~/server/internal/metadata/types";
definePageMeta({
layout: "admin",

View File

@ -158,7 +158,7 @@
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/g/${entry.id}`"
:href="`/admin/library/${entry.urlPrefix}/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
@ -221,7 +221,7 @@
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/library/r/${entry.id}`"
:href="`/admin/library/${entry.urlPrefix}/${entry.id}`"
class="w-fit rounded-md bg-zinc-800 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
@ -261,7 +261,7 @@
</p>
<p class="mt-3 text-sm md:ml-6 md:mt-0">
<NuxtLink
:href="`/admin/library/g/${entry.id}/import`"
:href="`/admin/library/${entry.urlPrefix}/${entry.id}/import`"
class="whitespace-nowrap font-medium text-blue-400 hover:text-blue-500"
>
<i18n-t
@ -406,6 +406,7 @@ function clientSideTransformation<T, V extends keyof T, K extends string>(
toImport?: boolean;
offline?: boolean;
};
urlPrefix: string,
}
> {
return values.map((e) => {
@ -418,6 +419,7 @@ function clientSideTransformation<T, V extends keyof T, K extends string>(
notifications: {
offline: true,
},
urlPrefix: type[0],
};
}
@ -433,6 +435,7 @@ function clientSideTransformation<T, V extends keyof T, K extends string>(
},
hasNotifications: noVersions || toImport,
status: "online" as const,
urlPrefix: type[0],
};
});
}

Some files were not shown because too many files have changed in this diff Show More