Store overhaul (#142)

* feat: small library tweaks + company page

* feat: new store view

* fix: ci merge error

* feat: add genres to store page

* feat: sorting

* feat: lock game/version imports while their tasks are running

* feat: feature games

* feat: tag based filtering

* fix: make tags alphabetical

* refactor: move a bunch of i18n to common

* feat: add localizations for everything

* fix: title description on panel

* fix: feature carousel text

* fix: i18n footer strings

* feat: add tag page

* fix: develop merge

* feat: offline games support (don't error out if provider throws)

* feat: tag management

* feat: show library next to game import + small fixes

* feat: most of the company and tag managers

* feat: company text field editing

* fix: small fixes + tsgo experiemental

* feat: upload icon and banner

* feat: store infinite scrolling and bulk import mode

* fix: lint

* fix: add drop-base to prettier ignore
This commit is contained in:
DecDuck
2025-07-30 13:40:49 +10:00
committed by GitHub
parent 1ae051f066
commit 8363de2eed
97 changed files with 3506 additions and 524 deletions

View File

@ -1 +1 @@
drop-base/ drop-base/

View File

@ -148,7 +148,6 @@ type(scope)!: subject
``` ```
- `type`: the type of the commit is one of the following: - `type`: the type of the commit is one of the following:
- `feat`: new features. - `feat`: new features.
- `fix`: bug fixes. - `fix`: bug fixes.
- `docs`: documentation changes. - `docs`: documentation changes.
@ -165,7 +164,6 @@ type(scope)!: subject
- `scope`: section of the codebase that the commit makes changes to. If it makes changes to - `scope`: section of the codebase that the commit makes changes to. If it makes changes to
many sections, or if no section in particular is modified, leave blank without the parentheses. many sections, or if no section in particular is modified, leave blank without the parentheses.
Examples: Examples:
- Commit that changes the `git` plugin: - Commit that changes the `git` plugin:
``` ```
@ -179,7 +177,6 @@ type(scope)!: subject
``` ```
For changes to plugins or themes, the scope should be the plugin or theme name: For changes to plugins or themes, the scope should be the plugin or theme name:
- ✅ `fix(agnoster): commit subject` - ✅ `fix(agnoster): commit subject`
- ❌ `fix(theme/agnoster): commit subject` - ❌ `fix(theme/agnoster): commit subject`
@ -209,7 +206,6 @@ type(scope)!: subject
to specify other details, you can use the commit body, but it won't be visible. to specify other details, you can use the commit body, but it won't be visible.
Formatting tricks: the commit subject may contain: Formatting tricks: the commit subject may contain:
- Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool: - Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool:
``` ```

View File

@ -84,7 +84,7 @@
</Menu> </Menu>
</div> </div>
<CreateCollectionModal <ModalCreateCollection
v-model="createCollectionModal" v-model="createCollectionModal"
:game-id="props.gameId" :game-id="props.gameId"
/> />
@ -122,20 +122,9 @@ async function toggleLibrary() {
body: { body: {
id: props.gameId, id: props.gameId,
}, },
failTitle: t("errors.library.add.title"),
}); });
await refreshLibrary(); await refreshLibrary();
} catch (e) {
createModal(
ModalType.Notification,
{
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} finally { } finally {
isLibraryLoading.value = false; isLibraryLoading.value = false;
} }
@ -147,26 +136,18 @@ async function toggleCollection(id: string) {
if (!collection) return; if (!collection) return;
const index = collection.entries.findIndex((e) => e.gameId == props.gameId); const index = collection.entries.findIndex((e) => e.gameId == props.gameId);
await $dropFetch(`/api/v1/collection/${id}/entry`, { await $dropFetch(`/api/v1/collection/:id/entry`, {
method: index == -1 ? "POST" : "DELETE", method: index == -1 ? "POST" : "DELETE",
params: { id },
body: { body: {
id: props.gameId, id: props.gameId,
}, },
failTitle: t("errors.library.add.title"),
}); });
await refreshCollection(id); await refreshCollection(id);
} catch (e) { } finally {
createModal( /* empty */
ModalType.Notification,
{
title: t("errors.library.add.title"),
description: t("errors.library.add.desc", [
// @ts-expect-error attempt to display statusMessage on error
e?.statusMessage ?? t("errors.unknown"),
]),
},
(_, c) => c(),
);
} }
} }
</script> </script>

View File

@ -31,11 +31,11 @@
<li v-for="game in filteredLibrary" :key="game.id" class="flex"> <li v-for="game in filteredLibrary" :key="game.id" class="flex">
<NuxtLink <NuxtLink
:to="`/library/game/${game.id}`" :to="`/library/game/${game.id}`"
class="flex flex-row items-center w-full p-1 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95" class="flex flex-row items-center w-full p-2 rounded-md transition-all duration-200 hover:bg-zinc-800 hover:scale-105 hover:shadow-lg active:scale-95"
> >
<img <img
:src="useObject(game.mCoverObjectId)" :src="useObject(game.mIconObjectId)"
class="h-9 w-9 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg" class="h-5 flex-shrink-0 rounded transition-all duration-300 group-hover:scale-105 hover:rotate-[-2deg] hover:shadow-lg"
alt="" alt=""
/> />
<div class="min-w-0 flex-1 pl-2.5"> <div class="min-w-0 flex-1 pl-2.5">

View File

@ -44,9 +44,7 @@ const props = defineProps<{
width?: number; width?: number;
}>(); }>();
const { showGamePanelTextDecoration } = await $dropFetch( const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
`/api/v1/admin/settings`,
);
const currentComponent = ref<HTMLDivElement>(); const currentComponent = ref<HTMLDivElement>();

View File

@ -23,10 +23,14 @@
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (showEditCoreMetadata = true)" @click="() => (showEditCoreMetadata = true)"
> >
{{ $t("edit") }} <PencilIcon class="size-4" /> {{ $t("common.edit") }} <PencilIcon class="size-4" />
</button> </button>
</div> </div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 pt-8">
<MultiItemSelector v-model="currentTags" :items="tags" />
</div>
<!-- image carousel pick --> <!-- image carousel pick -->
<div class="border-b border-zinc-700"> <div class="border-b border-zinc-700">
<div class="border-b border-zinc-700 py-4"> <div class="border-b border-zinc-700 py-4">
@ -268,7 +272,7 @@
</div> </div>
</div> </div>
</div> </div>
<UploadFileDialog <ModalUploadFile
v-model="showUploadModal" v-model="showUploadModal"
:options="{ id: game.id }" :options="{ id: game.id }"
accept="image/*" accept="image/*"
@ -314,7 +318,7 @@
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto" class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-700 hover:bg-zinc-950 transition-all duration-200 hover:scale-105 hover:shadow-lg active:scale-95 sm:mt-0 sm:w-auto"
@click="showAddCarouselModal = false" @click="showAddCarouselModal = false"
> >
{{ $t("close") }} {{ $t("common.close") }}
</button> </button>
</template> </template>
</ModalTemplate> </ModalTemplate>
@ -335,7 +339,7 @@
class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => insertImageAtCursor(image)" @click="() => insertImageAtCursor(image)"
> >
{{ $t("insert") }} {{ $t("common.insert") }}
</button> </button>
</div> </div>
</div> </div>
@ -424,7 +428,7 @@
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']" :class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => coreMetadataUpdate_wrapper()" @click="() => coreMetadataUpdate_wrapper()"
> >
{{ $t("save") }} {{ $t("common.save") }}
</LoadingButton> </LoadingButton>
<button <button
ref="cancelButtonRef" ref="cancelButtonRef"
@ -440,7 +444,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { GameModel } from "~/prisma/client/models"; import type { GameModel, GameTagModel } from "~/prisma/client/models";
import { micromark } from "micromark"; import { micromark } from "micromark";
import { import {
CheckIcon, CheckIcon,
@ -451,25 +455,42 @@ import {
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3"; import type { H3Error } from "h3";
definePageMeta({
layout: "admin",
});
const showUploadModal = ref(false); const showUploadModal = ref(false);
const showAddCarouselModal = ref(false); const showAddCarouselModal = ref(false);
const showAddImageDescriptionModal = ref(false); const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false); const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true); const mobileShowFinalDescription = ref(true);
const game = defineModel<SerializeObject<GameModel>>() as Ref< type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
SerializeObject<GameModel> const game = defineModel<ModelType>() as Ref<ModelType>;
>;
if (!game.value) if (!game.value)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: "Game not provided to editor component", statusMessage: "Game not provided to editor component",
}); });
const currentTags = ref<{ [key: string]: boolean }>(
Object.fromEntries(game.value.tags.map((e) => [e.id, true])),
);
const tags = (await $dropFetch("/api/v1/admin/tags")).map(
(e) => ({ name: e.name, param: e.id }) satisfies StoreSortOption,
);
watch(
currentTags,
async (v) => {
await $dropFetch(`/api/v1/admin/game/:id/tags`, {
method: "PATCH",
params: {
id: game.value.id,
},
body: { tags: Object.keys(v) },
failTitle: "Failed to update game tags",
});
},
{ deep: true },
);
const { t } = useI18n(); const { t } = useI18n();
// I don't know why I split these fields off. // I don't know why I split these fields off.
@ -493,7 +514,7 @@ function coreMetadataUploadFiles(e: InputEvent) {
{ {
title: t("errors.upload.title"), title: t("errors.upload.title"),
description: t("errors.upload.description", [t("errors.unknown")]), description: t("errors.upload.description", [t("errors.unknown")]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -510,14 +531,16 @@ async function coreMetadataUpdate() {
formData.append("icon", newIcon); formData.append("icon", newIcon);
} }
formData.append("id", game.value.id);
formData.append("name", coreMetadataName.value); formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.value); formData.append("description", coreMetadataDescription.value);
const result = await $dropFetch(`/api/v1/admin/game/metadata`, { const result = await $dropFetch(
method: "POST", `/api/v1/admin/game/${game.value.id}/metadata`,
body: formData, {
}); method: "POST",
body: formData,
},
);
return result; return result;
} }
@ -532,14 +555,16 @@ function coreMetadataUpdate_wrapper() {
description: t("errors.game.metadata.description", [ description: t("errors.game.metadata.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.statusMessage ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
}) })
.then((newGame) => { .then((newGame) => {
console.log(newGame);
if (!newGame) return; if (!newGame) return;
Object.assign(game.value, newGame); Object.assign(game.value, newGame);
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);
}) })
.finally(() => { .finally(() => {
coreMetadataLoading.value = false; coreMetadataLoading.value = false;
@ -573,10 +598,12 @@ watch(descriptionHTML, (_v) => {
savingTimeout = setTimeout(async () => { savingTimeout = setTimeout(async () => {
try { try {
descriptionSaving.value = DescriptionSavingState.Loading; descriptionSaving.value = DescriptionSavingState.Loading;
await $dropFetch("/api/v1/admin/game", { await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mDescription: game.value.mDescription, mDescription: game.value.mDescription,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -589,7 +616,7 @@ watch(descriptionHTML, (_v) => {
description: t("errors.game.description.description", [ description: t("errors.game.description.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.statusMessage ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -617,10 +644,12 @@ function insertImageAtCursor(id: string) {
async function updateBannerImage(id: string) { async function updateBannerImage(id: string) {
try { try {
if (game.value.mBannerObjectId == id) return; if (game.value.mBannerObjectId == id) return;
const { mBannerObjectId } = await $dropFetch("/api/v1/admin/game", { const { mBannerObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mBannerObjectId: id, mBannerObjectId: id,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -633,7 +662,7 @@ async function updateBannerImage(id: string) {
description: t("errors.game.banner.description", [ description: t("errors.game.banner.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.statusMessage ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -643,10 +672,12 @@ async function updateBannerImage(id: string) {
async function updateCoverImage(id: string) { async function updateCoverImage(id: string) {
try { try {
if (game.value.mCoverObjectId == id) return; if (game.value.mCoverObjectId == id) return;
const { mCoverObjectId } = await $dropFetch("/api/v1/admin/game", { const { mCoverObjectId } = await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mCoverObjectId: id, mCoverObjectId: id,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -659,7 +690,7 @@ async function updateCoverImage(id: string) {
description: t("errors.game.cover.description", [ description: t("errors.game.cover.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.statusMessage ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -688,7 +719,7 @@ async function deleteImage(id: string) {
description: t("errors.game.deleteImage.description", [ description: t("errors.game.deleteImage.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.statusMessage ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -715,10 +746,12 @@ function removeImageFromCarousel(id: string) {
async function updateImageCarousel() { async function updateImageCarousel() {
try { try {
await $dropFetch("/api/v1/admin/game", { await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH", method: "PATCH",
body: { params: {
id: game.value.id, id: game.value.id,
},
body: {
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds, mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
} satisfies PatchGameBody, } satisfies PatchGameBody,
}); });
@ -730,7 +763,7 @@ async function updateImageCarousel() {
description: t("errors.game.carousel.description", [ description: t("errors.game.carousel.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"), (e as H3Error)?.statusMessage ?? t("errors.unknown"),
]), ]),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );

View File

@ -1,6 +1,6 @@
<!-- eslint-disable vue/no-v-html --> <!-- eslint-disable vue/no-v-html -->
<template> <template>
<div v-if="game"> <div v-if="game && unimportedVersions">
<div class="grow flex flex-row gap-y-8"> <div class="grow flex flex-row gap-y-8">
<div class="grow w-full h-full px-6 py-4 flex flex-col"></div> <div class="grow w-full h-full px-6 py-4 flex flex-col"></div>
<div <div
@ -22,7 +22,6 @@
<!-- import games button --> <!-- import games button -->
<NuxtLink <NuxtLink
v-if="unimportedVersions !== undefined"
:href=" :href="
unimportedVersions.length > 0 unimportedVersions.length > 0
? `/admin/library/${game.id}/import` ? `/admin/library/${game.id}/import`
@ -96,6 +95,24 @@
</div> </div>
</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> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -103,10 +120,7 @@ import type { GameModel, GameVersionModel } from "~/prisma/client/models";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid"; import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack"; import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3"; import type { H3Error } from "h3";
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
definePageMeta({
layout: "admin",
});
// TODO implement UI for this page // TODO implement UI for this page
@ -142,7 +156,7 @@ async function updateVersionOrder() {
description: t("errors.version.order.desc", { description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"), error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}), }),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );
@ -170,7 +184,7 @@ async function deleteVersion(versionName: string) {
description: t("errors.version.delete.desc", { description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"), error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}), }),
buttonText: t("close"), buttonText: t("common.close"),
}, },
(e, c) => c(), (e, c) => c(),
); );

View File

@ -6,7 +6,7 @@
'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5': 'transition-all duration-300 text-left hover:scale-[1.02] hover:shadow-lg hover:-translate-y-0.5':
animate, animate,
}" }"
class="group relative w-48 h-64 rounded-lg overflow-hidden" class="group relative flex-1 min-w-42 max-w-48 h-64 rounded-lg overflow-hidden"
> >
<div <div
:class="{ :class="{
@ -20,7 +20,8 @@
:alt="imageProps.alt" :alt="imageProps.alt"
/> />
<div <div
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/20 to-transparent" v-if="showTitleDescription"
class="absolute inset-0 bg-gradient-to-t from-zinc-950/80 via-zinc-950/0 to-transparent"
/> />
</div> </div>

View File

@ -19,6 +19,6 @@
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types"; import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
const { game } = defineProps<{ const { game } = defineProps<{
game: GameMetadataSearchResult & { sourceName?: string }; game: Omit<GameMetadataSearchResult, "year"> & { sourceName?: string };
}>(); }>();
</script> </script>

View File

@ -0,0 +1,246 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.companies.addGame.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.companies.addGame.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => addGame()">
<Listbox v-model="currentGame" as="div">
<ListboxLabel
class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("library.admin.import.selectGameSearch") }}</ListboxLabel
>
<div class="relative mt-2">
<ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
>
<GameSearchResultWidget
v-if="currentGame"
:game="currentGame"
/>
<span v-else class="block truncate text-zinc-600">
{{ $t("library.admin.import.selectGamePlaceholder") }}
</span>
<span
class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"
>
<ChevronUpDownIcon
class="h-5 w-5 text-gray-400"
aria-hidden="true"
/>
</span>
</ListboxButton>
<transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<ListboxOption
v-for="result in metadataGames"
:key="result.id"
v-slot="{ active }"
as="template"
:value="result"
>
<li
:class="[
active ? 'bg-blue-600 text-white' : 'text-zinc-100',
'relative cursor-default select-none py-2 pl-3 pr-9',
]"
>
<GameSearchResultWidget :game="result" />
</li>
</ListboxOption>
<p
v-if="metadataGames.length == 0"
class="w-full text-center p-2 uppercase font-display text-zinc-700 font-bold"
>
{{ $t("library.admin.metadata.companies.addGame.noGames") }}
</p>
</ListboxOptions>
</transition>
</div>
</Listbox>
<div class="mt-6 flex items-center justify-between gap-3">
<label
id="published-label"
for="published"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.publisher")
}}</label
>
<div
class="group/published relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-5"
/>
<input
id="published"
v-model="published"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
</div>
<div class="mt-2 flex items-center justify-between gap-3">
<label
id="developer-label"
for="developer"
class="font-medium text-md text-zinc-100"
>{{
$t("library.admin.metadata.companies.addGame.developer")
}}</label
>
<div
class="group/developer relative inline-flex w-11 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developer:translate-x-5"
/>
<input
id="developer"
v-model="developed"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developer-label"
/>
</div>
</div>
<button class="hidden" type="submit" />
</form>
</div>
<div v-if="addError" class="mt-3 rounded-md bg-red-600/10 p-4">
<div class="flex">
<div class="flex-shrink-0">
<XCircleIcon class="h-5 w-5 text-red-600" aria-hidden="true" />
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ addError }}
</h3>
</div>
</div>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="addGameLoading"
:disabled="!(currentGame && (developed || published))"
class="w-full sm:w-fit"
@click="() => addGame()"
>
{{ $t("common.add") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import type { GameModel } from "~/prisma/client/models";
import {
DialogTitle,
Listbox,
ListboxButton,
ListboxLabel,
ListboxOption,
ListboxOptions,
} from "@headlessui/vue";
import type { GameMetadataSearchResult } from "~/server/internal/metadata/types";
import { FetchError } from "ofetch";
import type { SerializeObject } from "nitropack";
import { XCircleIcon } from "@heroicons/vue/24/solid";
const props = defineProps<{
companyId: string;
exclude?: string[];
}>();
const emit = defineEmits<{
created: [
game: SerializeObject<GameModel>,
published: boolean,
developed: boolean,
];
}>();
const games = await $dropFetch("/api/v1/admin/game");
const metadataGames = computed(() =>
games
.filter((e) => !(props.exclude ?? []).includes(e.id))
.map(
(e) =>
({
id: e.id,
name: e.mName,
icon: useObject(e.mIconObjectId),
description: e.mShortDescription,
}) satisfies Omit<GameMetadataSearchResult, "year">,
),
);
const { t } = useI18n();
const open = defineModel<boolean>({ required: true });
const currentGame = ref<(typeof metadataGames.value)[number]>();
const developed = ref(false);
const published = ref(false);
const addGameLoading = ref(false);
const addError = ref<string | undefined>(undefined);
async function addGame() {
if (!currentGame.value) return;
addGameLoading.value = true;
try {
const game = await $dropFetch("/api/v1/admin/company/:id/game", {
method: "POST",
params: { id: props.companyId },
body: {
id: currentGame.value.id,
developed: developed.value,
published: published.value,
},
});
emit("created", game, published.value, developed.value);
} catch (e) {
if (e instanceof FetchError) {
addError.value = e.statusMessage ?? e.message ?? t("errors.unknown");
} else {
throw e;
}
} finally {
currentGame.value = undefined;
developed.value = false;
published.value = false;
addGameLoading.value = false;
open.value = false;
}
}
</script>

View File

@ -29,7 +29,7 @@
class="w-full sm:w-fit" class="w-full sm:w-fit"
@click="() => createCollection()" @click="() => createCollection()"
> >
{{ $t("create") }} {{ $t("common.create") }}
</LoadingButton> </LoadingButton>
<button <button
ref="cancelButtonRef" ref="cancelButtonRef"

View File

@ -0,0 +1,78 @@
<template>
<ModalTemplate v-model="open">
<template #default>
<div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white">
{{ $t("library.admin.metadata.tags.modal.title") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.metadata.tags.modal.description") }}
</p>
</div>
<div class="mt-2">
<form @submit.prevent="() => createTag()">
<input
v-model="tagName"
type="text"
class="block w-full rounded-md border-0 bg-zinc-800 py-1.5 text-white shadow-sm ring-1 ring-inset ring-zinc-700 placeholder:text-zinc-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6"
/>
<button class="hidden" type="submit" />
</form>
</div>
</template>
<template #buttons="{ close }">
<LoadingButton
:loading="createTagLoading"
:disabled="!tagName"
class="w-full sm:w-fit"
@click="() => createTag()"
>
{{ $t("common.create") }}
</LoadingButton>
<button
ref="cancelButtonRef"
type="button"
class="mt-3 inline-flex w-full justify-center rounded-md bg-zinc-800 px-3 py-2 text-sm font-semibold text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 hover:bg-zinc-900 sm:mt-0 sm:w-auto"
@click="() => close()"
>
{{ $t("cancel") }}
</button>
</template>
</ModalTemplate>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { DialogTitle } from "@headlessui/vue";
import type { GameTagModel } from "~/prisma/client/models";
const emit = defineEmits<{
created: [tag: GameTagModel];
}>();
const open = defineModel<boolean>({ required: true });
const tagName = ref("");
const createTagLoading = ref(false);
async function createTag() {
if (!tagName.value || createTagLoading.value) return;
createTagLoading.value = true;
// Create the collection
const tag = await $dropFetch("/api/v1/admin/tags", {
method: "POST",
body: { name: tagName.value },
failTitle: "Failed to create tag",
});
// Reset and emit
tagName.value = "";
open.value = false;
emit("created", tag);
createTagLoading.value = false;
}
</script>

View File

@ -49,9 +49,11 @@ async function deleteCollection() {
if (!collection.value) return; if (!collection.value) return;
deleteLoading.value = true; deleteLoading.value = true;
await $dropFetch(`/api/v1/collection/${collection.value.id}`, { await $dropFetch(`/api/v1/collection/:id`, {
// @ts-expect-error not documented
method: "DELETE", method: "DELETE",
params: {
id: collection.value.id,
},
}); });
const index = collections.value.findIndex( const index = collections.value.findIndex(
(e) => e.id == collection.value?.id, (e) => e.id == collection.value?.id,

View File

@ -0,0 +1,115 @@
<template>
<div>
<div class="inline-flex gap-1 items-center flex-wrap">
<span
v-for="item in enabledItems"
:key="item.param"
class="inline-flex items-center gap-x-0.5 rounded-md bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-500 ring-1 ring-blue-800 ring-inset"
>
{{ item.name }}
<button
type="button"
class="group relative -mr-1 size-3.5 rounded-xs hover:bg-blue-600/20"
@click="() => remove(item.param)"
>
<span class="sr-only">{{ $t("common.remove") }}</span>
<svg
viewBox="0 0 14 14"
class="size-3.5 stroke-blue-500 group-hover:stroke-blue-400"
>
<path d="M4 4l6 6m0-6l-6 6" />
</svg>
<span class="absolute -inset-1" />
</button>
</span>
<span
v-if="enabledItems.length == 0"
class="font-display uppercase text-xs font-bold text-zinc-700"
>
{{ $t("common.noSelected") }}
</span>
</div>
<Combobox as="div" @update:model-value="add">
<div class="relative mt-2">
<ComboboxInput
class="block w-full rounded-md bg-zinc-900 py-1.5 pr-12 pl-3 text-base text-zinc-100 outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-zinc-500 focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:text-sm/6"
:display-value="(item) => (item as StoreSortOption)?.name"
placeholder="Start typing..."
@change="search = $event.target.value"
@blur="search = ''"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center rounded-r-md px-2 focus:outline-hidden"
>
<ChevronDownIcon class="size-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
<ComboboxOptions
v-if="filteredItems.length > 0 || search.length > 0"
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-white/5 focus:outline-hidden sm:text-sm"
>
<ComboboxOption
v-for="item in filteredItems"
:key="item.param"
v-slot="{ active }"
:value="item.param"
as="template"
>
<li
:class="[
'relative cursor-default py-2 pr-9 pl-3 select-none',
active
? 'bg-blue-600 text-white outline-hidden'
: 'text-zinc-100',
]"
>
<span class="block truncate">
{{ item.name }}
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</Combobox>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon } from "@heroicons/vue/20/solid";
import {
Combobox,
ComboboxButton,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/vue";
const props = defineProps<{
items: Array<StoreSortOption>;
}>();
const model = defineModel<{ [key: string]: boolean }>();
const search = ref("");
const filteredItems = computed(() =>
props.items.filter(
(item) =>
!model.value?.[item.param] &&
item.name.toLowerCase().includes(search.value.toLowerCase()),
),
);
const enabledItems = computed(() =>
props.items.filter((e) => model.value?.[e.param]),
);
function add(item: string) {
search.value = "";
model.value ??= {};
model.value[item] = true;
}
function remove(item: string) {
model.value ??= {};
model.value[item] = false;
}
</script>

View File

@ -33,7 +33,7 @@
class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2" class="inline-flex rounded-md text-zinc-400 hover:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="() => deleteMe()" @click="() => deleteMe()"
> >
<span class="sr-only">{{ $t("close") }}</span> <span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-5" aria-hidden="true" /> <XMarkIcon class="size-5" aria-hidden="true" />
</button> </button>
</div> </div>
@ -49,8 +49,11 @@ import type { NotificationModel } from "~/prisma/client/models";
const props = defineProps<{ notification: NotificationModel }>(); const props = defineProps<{ notification: NotificationModel }>();
async function deleteMe() { async function deleteMe() {
await $dropFetch(`/api/v1/notifications/${props.notification.id}`, { await $dropFetch(`/api/v1/notifications/:id`, {
method: "DELETE", method: "DELETE",
params: {
id: props.notification.id,
},
}); });
const notifications = useNotifications(); const notifications = useNotifications();
const indexOfMe = notifications.value.findIndex( const indexOfMe = notifications.value.findIndex(

488
components/StoreView.vue Normal file
View File

@ -0,0 +1,488 @@
<template>
<div>
<div>
<!-- Mobile filter dialog -->
<TransitionRoot as="template" :show="mobileFiltersOpen">
<Dialog
class="relative z-100 lg:hidden"
@close="mobileFiltersOpen = false"
>
<TransitionChild
as="template"
enter="transition-opacity ease-linear duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="transition-opacity ease-linear duration-300"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/25" />
</TransitionChild>
<div class="fixed inset-0 z-40 flex">
<TransitionChild
as="template"
enter="transition ease-in-out duration-300 transform"
enter-from="translate-x-full"
enter-to="translate-x-0"
leave="transition ease-in-out duration-300 transform"
leave-from="translate-x-0"
leave-to="translate-x-full"
>
<DialogPanel
class="relative ml-auto flex size-full max-w-sm flex-col overflow-y-auto bg-zinc-900 pt-4 pb-6 shadow-xl"
>
<div class="flex items-center justify-between px-4">
<h2 class="text-lg font-medium text-zinc-100">
{{ $t("store.view.srFilters") }}
</h2>
<button
type="button"
class="relative -mr-2 flex size-10 items-center justify-center rounded-md bg-zinc-900 p-2 text-zinc-500 hover:bg-zinc-800 focus:ring-2 focus:ring-blue-500 focus:outline-hidden"
@click="mobileFiltersOpen = false"
>
<span class="absolute -inset-0.5" />
<span class="sr-only">{{ $t("common.close") }}</span>
<XMarkIcon class="size-6" aria-hidden="true" />
</button>
</div>
<!-- Filters -->
<form class="mt-4 border-t border-zinc-700">
<Disclosure
v-for="section in options"
v-slot="{ open }"
:key="section.param"
as="div"
class="border-t border-zinc-700 px-4 py-6"
>
<h3 class="-mx-2 -my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 px-2 py-3 text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div
v-if="section.options.length <= 10"
class="gap-3 grid grid-cols-2"
>
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${option}`"
v-model="
(optionValues[section.param] as any)[
option.param
]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-900 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-gray-300 bg-white checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@update:value="
() =>
(optionValues[section.param] = option.param)
"
/>
</div>
</div>
<label
:for="`filter-mobile-${section.param}-${optionIdx}`"
class="min-w-0 flex-1 text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</TransitionRoot>
<main class="mx-auto px-4 sm:px-6 lg:px-8">
<div
class="flex items-baseline justify-between border-b border-zinc-700 py-6"
>
<div />
<div class="flex items-center">
<Menu as="div" class="relative inline-block text-left">
<div>
<MenuButton
class="group inline-flex justify-center text-sm font-medium text-zinc-400 hover:text-zinc-100"
>
{{ $t("store.view.sort") }}
<ChevronDownIcon
class="-mr-1 ml-1 size-5 shrink-0 text-gray-400 group-hover:text-zinc-100"
aria-hidden="true"
/>
</MenuButton>
</div>
<transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<MenuItems
class="absolute right-0 z-10 mt-2 w-40 origin-top-right rounded-md bg-zinc-950 shadow-2xl ring-1 ring-white/5 focus:outline-hidden"
>
<div class="py-1">
<MenuItem
v-for="option in sorts"
:key="option.param"
v-slot="{ active }"
>
<button
:class="[
currentSort == option.param
? 'font-medium text-zinc-100'
: 'text-zinc-400',
active ? 'bg-zinc-900 outline-hidden' : '',
'w-full text-left block px-4 py-2 text-sm',
]"
@click="() => (currentSort = option.param)"
>
{{ option.name }}
</button>
</MenuItem>
</div>
</MenuItems>
</transition>
</Menu>
<button
v-if="false"
type="button"
class="-m-2 ml-5 p-2 text-zinc-500 hover:text-zinc-400 sm:ml-7"
>
<span class="sr-only">{{ $t("store.view.srViewGrid") }}</span>
<Squares2X2Icon class="size-5" aria-hidden="true" />
</button>
<button
type="button"
:class="[
'-m-2 ml-4 p-2 sm:ml-6 lg:hidden',
filterQuery
? 'text-zinc-100 hover:text-zinc-200'
: 'text-zinc-500 hover:text-zinc-400',
]"
@click="mobileFiltersOpen = true"
>
<span class="sr-only"> {{ $t("store.view.srFilters") }} </span>
<FunnelIcon class="size-5" aria-hidden="true" />
</button>
</div>
</div>
<section aria-labelledby="games-heading" class="pt-6 pb-24">
<h2 id="games-heading" class="sr-only">
{{ $t("store.view.srGames") }}
</h2>
<div class="grid grid-cols-1 gap-x-8 gap-y-10 lg:grid-cols-5">
<!-- Filters -->
<form class="hidden lg:block">
<Disclosure
v-for="section in options"
:key="section.param"
v-slot="{ open }"
as="div"
class="border-b border-zinc-700 py-6"
>
<h3 class="-my-3 flow-root">
<DisclosureButton
class="flex w-full items-center justify-between bg-zinc-900 py-3 text-sm text-zinc-500 hover:text-zinc-400"
>
<span class="font-medium text-zinc-100">{{
section.name
}}</span>
<span class="ml-6 flex items-center">
<PlusIcon
v-if="!open"
class="size-5"
aria-hidden="true"
/>
<MinusIcon v-else class="size-5" aria-hidden="true" />
</span>
</DisclosureButton>
</h3>
<DisclosurePanel class="pt-6">
<div v-if="section.options.length <= 10" class="space-y-4">
<div
v-for="(option, optionIdx) in section.options"
:key="option.param"
class="flex gap-3"
>
<div class="flex h-5 shrink-0 items-center">
<div class="group grid size-4 grid-cols-1">
<input
v-if="section.multiple"
:id="`filter-${section.param}-${optionIdx}`"
v-model="
(optionValues[section.param] as any)[option.param]
"
:name="`${section.param}[]`"
type="checkbox"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
/>
<input
v-else
:id="`filter-${section.param}-${optionIdx}`"
:value="optionValues[section.param]"
:name="`${section.param}[]`"
type="radio"
class="col-start-1 row-start-1 appearance-none rounded-sm border border-zinc-700 bg-zinc-800 checked:border-blue-600 checked:bg-blue-600 indeterminate:border-blue-600 indeterminate:bg-blue-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:border-gray-300 disabled:bg-gray-100 disabled:checked:bg-gray-100 forced-colors:appearance-auto"
@input="optionValues[section.param] = option.param"
/>
</div>
</div>
<label
:for="`filter-${section.param}-${optionIdx}`"
class="text-sm text-zinc-400"
>{{ option.name }}</label
>
</div>
</div>
<MultiItemSelector
v-else
v-model="[optionValues[section.param] as any][0]"
:items="section.options"
/>
</DisclosurePanel>
</Disclosure>
</form>
<!-- Product grid -->
<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-5 2xl:grid-cols-6 gap-4"
>
<!-- Your content -->
<GamePanel
v-for="game in games"
:key="game.id"
:game="game"
:href="`/store/${game.id}`"
:show-title-description="showGamePanelTextDecoration"
/>
<div
v-if="loading"
class="absolute inset-0 bg-zinc-900/40 flex items-center justify-center"
>
<svg
aria-hidden="true"
class="w-8 h-8 text-transparent animate-spin fill-blue-600"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
</div>
</div>
<div v-else class="flex lg:col-span-4 items-start justify-center">
<span class="uppercase text-zinc-700 font-display font-bold">{{
$t("common.noResults")
}}</span>
</div>
</div>
</section>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import {
Dialog,
DialogPanel,
Disclosure,
DisclosureButton,
DisclosurePanel,
Menu,
MenuButton,
MenuItem,
MenuItems,
TransitionChild,
TransitionRoot,
} from "@headlessui/vue";
import { XMarkIcon } from "@heroicons/vue/24/outline";
import {
ChevronDownIcon,
FunnelIcon,
MinusIcon,
PlusIcon,
Squares2X2Icon,
} from "@heroicons/vue/20/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import MultiItemSelector from "./MultiItemSelector.vue";
const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`);
const mobileFiltersOpen = ref(false);
const props = defineProps<{
params?: { [key: string]: string };
extraOptions?: Array<StoreFilterOption>;
prefilled?: {
[key: string]: { [key: string]: string | { [key: string]: boolean } };
};
}>();
const tags =
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/store/tags");
const sorts: Array<StoreSortOption> = [
{
name: "Default",
param: "default",
},
{
name: "Newest",
param: "newest",
},
{
name: "Recently Added",
param: "recent",
},
];
const currentSort = ref(sorts[0].param);
const options: Array<StoreFilterOption> = [
...(tags.length > 0
? [
{
name: "Tags",
param: "tags",
multiple: true,
options: tags.map((e) => ({ name: e.name, param: e.id })),
},
]
: []),
{
name: "Platform",
param: "platform",
multiple: true,
options: Object.values(PlatformClient).map((e) => ({ name: e, param: e })),
},
...(props.extraOptions ?? []),
];
const optionValues = ref<{
[key: string]: string | undefined | { [key: string]: boolean | undefined };
}>(
Object.fromEntries(
options.map((v) => [v.param, v.multiple ? {} : undefined]),
),
);
Object.assign(optionValues.value, props.prefilled);
const filterQuery = computed(() => {
const query = Object.entries(optionValues.value)
.filter(
([_, v]) =>
v &&
(typeof v !== "object" || Object.values(v).filter((e) => e).length > 0),
)
.map(([n, v]) => {
if (typeof v === "string") return [`${n}=${v}`];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const enabledOptions = Object.entries(v as any).filter(([_, e]) => e);
return `${n}=${enabledOptions.map(([k, _]) => k).join(",")}`;
})
.join("&");
const extraFilters = props.params
? Object.entries(props.params)
.map(([k, v]) => `${k}=${v}`)
.join("&")
: props.params;
return `${query}${extraFilters ? (query ? "&" : "") + extraFilters : ""}`;
});
const games = ref<Array<SerializeObject<GameModel>>>();
const loading = ref(false);
const productGrid = useTemplateRef<HTMLElement>("product-grid");
const { reset } = useInfiniteScroll(
productGrid,
async () => await updateGames(filterQuery.value, false),
{
distance: 10,
canLoadMore: () => {
return canLoadMore.value;
},
},
);
const canLoadMore = ref(true);
async function updateGames(query: string, resetGames: boolean) {
loading.value = true;
games.value ??= [];
const newValues = await $dropFetch<{
results: Array<SerializeObject<GameModel>>;
count: number;
}>(
`/api/v1/store?take=50&skip=${resetGames ? 0 : games.value?.length || 0}&sort=${currentSort.value}${query ? "&" + query : ""}`,
);
if (resetGames) {
games.value = newValues.results;
if (import.meta.client) await reset();
} else {
games.value.push(...newValues.results);
}
canLoadMore.value = games.value.length < newValues.count;
loading.value = false;
}
watch(filterQuery, (newUrl) => {
updateGames(newUrl, true);
});
watch(currentSort, (_) => {
updateGames(filterQuery.value, true);
});
await updateGames(filterQuery.value, true);
</script>

View File

@ -116,7 +116,7 @@ const { t } = useI18n();
const versionInfo = await $dropFetch("/api/v1"); const versionInfo = await $dropFetch("/api/v1");
const navigation = { const navigation = computed(() => ({
games: [ games: [
{ name: t("store.recentlyAdded"), href: "#" }, { name: t("store.recentlyAdded"), href: "#" },
{ name: t("store.recentlyReleased"), href: "#" }, { name: t("store.recentlyReleased"), href: "#" },
@ -156,5 +156,5 @@ const navigation = {
icon: IconsDiscordLogo, icon: IconsDiscordLogo,
}, },
], ],
}; }));
</script> </script>

View File

@ -4,6 +4,7 @@ import type {
NitroFetchRequest, NitroFetchRequest,
TypedInternalResponse, TypedInternalResponse,
} from "nitropack/types"; } from "nitropack/types";
import type { FetchError } from "ofetch";
interface DropFetch< interface DropFetch<
DefaultT = unknown, DefaultT = unknown,
@ -15,7 +16,7 @@ interface DropFetch<
O extends NitroFetchOptions<R> = NitroFetchOptions<R>, O extends NitroFetchOptions<R> = NitroFetchOptions<R>,
>( >(
request: R, request: R,
opts?: O, opts?: O & { failTitle?: string },
): Promise< ): Promise<
// sometimes there is an error, other times there isn't // sometimes there is an error, other times there isn't
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -28,12 +29,29 @@ interface DropFetch<
>; >;
} }
export const $dropFetch: DropFetch = async (request, opts) => { export const $dropFetch: DropFetch = async (rawRequest, opts) => {
const requestParts = rawRequest.toString().split("/");
requestParts.forEach((part, index) => {
if (!part.startsWith(":")) {
return;
}
const partName = part.slice(1);
const replacement = opts?.params?.[partName] as string | undefined;
if (!replacement) {
return;
}
requestParts[index] = replacement;
delete opts?.params?.[partName];
});
const request = requestParts.join("/");
if (!getCurrentInstance()?.proxy) { if (!getCurrentInstance()?.proxy) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Excessive stack depth comparing types // @ts-ignore Excessive stack depth comparing types
return await $fetch(request, opts); return await $fetch(request, opts);
} }
const id = request.toString(); const id = request.toString();
const state = useState(id); const state = useState(id);
@ -41,15 +59,31 @@ export const $dropFetch: DropFetch = async (request, opts) => {
// Deep copy // Deep copy
const object = JSON.parse(JSON.stringify(state.value)); const object = JSON.parse(JSON.stringify(state.value));
// Never use again on client // Never use again on client
state.value = undefined; if (import.meta.client) state.value = undefined;
return object; return object;
} }
const headers = useRequestHeaders(["cookie", "authorization"]); const headers = useRequestHeaders(["cookie", "authorization"]);
const data = await $fetch(request, { try {
...opts, const data = await $fetch(request, {
headers: { ...opts?.headers, ...headers }, ...opts,
}); headers: { ...opts?.headers, ...headers },
if (import.meta.server) state.value = data; });
return data; if (import.meta.server) state.value = data;
return data;
} catch (e) {
if (import.meta.client && opts?.failTitle) {
createModal(
ModalType.Notification,
{
title: opts.failTitle,
description:
(e as FetchError)?.statusMessage ?? (e as string).toString(),
buttonText: $t("common.close"),
},
(_, c) => c(),
);
}
throw e;
}
}; };

11
composables/store.ts Normal file
View File

@ -0,0 +1,11 @@
export type StoreFilterOption = {
name: string;
param: string;
options: Array<StoreSortOption>;
multiple?: boolean;
};
export type StoreSortOption = {
name: string;
param: string;
};

View File

@ -18,6 +18,7 @@ export default withNuxt([
extensions: [".js", ".vue", ".ts"], extensions: [".js", ".vue", ".ts"],
}, },
], ],
"@intlify/vue-i18n/no-missing-keys": "error",
}, },
settings: { settings: {
"vue-i18n": { "vue-i18n": {

View File

@ -24,8 +24,8 @@
}, },
"actions": "Deeds", "actions": "Deeds",
"add": "Add", "add": "Add",
"adminTitle": "Cap'n's Quarters | Drop", "adminTitle": "Cap'n's Quarters - Drop",
"adminTitleTemplate": "{0} | Cap'n | Drop", "adminTitleTemplate": "{0} - Cap'n - Drop",
"auth": { "auth": {
"callback": { "callback": {
"authClient": "Grant passage to this scallywag?", "authClient": "Grant passage to this scallywag?",
@ -70,27 +70,30 @@
"quoted": "\"\"", "quoted": "\"\"",
"srComma": ", {0}" "srComma": ", {0}"
}, },
"close": "Shut yer trap!",
"common": { "common": {
"cannotUndo": "This deed cannot be undone, ye hear!", "cannotUndo": "This deed cannot be undone, ye hear!",
"close": "Shut yer trap!",
"create": "Forge!",
"date": "Date", "date": "Date",
"deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?", "deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?",
"divider": "{'|'}", "divider": "{'|'}",
"edit": "Amend",
"friends": "Shipmates", "friends": "Shipmates",
"groups": "Crews", "groups": "Crews",
"insert": "Insert",
"name": "Name, argh!",
"noResults": "No plunder found!", "noResults": "No plunder found!",
"save": "Stow it!",
"servers": "Ships", "servers": "Ships",
"srLoading": "Loading, loading, argh...", "srLoading": "Loading, loading, argh...",
"tags": "Marks", "tags": "Marks",
"today": "Today" "today": "Today"
}, },
"create": "Forge!",
"delete": "Scuttle!", "delete": "Scuttle!",
"drop": { "drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!", "desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!",
"drop": "Drop" "drop": "Drop"
}, },
"edit": "Amend",
"editor": { "editor": {
"bold": "Bold, like a cannonball!", "bold": "Bold, like a cannonball!",
"boldPlaceholder": "bold text, matey", "boldPlaceholder": "bold text, matey",
@ -214,7 +217,6 @@
"helpUsTranslate": "Help us translate Drop {arrow}, argh!", "helpUsTranslate": "Help us translate Drop {arrow}, argh!",
"highest": "highest", "highest": "highest",
"home": "Home Port", "home": "Home Port",
"insert": "Insert",
"library": { "library": {
"addGames": "All Plunder", "addGames": "All Plunder",
"addToLib": "Add to Yer Treasure Hoard", "addToLib": "Add to Yer Treasure Hoard",
@ -327,7 +329,6 @@
"subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!" "subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!"
}, },
"lowest": "lowest", "lowest": "lowest",
"name": "Name, argh!",
"news": { "news": {
"article": { "article": {
"add": "Add, ye dog!", "add": "Add, ye dog!",
@ -360,7 +361,6 @@
"title": "Latest News from the High Seas" "title": "Latest News from the High Seas"
}, },
"options": "Options, matey!", "options": "Options, matey!",
"save": "Stow it!",
"security": "Safety", "security": "Safety",
"selectLanguage": "Pick yer tongue", "selectLanguage": "Pick yer tongue",
"settings": "Settings", "settings": "Settings",

View File

@ -23,10 +23,9 @@
"title": "Account Settings" "title": "Account Settings"
}, },
"actions": "Actions", "actions": "Actions",
"adminTitle": "Admin Dashboard | Drop", "add": "Add",
"adminTitleTemplate": "{0} | Admin | Drop", "adminTitle": "Admin Dashboard - Drop",
"title": "Drop", "adminTitleTemplate": "{0} - Admin - Drop",
"titleTemplate": "{0} | Drop",
"auth": { "auth": {
"callback": { "callback": {
"authClient": "Authorize client?", "authClient": "Authorize client?",
@ -71,27 +70,34 @@
"quoted": "\"\"", "quoted": "\"\"",
"srComma": ", {0}" "srComma": ", {0}"
}, },
"close": "Close",
"common": { "common": {
"cannotUndo": "This action cannot be undone.", "cannotUndo": "This action cannot be undone.",
"close": "Close",
"create": "Create",
"date": "Date", "date": "Date",
"deleteConfirm": "Are you sure you want to delete \"{0}\"?", "deleteConfirm": "Are you sure you want to delete \"{0}\"?",
"divider": "{'|'}",
"edit": "Edit",
"friends": "Friends", "friends": "Friends",
"groups": "Groups", "groups": "Groups",
"insert": "Insert",
"name": "Name",
"noResults": "No results", "noResults": "No results",
"noSelected": "No items selected.",
"remove": "Remove",
"save": "Save",
"saved": "Saved",
"servers": "Servers", "servers": "Servers",
"srLoading": "Loading...",
"tags": "Tags", "tags": "Tags",
"today": "Today", "today": "Today",
"divider": "{'|'}", "add": "Add"
"srLoading": "Loading..."
}, },
"create": "Create",
"delete": "Delete", "delete": "Delete",
"drop": { "drop": {
"desc": "An open-source game distribution platform built for speed, flexibility and beauty.", "desc": "An open-source game distribution platform built for speed, flexibility and beauty.",
"drop": "Drop" "drop": "Drop"
}, },
"edit": "Edit",
"editor": { "editor": {
"bold": "Bold", "bold": "Bold",
"boldPlaceholder": "bold text", "boldPlaceholder": "bold text",
@ -107,17 +113,6 @@
"listItemPlaceholder": "list item" "listItemPlaceholder": "list item"
}, },
"errors": { "errors": {
"auth": {
"method": {
"signinDisabled": "Sign in method not enabled"
},
"invalidUserOrPass": "Invalid username or password.",
"disabled": "Invalid or disabled account. Please contact the server administrator.",
"invalidPassState": "Invalid password state. Please contact the server administrator.",
"inviteIdRequired": "id required in fetching invitation",
"invalidInvite": "Invalid or expired invitation",
"usernameTaken": "Username already taken."
},
"admin": { "admin": {
"user": { "user": {
"delete": { "delete": {
@ -126,7 +121,44 @@
} }
} }
}, },
"auth": {
"disabled": "Invalid or disabled account. Please contact the server administrator.",
"invalidInvite": "Invalid or expired invitation",
"invalidPassState": "Invalid password state. Please contact the server administrator.",
"invalidUserOrPass": "Invalid username or password.",
"inviteIdRequired": "id required in fetching invitation",
"method": {
"signinDisabled": "Sign in method not enabled"
},
"usernameTaken": "Username already taken."
},
"backHome": "{arrow} Back to home", "backHome": "{arrow} Back to home",
"game": {
"banner": {
"description": "Drop failed to update the banner image: {0}",
"title": "Failed to update the banner image"
},
"carousel": {
"description": "Drop failed to update the image carousel: {0}",
"title": "Failed to update image carousel"
},
"cover": {
"description": "Drop failed to update the cover image: {0}",
"title": "Failed to update the cover image"
},
"deleteImage": {
"description": "Drop failed to delete the image: {0}",
"title": "Failed to delete the image"
},
"description": {
"description": "Drop failed to update the game description: {0}",
"title": "Failed to update game description"
},
"metadata": {
"description": "Drop failed to update the game's metadata: {0}",
"title": "Failed to update metadata"
}
},
"invalidBody": "Invalid request body: {0}", "invalidBody": "Invalid request body: {0}",
"inviteRequired": "Invitation required to sign up.", "inviteRequired": "Invitation required to sign up.",
"library": { "library": {
@ -163,6 +195,10 @@
"signIn": "Sign in {arrow}", "signIn": "Sign in {arrow}",
"support": "Support Discord", "support": "Support Discord",
"unknown": "An unknown error occurred", "unknown": "An unknown error occurred",
"upload": {
"description": "Drop couldn't upload the file: {0}",
"title": "Failed to upload file"
},
"version": { "version": {
"delete": { "delete": {
"desc": "Drop encountered an error while deleting the version: {error}", "desc": "Drop encountered an error while deleting the version: {error}",
@ -172,47 +208,17 @@
"desc": "Drop encountered an error while updating the version: {error}", "desc": "Drop encountered an error while updating the version: {error}",
"title": "There an error while updating the version order" "title": "There an error while updating the version order"
} }
},
"upload": {
"title": "Failed to upload file",
"description": "Drop couldn't upload the file: {0}"
},
"game": {
"metadata": {
"title": "Failed to update metadata",
"description": "Drop failed to update the game's metadata: {0}"
},
"description": {
"title": "Failed to update game description",
"description": "Drop failed to update the game description: {0}"
},
"banner": {
"title": "Failed to update the banner image",
"description": "Drop failed to update the banner image: {0}"
},
"cover": {
"title": "Failed to update the cover image",
"description": "Drop failed to update the cover image: {0}"
},
"deleteImage": {
"title": "Failed to delete the image",
"description": "Drop failed to delete the image: {0}"
},
"carousel": {
"title": "Failed to update image carousel",
"description": "Drop failed to update the image carousel: {0}"
}
} }
}, },
"footer": { "footer": {
"about": "About", "about": "About",
"aboutDrop": "About Drop", "aboutDrop": "About Drop",
"comparison": "Comparison",
"docs": { "docs": {
"client": "Client Docs", "client": "Client Docs",
"server": "Server Docs" "server": "Server Docs"
}, },
"documentation": "Documentation", "documentation": "Documentation",
"comparison": "Comparison",
"findGame": "Find a Game", "findGame": "Find a Game",
"footer": "Footer", "footer": "Footer",
"games": "Games", "games": "Games",
@ -226,81 +232,43 @@
"header": { "header": {
"admin": { "admin": {
"admin": "Admin", "admin": "Admin",
"metadata": "Meta",
"settings": "Settings",
"tasks": "Tasks", "tasks": "Tasks",
"users": "Users", "users": "Users"
"settings": "Settings"
}, },
"back": "Back", "back": "Back",
"openSidebar": "Open sidebar" "openSidebar": "Open sidebar"
}, },
"helpUsTranslate": "Help us translate Drop {arrow}",
"highest": "highest", "highest": "highest",
"home": "Home", "home": "Home",
"users": {
"admin": {
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
"authLink": "Authentication {arrow}",
"displayNameHeader": "Display Name",
"usernameHeader": "Username",
"emailHeader": "Email",
"adminHeader": "Admin?",
"authoptionsHeader": "Auth Options",
"srEditLabel": "Edit",
"adminUserLabel": "Admin user",
"normalUserLabel": "Normal user",
"delete": "Delete",
"deleteUser": "Delete user {0}",
"authentication": {
"title": "Authentication",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
"enabledKey": "Enabled?",
"enabled": "Enabled",
"disabled": "Disabled",
"srOpenOptions": "Open options",
"configure": "Configure",
"simple": "Simple (username/password)",
"oidc": "OpenID Connect"
},
"simple": {
"title": "Simple authentication",
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
"invitationTitle": "invitations",
"createInvitation": "Create invitation",
"noUsernameEnforced": "No username enforced.",
"noEmailEnforced": "No email enforced.",
"adminInvitation": "Admin invitation",
"userInvitation": "User invitation",
"expires": "Expires: {expiry}",
"neverExpires": "Never expires.",
"noInvitations": "No invitations.",
"inviteTitle": "Invite user to Drop",
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
"inviteUsernameLabel": "Username (optional)",
"inviteUsernameFormat": "Must be 5 or more characters",
"inviteUsernamePlaceholder": "myUsername",
"inviteEmailLabel": "Email address (optional)",
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
"inviteEmailPlaceholder": "me{'@'}example.com",
"inviteAdminSwitchLabel": "Admin invitation",
"inviteAdminSwitchDescription": "Create this user as an administrator",
"inviteExpiryLabel": "Expires",
"inviteButton": "Invite",
"invite3Days": "3 days",
"inviteWeek": "1 week",
"inviteMonth": "1 month",
"invite6Months": "6 months",
"inviteYear": "1 year",
"inviteNever": "Never"
}
}
},
"library": { "library": {
"addGames": "All Games", "addGames": "All Games",
"addToLib": "Add to Library", "addToLib": "Add to Library",
"admin": { "admin": {
"detectedGame": "Drop has detected you have new games to import.", "detectedGame": "Drop has detected you have new games to import.",
"detectedVersion": "Drop has detected you have new verions of this game to import.", "detectedVersion": "Drop has detected you have new verions of this game to import.",
"offlineTitle": "Game offline",
"offline": "Drop couldn't access this game.",
"game": {
"addCarouselNoImages": "No images to add.",
"addDescriptionNoImages": "No images to add.",
"addImageCarousel": "Add from image library",
"currentBanner": "banner",
"currentCover": "cover",
"deleteImage": "Delete image",
"editGameDescription": "Game Description",
"editGameName": "Game Name",
"imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
"imageCarouselEmpty": "No images added to the carousel yet.",
"imageLibrary": "Image library",
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
"removeImageCarousel": "Remove image",
"setBanner": "Set as banner",
"setCover": "Set as cover"
},
"gameLibrary": "Game Library", "gameLibrary": "Game Library",
"import": { "import": {
"import": "Import", "import": "Import",
@ -313,6 +281,8 @@
"selectGamePlaceholder": "Please select a game...", "selectGamePlaceholder": "Please select a game...",
"selectGameSearch": "Select game", "selectGameSearch": "Select game",
"selectPlatform": "Please select a platform...", "selectPlatform": "Please select a platform...",
"bulkImportTitle": "Bulk import mode",
"bulkImportDescription": "When on, this page won't redirect you to the import task, so you can import multiple games in succession.",
"version": { "version": {
"advancedOptions": "Advanced options", "advancedOptions": "Advanced options",
"import": "Import version", "import": "Import version",
@ -343,38 +313,16 @@
"openEditor": "Open in Editor {arrow}", "openEditor": "Open in Editor {arrow}",
"openStore": "Open in Store", "openStore": "Open in Store",
"shortDesc": "Short Description", "shortDesc": "Short Description",
"version": {
"noVersions": "You have no versions of this game available.",
"noVersionsAdded": "no versions added",
"delta": "Upgrade mode"
},
"game": {
"imageCarousel": "Image Carousel",
"imageCarouselDescription": "Customise what images and what order are shown on the store page.",
"addImageCarousel": "Add from image library",
"imageCarouselEmpty": "No images added to the carousel yet.",
"removeImageCarousel": "Remove image",
"addCarouselNoImages": "No images to add.",
"imageLibrary": "Image library",
"imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.",
"setBanner": "Set as banner",
"setCover": "Set as cover",
"deleteImage": "Delete image",
"currentBanner": "banner",
"currentCover": "cover",
"addDescriptionNoImages": "No images to add.",
"editGameName": "Game Name",
"editGameDescription": "Game Description"
},
"sources": { "sources": {
"create": "Create source", "create": "Create source",
"edit": "Edit source",
"createDesc": "Drop will use this source to access your game library, and make them available.", "createDesc": "Drop will use this source to access your game library, and make them available.",
"desc": "Configure your library sources, where Drop will look for new games and versions to import.", "desc": "Configure your library sources, where Drop will look for new games and versions to import.",
"fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.", "fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.",
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
"fsPath": "Path", "fsPath": "Path",
"fsPathDesc": "An absolute path to your game library.", "fsPathDesc": "An absolute path to your game library.",
"fsPathPlaceholder": "/mnt/games", "fsPathPlaceholder": "/mnt/games",
"fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.",
"link": "Sources {arrow}", "link": "Sources {arrow}",
"nameDesc": "The name of your source, for reference.", "nameDesc": "The name of your source, for reference.",
"namePlaceholder": "My New Source", "namePlaceholder": "My New Source",
@ -384,7 +332,58 @@
}, },
"subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.", "subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.",
"title": "Libraries", "title": "Libraries",
"versionPriority": "Version priority" "version": {
"delta": "Upgrade mode",
"noVersions": "You have no versions of this game available.",
"noVersionsAdded": "no versions added"
},
"versionPriority": "Version priority",
"metadata": {
"tags": {
"title": "Tags",
"description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.",
"action": "Manage {arrow}",
"create": "Create",
"modal": {
"title": "Create Tag",
"description": "Create a tag to organize your library."
}
},
"companies": {
"title": "Companies",
"description": "Companies organize games by who they were developed or published by.",
"action": "Manage {arrow}",
"search": "Search companies...",
"searchGames": "Search company games...",
"noCompanies": "No companies",
"noGames": "No games",
"editor": {
"libraryTitle": "Game Library",
"libraryDescription": "Add, remove, or customise what this company has developed and/or published.",
"action": "Add Game {plus}",
"published": "Published",
"developed": "Developed",
"uploadIcon": "Upload icon",
"uploadBanner": "Upload banner",
"noDescription": "(no description)"
},
"addGame": {
"title": "Connect game to this company",
"description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.",
"publisher": "Publisher?",
"developer": "Developer?",
"noGames": "No games to add"
},
"modals": {
"nameTitle": "Edit company name",
"nameDescription": "Edit the company's name. Used to match to new game imports.",
"shortDeckTitle": "Edit company description",
"shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.",
"websiteTitle": "Edit company website",
"websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection."
}
}
}
}, },
"back": "Back to Library", "back": "Back to Library",
"collection": { "collection": {
@ -407,29 +406,7 @@
"search": "Search library...", "search": "Search library...",
"subheader": "Organize your games into collections for easy access, and access all your games." "subheader": "Organize your games into collections for easy access, and access all your games."
}, },
"tasks": {
"admin": {
"scheduled": {
"cleanupInvitationsName": "Clean up invitations",
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
"cleanupObjectsName": "Clean up objects",
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
"cleanupSessionsName": "Clean up sessions.",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"checkUpdateName": "Check update.",
"checkUpdateDescription": "Check if Drop has an update."
},
"runningTasksTitle": "Running tasks",
"noTasksRunning": "No tasks currently running",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"weeklyScheduledTitle": "Weekly scheduled tasks",
"viewTask": "View {arrow}",
"back": "{arrow} Back to Tasks"
}
},
"lowest": "lowest", "lowest": "lowest",
"name": "Name",
"news": { "news": {
"article": { "article": {
"add": "Add", "add": "Add",
@ -462,34 +439,37 @@
"title": "Latest News" "title": "Latest News"
}, },
"options": "Options", "options": "Options",
"save": "Save",
"saved": "Saved",
"add": "Add",
"insert": "Insert",
"security": "Security", "security": "Security",
"selectLanguage": "Select language",
"settings": { "settings": {
"admin": { "admin": {
"title": "Settings",
"description": "Configure Drop settings", "description": "Configure Drop settings",
"store": { "store": {
"title": "Store", "dropGameAltPlaceholder": "Example Game icon",
"showGamePanelTextDecoration": "Show title and description on game tiles (default: on)",
"dropGameNamePlaceholder": "Example Game",
"dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.", "dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.",
"dropGameAltPlaceholder": "Example Game icon" "dropGameNamePlaceholder": "Example Game",
} "showGamePanelTextDecoration": "Show title and description on game tiles (default: on)",
"title": "Store"
},
"title": "Settings"
} }
}, },
"store": { "store": {
"about": "About",
"commingSoon": "coming soon", "commingSoon": "coming soon",
"developers": "Developers | Developer | Developers",
"exploreMore": "Explore more {arrow}", "exploreMore": "Explore more {arrow}",
"featured": "Featured",
"images": "Game Images", "images": "Game Images",
"lookAt": "Check it out", "lookAt": "Check it out",
"noDevelopers": "No developers",
"noGame": "no game", "noGame": "no game",
"noImages": "No images", "noImages": "No images",
"noPublishers": "No publishers.",
"noTags": "No tags",
"openAdminDashboard": "Open in Admin Dashboard", "openAdminDashboard": "Open in Admin Dashboard",
"platform": "Platform | Platform | Platforms", "platform": "Platform | Platform | Platforms",
"publishers": "Publishers | Publisher | Publishers",
"rating": "Rating", "rating": "Rating",
"readLess": "Click to read less", "readLess": "Click to read less",
"readMore": "Click to read more", "readMore": "Click to read more",
@ -498,9 +478,41 @@
"recentlyUpdated": "Recently Updated", "recentlyUpdated": "Recently Updated",
"released": "Released", "released": "Released",
"reviews": "({0} Reviews)", "reviews": "({0} Reviews)",
"tags": "Tags",
"title": "Store", "title": "Store",
"view": "View in Store" "view": {
"sort": "Sort",
"srFilters": "Filters",
"srGames": "Games",
"srViewGrid": "View grid"
},
"viewInStore": "View in Store",
"website": "Website"
}, },
"tasks": {
"admin": {
"back": "{arrow} Back to Tasks",
"completedTasksTitle": "Completed tasks",
"dailyScheduledTitle": "Daily scheduled tasks",
"noTasksRunning": "No tasks currently running",
"runningTasksTitle": "Running tasks",
"scheduled": {
"checkUpdateDescription": "Check if Drop has an update.",
"checkUpdateName": "Check update.",
"cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.",
"cleanupInvitationsName": "Clean up invitations",
"cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.",
"cleanupObjectsName": "Clean up objects",
"cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.",
"cleanupSessionsName": "Clean up sessions."
},
"viewTask": "View {arrow}",
"weeklyScheduledTitle": "Weekly scheduled tasks"
}
},
"title": "Drop",
"titleTemplate": "{0} - Drop",
"todo": "Todo",
"type": "Type", "type": "Type",
"upload": "Upload", "upload": "Upload",
"uploadFile": "Upload file", "uploadFile": "Upload file",
@ -516,8 +528,63 @@
"settings": "Account settings" "settings": "Account settings"
} }
}, },
"todo": "Todo", "users": {
"selectLanguage": "Select language", "admin": {
"helpUsTranslate": "Help us translate Drop {arrow}", "adminHeader": "Admin?",
"adminUserLabel": "Admin user",
"authentication": {
"configure": "Configure",
"description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.",
"disabled": "Disabled",
"enabled": "Enabled",
"enabledKey": "Enabled?",
"oidc": "OpenID Connect",
"simple": "Simple (username/password)",
"srOpenOptions": "Open options",
"title": "Authentication"
},
"authLink": "Authentication {arrow}",
"authoptionsHeader": "Auth Options",
"delete": "Delete",
"deleteUser": "Delete user {0}",
"description": "Manage the users on your Drop instance, and configure your authentication methods.",
"displayNameHeader": "Display Name",
"emailHeader": "Email",
"normalUserLabel": "Normal user",
"simple": {
"adminInvitation": "Admin invitation",
"createInvitation": "Create invitation",
"description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.",
"expires": "Expires: {expiry}",
"invitationTitle": "invitations",
"invite3Days": "3 days",
"invite6Months": "6 months",
"inviteAdminSwitchDescription": "Create this user as an administrator",
"inviteAdminSwitchLabel": "Admin invitation",
"inviteButton": "Invite",
"inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.",
"inviteEmailDescription": "Must be in the format user{'@'}example.com",
"inviteEmailLabel": "Email address (optional)",
"inviteEmailPlaceholder": "me{'@'}example.com",
"inviteExpiryLabel": "Expires",
"inviteMonth": "1 month",
"inviteNever": "Never",
"inviteTitle": "Invite user to Drop",
"inviteUsernameFormat": "Must be 5 or more characters",
"inviteUsernameLabel": "Username (optional)",
"inviteUsernamePlaceholder": "myUsername",
"inviteWeek": "1 week",
"inviteYear": "1 year",
"neverExpires": "Never expires.",
"noEmailEnforced": "No email enforced.",
"noInvitations": "No invitations.",
"noUsernameEnforced": "No username enforced.",
"title": "Simple authentication",
"userInvitation": "User invitation"
},
"srEditLabel": "Edit",
"usernameHeader": "Username"
}
},
"welcome": "American, Welcome!" "welcome": "American, Welcome!"
} }

View File

@ -164,6 +164,7 @@ import {
Cog6ToothIcon, Cog6ToothIcon,
UserGroupIcon, UserGroupIcon,
RectangleStackIcon, RectangleStackIcon,
DocumentIcon,
} from "@heroicons/vue/24/outline"; } from "@heroicons/vue/24/outline";
import type { NavigationItem } from "~/composables/types"; import type { NavigationItem } from "~/composables/types";
import { useCurrentNavigationIndex } from "~/composables/current-page-engine"; import { useCurrentNavigationIndex } from "~/composables/current-page-engine";
@ -180,6 +181,12 @@ const navigation: Array<NavigationItem & { icon: Component }> = [
prefix: "/admin/library", prefix: "/admin/library",
icon: ServerStackIcon, icon: ServerStackIcon,
}, },
{
label: $t("header.admin.metadata"),
route: "/admin/metadata",
prefix: "/admin/metadata",
icon: DocumentIcon,
},
{ {
label: $t("header.admin.users"), label: $t("header.admin.users"),
route: "/admin/users", route: "/admin/users",

View File

@ -36,11 +36,11 @@ export default defineNuxtConfig({
modules: [ modules: [
"vue3-carousel-nuxt", "vue3-carousel-nuxt",
"nuxt-security", "nuxt-security", // "@nuxt/image",
// "@nuxt/image",
"@nuxt/fonts", "@nuxt/fonts",
"@nuxt/eslint", "@nuxt/eslint",
"@nuxtjs/i18n", "@nuxtjs/i18n",
"@vueuse/nuxt",
], ],
// Nuxt-only config // Nuxt-only config

View File

@ -27,6 +27,7 @@
"@nuxtjs/i18n": "^9.5.5", "@nuxtjs/i18n": "^9.5.5",
"@prisma/client": "^6.11.1", "@prisma/client": "^6.11.1",
"@tailwindcss/vite": "^4.0.6", "@tailwindcss/vite": "^4.0.6",
"@vueuse/nuxt": "13.6.0",
"argon2": "^0.43.0", "argon2": "^0.43.0",
"arktype": "^2.1.10", "arktype": "^2.1.10",
"axios": "^1.7.7", "axios": "^1.7.7",

View File

@ -24,7 +24,7 @@
scope="col" scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-6"
> >
{{ $t("name") }} {{ $t("common.name") }}
</th> </th>
<th <th
scope="col" scope="col"

View File

@ -131,7 +131,8 @@ import type { Component } from "vue";
const route = useRoute(); const route = useRoute();
const gameId = route.params.id.toString(); const gameId = route.params.id.toString();
const { game: rawGame, unimportedVersions } = await $dropFetch( const { game: rawGame, unimportedVersions } = await $dropFetch(
`/api/v1/admin/game?id=${encodeURIComponent(gameId)}`, `/api/v1/admin/game/:id`,
{ params: { id: gameId } },
); );
const game = ref(rawGame); const game = ref(rawGame);
@ -139,11 +140,6 @@ definePageMeta({
layout: "admin", layout: "admin",
}); });
useHead({
// To do a title with the game name in it, we need some sort of watch
title: "Game Editor",
});
enum GameEditorMode { enum GameEditorMode {
Metadata = "Metadata", Metadata = "Metadata",
Versions = "Versions", Versions = "Versions",
@ -160,4 +156,15 @@ const components: {
}; };
const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata); const currentMode = ref<GameEditorMode>(GameEditorMode.Metadata);
useHead({
// To do a title with the game name in it, we need some sort of watch
title: `${currentMode.value} - ${game.value.mName}`,
});
watch(currentMode, (v) => {
useHead({
title: `${v} - ${game.value.mName}`,
});
});
</script> </script>

View File

@ -12,9 +12,15 @@
<ListboxButton <ListboxButton
class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6" class="relative w-full cursor-default rounded-md bg-zinc-950 py-1.5 pl-3 pr-10 text-left text-zinc-100 shadow-sm ring-1 ring-inset ring-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-600 sm:text-sm sm:leading-6"
> >
<span v-if="currentlySelectedGame != -1" class="block truncate">{{ <span v-if="currentlySelectedGame != -1" class="block truncate"
games.unimportedGames[currentlySelectedGame].game >{{ games.unimportedGames[currentlySelectedGame].game }}
}}</span> <span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{
games.unimportedGames[currentlySelectedGame].library.name
}}</span
></span
>
<span v-else class="block truncate text-zinc-400">{{ <span v-else class="block truncate text-zinc-400">{{
$t("library.admin.import.selectDir") $t("library.admin.import.selectDir")
}}</span> }}</span>
@ -37,9 +43,9 @@
class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm" class="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-zinc-900 py-1 text-base shadow-lg ring-1 ring-zinc-800 focus:outline-none sm:text-sm"
> >
<ListboxOption <ListboxOption
v-for="({ game }, gameIdx) in games.unimportedGames" v-for="({ game, library }, gameIdx) in games.unimportedGames"
:key="game" :key="game"
v-slot="{ active, selected }" v-slot="{ active }"
as="template" as="template"
:value="gameIdx" :value="gameIdx"
> >
@ -51,14 +57,20 @@
> >
<span <span
:class="[ :class="[
selected ? 'font-semibold' : 'font-normal', gameIdx === currentlySelectedGame
'block truncate', ? 'font-semibold'
: 'font-normal',
'inline-flex items-center gap-x-2 block truncate py-1 w-full',
]" ]"
>{{ game }}</span >{{ game }}
<span
class="px-1 py-0.5 text-xs bg-blue-600/10 rounded-sm ring-1 ring-blue-600 text-blue-400"
>{{ library.name }}</span
></span
> >
<span <span
v-if="selected" v-if="gameIdx === currentlySelectedGame"
:class="[ :class="[
active ? 'text-white' : 'text-blue-600', active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4', 'absolute inset-y-0 right-0 flex items-center pr-4',
@ -72,6 +84,34 @@
</transition> </transition>
</div> </div>
</Listbox> </Listbox>
<div class="flex items-center justify-between gap-x-8">
<span class="flex grow flex-col">
<label
id="bulkImport-label"
class="text-sm/6 font-medium text-zinc-100"
>{{ $t("library.admin.import.bulkImportTitle") }}</label
>
<span id="bulkImport-description" class="text-sm text-zinc-400">{{
$t("library.admin.import.bulkImportDescription")
}}</span>
</span>
<div
class="group relative inline-flex w-11 shrink-0 rounded-full bg-zinc-800 p-0.5 inset-ring inset-ring-zinc-100/5 outline-offset-2 outline-blue-600 transition-colors duration-200 ease-in-out has-checked:bg-blue-600 has-focus-visible:outline-2"
>
<span
class="size-5 rounded-full bg-white shadow-xs ring-1 ring-zinc-100/5 transition-transform duration-200 ease-in-out group-has-checked:translate-x-5"
/>
<input
id="bulkImport"
v-model="bulkImportMode"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
name="bulkImport"
aria-labelledby="bulkImport-label"
aria-describedby="bulkImport-description"
/>
</div>
</div>
<div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4"> <div v-if="currentlySelectedGame !== -1" class="flex flex-col gap-y-4">
<!-- without metadata option --> <!-- without metadata option -->
@ -277,18 +317,20 @@ definePageMeta({
const { t } = useI18n(); const { t } = useI18n();
const games = await $dropFetch("/api/v1/admin/import/game"); const rawGames = await $dropFetch("/api/v1/admin/import/game");
const games = ref(rawGames);
const currentlySelectedGame = ref(-1); const currentlySelectedGame = ref(-1);
const gameSearchResultsLoading = ref(false); const gameSearchResultsLoading = ref(false);
const gameSearchResultsError = ref<string | undefined>(); const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref(""); const gameSearchTerm = ref("");
const gameSearchLoading = ref(false); const gameSearchLoading = ref(false);
const bulkImportMode = ref(false);
async function updateSelectedGame(value: number) { async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return; if (currentlySelectedGame.value == value) return;
currentlySelectedGame.value = value; currentlySelectedGame.value = value;
if (currentlySelectedGame.value == -1) return; if (currentlySelectedGame.value == -1) return;
const option = games.unimportedGames[currentlySelectedGame.value]; const option = games.value.unimportedGames[currentlySelectedGame.value];
if (!option) return; if (!option) return;
metadataResults.value = undefined; metadataResults.value = undefined;
@ -299,12 +341,19 @@ async function updateSelectedGame(value: number) {
} }
async function searchGame() { async function searchGame() {
gameSearchResultsError.value = undefined;
gameSearchLoading.value = true; gameSearchLoading.value = true;
const results = await $dropFetch( try {
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`, const results = await $dropFetch(
); `/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
metadataResults.value = results; );
gameSearchLoading.value = false; metadataResults.value = results;
gameSearchLoading.value = false;
} catch (e) {
gameSearchLoading.value = false;
throw e;
}
} }
function updateSelectedGame_wrapper(value: number) { function updateSelectedGame_wrapper(value: number) {
@ -332,18 +381,24 @@ async function importGame(useMetadata: boolean) {
useMetadata && metadataResults.value useMetadata && metadataResults.value
? metadataResults.value[currentlySelectedMetadata.value] ? metadataResults.value[currentlySelectedMetadata.value]
: undefined; : undefined;
const option = games.unimportedGames[currentlySelectedGame.value]; const option = games.value.unimportedGames[currentlySelectedGame.value];
const { taskId } = await $dropFetch("/api/v1/admin/import/game", { const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
method: "POST", method: "POST",
body: { body: {
path: option.game, path: option.game,
library: option.library, library: option.library.id,
metadata, metadata,
}, },
}); });
router.push(`/admin/task/${taskId}`); if (!bulkImportMode.value) {
router.push(`/admin/task/${taskId}`);
} else {
games.value.unimportedGames.splice(currentlySelectedGame.value, 1);
currentlySelectedGame.value = -1;
gameSearchResultsError.value = undefined;
}
} }
function importGame_wrapper(metadata = true) { function importGame_wrapper(metadata = true) {
importLoading.value = true; importLoading.value = true;

View File

@ -78,20 +78,55 @@
<li <li
v-for="game in filteredLibraryGames" v-for="game in filteredLibraryGames"
:key="game.id" :key="game.id"
class="col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 transition-all duration-200 hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 group" class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
> >
<div class="flex flex-1 flex-row p-4 gap-x-4"> <div class="flex flex-1 flex-row p-4 gap-x-4">
<img <img
class="h-20 w-20 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800" class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)" :src="useObject(game.mIconObjectId)"
alt="" alt=""
/> />
<div class="flex flex-col"> <div class="flex flex-col">
<h3 class="text-sm font-medium text-zinc-100 font-display"> <h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ game.mName }} {{ game.mName }}
<button
type="button"
:class="[
'rounded-full p-1 shadow-xs focus-visible:outline-2 focus-visible:outline-offset-2',
game.featured
? 'bg-yellow-400 hover:bg-yellow-300 focus-visible:outline-yellow-400 text-zinc-900'
: 'bg-zinc-800 hover:bg-zinc-700 focus-visible:outline-zinc-400 text-white',
]"
@click="() => featureGame(game.id)"
>
<svg
v-if="gameFeatureLoading[game.id]"
aria-hidden="true"
:class="[
game.featured ? ' fill-zinc-900' : 'fill-zinc-100',
'size-3 text-transparent animate-spin',
]"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<StarIcon v-else class="size-3" aria-hidden="true" />
</button>
<span <span
class="ml-2 inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20" class="inline-flex items-center rounded-full bg-blue-600/10 px-2 py-1 text-xs font-medium text-blue-600 ring-1 ring-inset ring-blue-600/20"
>{{ game.metadataSource }}</span >{{ game.library!.name }}</span
> >
</h3> </h3>
<dl class="mt-1 flex flex-col justify-between"> <dl class="mt-1 flex flex-col justify-between">
@ -180,6 +215,24 @@
</div> </div>
</div> </div>
</div> </div>
<div
v-if="game.notifications.offline"
class="rounded-md bg-red-600/10 p-4"
>
<div class="flex">
<div class="flex-shrink-0">
<ExclamationCircleIcon
class="h-5 w-5 text-red-600"
aria-hidden="true"
/>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-600">
{{ $t("library.admin.offline") }}
</h3>
</div>
</div>
</div>
</div> </div>
</li> </li>
<p <p
@ -199,8 +252,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid"; import {
import { InformationCircleIcon } from "@heroicons/vue/20/solid"; ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline"; import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n(); const { t } = useI18n();
@ -216,13 +272,37 @@ useHead({
const searchQuery = ref(""); const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library"); const libraryState = await $dropFetch("/api/v1/admin/library");
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
const toImport = ref( const toImport = ref(
Object.values(libraryState.unimportedGames).flat().length > 0, Object.values(libraryState.unimportedGames).flat().length > 0,
); );
const libraryGames = ref( const libraryGames = ref<
Array<
LibraryStateGame & {
status: "online" | "offline";
hasNotifications?: boolean;
notifications: {
noVersions?: boolean;
toImport?: boolean;
offline?: boolean;
};
}
>
>(
libraryState.games.map((e) => { libraryState.games.map((e) => {
if (e.status == "offline") {
return {
...e.game,
status: "offline" as const,
hasNotifications: true,
notifications: {
offline: true,
},
};
}
const noVersions = e.status.noVersions; const noVersions = e.status.noVersions;
const toImport = e.status.unimportedVersions.length > 0; const toImport = e.status.unimportedVersions.length > 0;
@ -233,6 +313,7 @@ const libraryGames = ref(
toImport, toImport,
}, },
hasNotifications: noVersions || toImport, hasNotifications: noVersions || toImport,
status: "online" as const,
}; };
}), }),
); );
@ -251,9 +332,31 @@ const filteredLibraryGames = computed(() =>
); );
async function deleteGame(id: string) { async function deleteGame(id: string) {
await $dropFetch(`/api/v1/admin/game?id=${id}`, { method: "DELETE" }); await $dropFetch(`/api/v1/admin/game/${id}`, {
method: "DELETE",
failTitle: "Failed to delete game",
});
const index = libraryGames.value.findIndex((e) => e.id === id); const index = libraryGames.value.findIndex((e) => e.id === id);
libraryGames.value.splice(index, 1); libraryGames.value.splice(index, 1);
toImport.value = true; toImport.value = true;
} }
const gameFeatureLoading = ref<{ [key: string]: boolean }>({});
async function featureGame(id: string) {
const gameIndex = libraryGames.value.findIndex((e) => e.id === id);
const game = libraryGames.value[gameIndex];
gameFeatureLoading.value[game.id] = true;
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
params: {
id: game.id,
},
body: { featured: !game.featured },
failTitle: "Failed to feature/unfeature game",
});
libraryGames.value[gameIndex].featured = !game.featured;
gameFeatureLoading.value[game.id] = false;
}
</script> </script>

View File

@ -14,7 +14,7 @@
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (actionSourceOpen = true)" @click="() => (actionSourceOpen = true)"
> >
{{ $t("create") }} {{ $t("common.create") }}
</button> </button>
</div> </div>
</div> </div>
@ -28,7 +28,7 @@
scope="col" scope="col"
class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3" class="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-zinc-100 sm:pl-3"
> >
{{ $t("name") }} {{ $t("common.name") }}
</th> </th>
<th <th
scope="col" scope="col"
@ -49,7 +49,7 @@
{{ $t("options") }} {{ $t("options") }}
</th> </th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3"> <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-3">
<span class="sr-only">{{ $t("edit") }}</span> <span class="sr-only">{{ $t("common.edit") }}</span>
</th> </th>
</tr> </tr>
</thead> </thead>
@ -84,7 +84,7 @@
class="text-blue-500 hover:text-blue-400" class="text-blue-500 hover:text-blue-400"
@click="() => edit(sourceIdx)" @click="() => edit(sourceIdx)"
> >
{{ $t("edit") }} {{ $t("common.edit") }}
<span class="sr-only"> <span class="sr-only">
{{ $t("chars.srComma", [source.name]) }} {{ $t("chars.srComma", [source.name]) }}
</span> </span>
@ -110,9 +110,20 @@
<ModalTemplate v-model="actionSourceOpen"> <ModalTemplate v-model="actionSourceOpen">
<template #default> <template #default>
<div> <div>
<DialogTitle as="h3" class="text-lg font-medium leading-6 text-white"> <DialogTitle
v-if="createMode"
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.create") }} {{ $t("library.admin.sources.create") }}
</DialogTitle> </DialogTitle>
<DialogTitle
v-else
as="h3"
class="text-lg font-medium leading-6 text-white"
>
{{ $t("library.admin.sources.edit") }}
</DialogTitle>
<p class="mt-1 text-zinc-400 text-sm"> <p class="mt-1 text-zinc-400 text-sm">
{{ $t("library.admin.sources.createDesc") }} {{ $t("library.admin.sources.createDesc") }}
</p> </p>
@ -125,7 +136,7 @@
<label <label
for="name" for="name"
class="block text-sm font-medium leading-6 text-zinc-100" class="block text-sm font-medium leading-6 text-zinc-100"
>{{ $t("name") }}</label >{{ $t("common.name") }}</label
> >
<p class="text-zinc-400 block text-xs font-medium leading-6"> <p class="text-zinc-400 block text-xs font-medium leading-6">
{{ $t("library.admin.sources.nameDesc") }} {{ $t("library.admin.sources.nameDesc") }}
@ -228,7 +239,7 @@
class="w-full sm:w-fit" class="w-full sm:w-fit"
@click="() => performActionSource_wrapper()" @click="() => performActionSource_wrapper()"
> >
{{ createMode ? $t("create") : $t("save") }} {{ createMode ? $t("common.create") : $t("common.save") }}
</LoadingButton> </LoadingButton>
<button <button
ref="cancelButtonRef" ref="cancelButtonRef"
@ -279,6 +290,10 @@ definePageMeta({
layout: "admin", layout: "admin",
}); });
useHead({
title: "Library Sources",
});
const { t } = useI18n(); const { t } = useI18n();
const sources = ref( const sources = ref(

View File

@ -0,0 +1,419 @@
<template>
<div class="space-y-4">
<div
class="relative overflow-hidden rounded-lg flex flex-col lg:flex-row lg:justify-between items-start lg:items-center gap-2 p-8"
>
<img
:src="useObject(company.mBannerObjectId)"
class="absolute inset-0 w-full h-full object-cover object-center"
/>
<div class="absolute inset-0 bg-zinc-900/80" />
<div class="relative inline-flex items-center gap-4">
<!-- icon image -->
<div class="relative group/iconupload rounded-xl overflow-hidden">
<img :src="useObject(company.mLogoObjectId)" class="size-20" />
<button
class="rounded-xl transition duration-200 absolute inset-0 opacity-0 group-hover/iconupload:opacity-100 focus-visible/iconupload:opacity-100 cursor-pointer bg-zinc-900/80 text-zinc-100 flex flex-col items-center justify-center text-center text-xs font-semibold ring-1 ring-inset ring-zinc-800 px-2"
@click="() => (uploadLogoOpen = true)"
>
<ArrowUpTrayIcon class="size-5" />
<span>{{
$t("library.admin.metadata.companies.editor.uploadIcon")
}}</span>
</button>
</div>
<div class="flex flex-col">
<h1
class="group/name inline-flex items-center gap-x-3 text-5xl font-bold font-display text-zinc-100"
>
{{ company.mName }}
<button @click="() => editName()">
<PencilIcon
class="transition duration-200 opacity-0 group-hover/name:opacity-100 size-8"
/>
</button>
</h1>
<p
class="group/description mt-1 inline-flex items-center gap-x-3 text-lg text-zinc-400 max-w-xl"
>
{{
company.mShortDescription ||
$t("library.admin.metadata.companies.editor.noDescription")
}}
<button @click="() => editShortDescription()">
<PencilIcon
class="transition duration-200 opacity-0 group-hover/description:opacity-100 size-5"
/>
</button>
</p>
<p
class="group/website mt-1 text-zinc-500 inline-flex items-center gap-x-3"
>
{{ company.mWebsite }}
<button @click="() => editWebsite()">
<PencilIcon
class="transition duration-200 opacity-0 group-hover/website:opacity-100 size-4"
/>
</button>
</p>
</div>
</div>
<button
type="button"
class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (uploadBannerOpen = true)"
>
{{ $t("library.admin.metadata.companies.editor.uploadBanner") }}
<ArrowUpTrayIcon class="size-4" />
</button>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.editor.libraryTitle") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.editor.libraryDescription") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (addGameModelOpen = true)"
>
<i18n-t
keypath="library.admin.metadata.companies.editor.action"
tag="span"
scope="global"
class="inline-flex items-center gap-x-1"
>
<template #plus>
<PlusIcon class="size-4" />
</template>
</i18n-t>
</button>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.admin.metadata.companies.searchGames')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="game in filteredGames"
:key="game.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border border-zinc-800 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(game.mIconObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ game.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ game.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-3">
<div class="flex items-center gap-3">
<div
class="group/published relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/published:translate-x-3"
/>
<input
id="published"
v-model="published[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="published-label"
/>
</div>
<label
id="published-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.published")
}}</label
>
</div>
<div class="flex items-center gap-3">
<div
class="group/developed relative inline-flex w-7 shrink-0 rounded-full p-0.5 inset-ring outline-offset-2 transition-colors duration-200 ease-in-out has-focus-visible:outline-2 bg-white/5 inset-ring-white/10 outline-blue-500 has-checked:bg-blue-500"
>
<span
class="size-3 rounded-full bg-white shadow-xs ring-1 ring-gray-900/5 transition-transform duration-200 ease-in-out group-has-checked/developed:translate-x-3"
/>
<input
id="developed"
v-model="developed[game.id]"
type="checkbox"
class="w-auto h-auto opacity-0 absolute inset-0 focus:outline-hidden"
aria-labelledby="developed-label"
/>
</div>
<label
id="developed-label"
for="published"
class="font-medium text-xs text-zinc-100"
>{{
$t("library.admin.metadata.companies.editor.developed")
}}</label
>
</div>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => removeGame(game.id)"
>
{{ $t("common.remove") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredGames.length == 0 && games.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredGames.length == 0 && games.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noGames") }}
</p>
</ul>
<ModalAddCompanyGame
v-model="addGameModelOpen"
:exclude="games.map((e) => e.id)"
:company-id="company.id"
@created="appendGame"
/>
<ModalUploadFile
v-model="uploadLogoOpen"
:endpoint="`/api/v1/admin/company/${company.id}/icon`"
accept="image/*"
@upload="updateLogo"
/>
<ModalUploadFile
v-model="uploadBannerOpen"
:endpoint="`/api/v1/admin/company/${company.id}/banner`"
accept="image/*"
@upload="updateBanner"
/>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import { ArrowUpTrayIcon, PencilIcon, PlusIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { GameModel } from "~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const route = useRoute();
const companyId = route.params.id!.toString();
const result = await $dropFetch("/api/v1/admin/company/:id", {
params: { id: companyId },
});
const company = ref(result.company);
const games = ref(result.games);
const addGameModelOpen = ref(false);
const uploadLogoOpen = ref(false);
const uploadBannerOpen = ref(false);
const { t } = useI18n();
useHead({
title: `${company.value.mName} - Company`,
});
const searchQuery = ref("");
const filteredGames = computed(() =>
games.value.filter(
(e: SerializeObject<GameModel>) =>
e.mName.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
e.mShortDescription.includes(searchQuery.value.toLowerCase()),
),
);
function buildToggleProxy(param: "developed" | "published") {
async function tick(id: string, enabled: boolean) {
if (
company.value.developed.length == 0 &&
company.value.published.length == 0
)
return await removeGame(id);
await $dropFetch("/api/v1/admin/company/:id/game", {
method: "PATCH",
params: {
id: company.value.id,
},
body: {
action: param,
enabled,
id,
},
failTitle: `Failed to update ${param} for game`,
});
}
return new Proxy({} as { [key: string]: boolean }, {
get(_target, prop, _reciever) {
return company.value[param].includes(prop.toString());
},
set(_target, prop, value) {
if (typeof value !== "boolean") return false;
const id = prop.toString();
const exists = company.value[param].findIndex((e) => e === id);
if (value && exists == -1) {
company.value[param].push(id);
}
if (!value && exists != -1) {
company.value[param].splice(exists, 1);
}
tick(id, value);
return true;
},
});
}
const published = buildToggleProxy("published");
const developed = buildToggleProxy("developed");
async function removeGame(gameId: string) {
await $dropFetch("/api/v1/admin/company/:id/game", {
params: {
id: company.value.id,
},
body: {
id: gameId,
},
method: "DELETE",
failTitle: "Failed to remove game",
});
const gameIndex = games.value.findIndex((e) => e.id == gameId);
if (gameIndex == -1) return;
games.value.splice(gameIndex, 1);
const publishedIndex = company.value.published.findIndex((e) => e === gameId);
if (publishedIndex != -1) {
company.value.published.splice(publishedIndex, 1);
}
const developedIndex = company.value.developed.findIndex((e) => e === gameId);
if (developedIndex != -1) {
company.value.developed.splice(developedIndex, 1);
}
}
function appendGame(
game: (typeof games.value)[number],
published: boolean,
developed: boolean,
) {
games.value.push(game);
if (published) {
company.value.published.push(game.id);
}
if (developed) {
company.value.developed.push(game.id);
}
}
function buildFieldEditModal(
field: "mName" | "mShortDescription" | "mWebsite",
title: string,
description: string,
) {
function modal() {
createModal(
ModalType.TextInput,
{
title,
description,
dft: company.value[field],
},
async (e, c, s) => {
switch (e) {
case "cancel": {
c();
break;
}
case "submit": {
const result = await $dropFetch("/api/v1/admin/company/:id", {
method: "PATCH",
params: { id: company.value.id },
body: { [field]: s! },
failTitle: "Failed to update company details",
});
company.value[field] = result[field];
c();
break;
}
}
},
);
}
return modal;
}
const editName = buildFieldEditModal(
"mName",
t("library.admin.metadata.companies.modals.nameTitle"),
t("library.admin.metadata.companies.modals.nameDescription"),
);
const editShortDescription = buildFieldEditModal(
"mShortDescription",
t("library.admin.metadata.companies.modals.shortDeckTitle"),
t("library.admin.metadata.companies.modals.shortDeckDescription"),
);
const editWebsite = buildFieldEditModal(
"mWebsite",
t("library.admin.metadata.companies.modals.websiteTitle"),
t("library.admin.metadata.companies.modals.websiteDescription"),
);
function updateLogo(response: { id: string }) {
company.value.mLogoObjectId = response.id;
}
function updateBanner(response: { id: string }) {
company.value.mBannerObjectId = response.id;
}
</script>

View File

@ -0,0 +1,150 @@
<template>
<div class="space-y-4">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 sm:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/library/sources"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.sources.link"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div class="mt-2 grid grid-cols-1">
<input
id="search"
v-model="searchQuery"
type="text"
name="search"
class="col-start-1 row-start-1 block w-full rounded-md bg-zinc-900 py-1.5 pl-10 pr-3 text-base text-zinc-100 outline outline-1 -outline-offset-1 outline-zinc-700 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-blue-600 sm:pl-9 sm:text-sm/6"
:placeholder="$t('library.admin.metadata.companies.search')"
/>
<MagnifyingGlassIcon
class="pointer-events-none col-start-1 row-start-1 ml-3 size-5 self-center text-zinc-400 sm:size-4"
aria-hidden="true"
/>
</div>
<ul
role="list"
class="grid grid-cols-1 gap-6 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4"
>
<li
v-for="company in filteredCompanies"
:key="company.id"
class="relative overflow-hidden col-span-1 flex flex-col justify-center divide-y divide-zinc-800 rounded-xl bg-zinc-950/30 text-left shadow-md border hover:scale-102 hover:shadow-xl hover:bg-zinc-950/70 border-zinc-800 transition-all duration-200 group"
>
<div class="flex flex-1 flex-row p-4 gap-x-4">
<img
class="h-20 w-20 p-3 flex-shrink-0 rounded-xl shadow group-hover:shadow-lg transition-all duration-200 bg-zinc-900 object-cover border border-zinc-800"
:src="useObject(company.mLogoObjectId)"
alt=""
/>
<div class="flex flex-col">
<h3
class="gap-x-2 text-sm inline-flex items-center font-medium text-zinc-100 font-display"
>
{{ company.mName }}
</h3>
<dl class="mt-1 flex flex-col justify-between">
<dt class="sr-only">{{ $t("library.admin.shortDesc") }}</dt>
<dd class="text-sm text-zinc-400">
{{ company.mShortDescription }}
</dd>
</dl>
<div class="mt-4 flex flex-col gap-y-1">
<NuxtLink
:href="`/admin/metadata/companies/${company.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
keypath="library.admin.openEditor"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<button
class="w-fit rounded-md bg-red-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600"
@click="() => deleteCompany(company.id)"
>
{{ $t("delete") }}
</button>
</div>
</div>
</div>
</li>
<p
v-if="filteredCompanies.length == 0 && companies.length != 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("common.noResults") }}
</p>
<p
v-if="filteredCompanies.length == 0 && companies.length == 0"
class="text-zinc-600 text-sm font-display font-bold uppercase text-center col-span-4"
>
{{ $t("library.admin.metadata.companies.noCompanies") }}
</p>
</ul>
</div>
</template>
<script setup lang="ts">
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
import type { CompanyModel } from "~/prisma/client/models";
const { t } = useI18n();
definePageMeta({
layout: "admin",
});
useHead({
title: t("library.admin.metadata.companies.title"),
});
const searchQuery = ref("");
const companies = ref(await $dropFetch("/api/v1/admin/company"));
const filteredCompanies = computed(() =>
companies.value.filter((e: CompanyModel) => {
if (!searchQuery.value) return true;
const searchQueryLower = searchQuery.value.toLowerCase();
if (e.mName.toLowerCase().includes(searchQueryLower)) return true;
if (e.mShortDescription.toLowerCase().includes(searchQueryLower))
return true;
return false;
}),
);
async function deleteCompany(id: string) {
await $dropFetch(`/api/v1/admin/company/:id`, {
method: "DELETE",
params: { id },
failTitle: "Failed to delete company",
});
const index = companies.value.findIndex((e) => e.id === id);
companies.value.splice(index, 1);
}
</script>

View File

@ -0,0 +1,62 @@
<template>
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-16">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/tags"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.tags.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.companies.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.companies.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<NuxtLink
to="/admin/metadata/companies"
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
<i18n-t
keypath="library.admin.metadata.companies.action"
tag="span"
scope="global"
>
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({
layout: "admin",
});
</script>

View File

@ -0,0 +1,75 @@
<template>
<div class="space-y-6">
<div class="sm:flex sm:items-center">
<div class="sm:flex-auto">
<h1 class="text-base font-semibold text-zinc-100">
{{ $t("library.admin.metadata.tags.title") }}
</h1>
<p class="mt-2 text-sm text-zinc-400">
{{ $t("library.admin.metadata.tags.description") }}
</p>
</div>
<div class="mt-4 lg:ml-16 sm:mt-0 sm:flex-none">
<button
class="block rounded-md bg-blue-600 px-3 py-2 text-center text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-blue-500 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="() => (createModalOpen = true)"
>
{{ $t("library.admin.metadata.tags.create") }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-3">
<div
v-for="(tag, tagIdx) in tags"
:key="tag.id"
class="py-2 px-3 inline-flex gap-x-3 bg-zinc-950 ring-1 ring-zinc-800 text-zinc-300"
>
{{ tag.name }}
<button @click="() => deleteTag(tagIdx)">
<TrashIcon
class="transition size-4 text-zinc-700 hover:text-red-500"
/>
</button>
</div>
</div>
<ModalCreateTag v-model="createModalOpen" @created="onTagCreate" />
</div>
</template>
<script setup lang="ts">
import { TrashIcon } from "@heroicons/vue/24/outline";
import type { SerializeObject } from "nitropack";
import type { GameTagModel } from "~/prisma/client/models";
definePageMeta({
layout: "admin",
});
const createModalOpen = ref(false);
const tags = ref(
await $dropFetch<Array<SerializeObject<GameTagModel>>>("/api/v1/admin/tags"),
);
function sort() {
tags.value.sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);
}
sort();
async function onTagCreate(tag: GameTagModel) {
tags.value.push(tag);
sort();
}
async function deleteTag(tagIdx: number) {
const tag = tags.value[tagIdx];
await $dropFetch(`/api/v1/admin/tags/${tag.id}`, {
method: "DELETE",
failTitle: "Failed to delete tag",
});
tags.value.splice(tagIdx, 1);
}
</script>

View File

@ -59,7 +59,7 @@
:loading="saving" :loading="saving"
:disabled="!allowSave" :disabled="!allowSave"
> >
{{ allowSave ? $t("save") : $t("saved") }} {{ allowSave ? $t("common.save") : $t("common.saved") }}
</LoadingButton> </LoadingButton>
</form> </form>
</div> </div>
@ -78,7 +78,7 @@ useHead({
title: t("settings.admin.title"), title: t("settings.admin.title"),
}); });
const settings = await $dropFetch("/api/v1/admin/settings"); const settings = await $dropFetch("/api/v1/settings");
const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data"); const { game } = await $dropFetch("/api/v1/admin/settings/dummy-data");
const allowSave = ref(false); const allowSave = ref(false);

View File

@ -47,7 +47,7 @@
/> />
</div> </div>
<p class="mt-1 truncate text-sm text-zinc-400"> <p class="mt-1 truncate text-sm text-zinc-400">
{{ task.value.log.at(-1) }} {{ parseTaskLog(task.value.log.at(-1) ?? "").message }}
</p> </p>
<NuxtLink <NuxtLink
type="button" type="button"

View File

@ -138,7 +138,7 @@
</div> </div>
</div> </div>
</div> </div>
<DeleteUserModal v-model="userToDelete" /> <ModalDeleteUser v-model="userToDelete" />
</div> </div>
</template> </template>

View File

@ -157,7 +157,7 @@
<div> <div>
<LoadingButton type="submit" :loading="loading" class="w-full"> <LoadingButton type="submit" :loading="loading" class="w-full">
{{ $t("create") }} {{ $t("common.create") }}
</LoadingButton> </LoadingButton>
</div> </div>

View File

@ -51,7 +51,7 @@
</TransitionChild> </TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like --> <!-- Sidebar component, swap this element with another sidebar if you like -->
<div class="bg-zinc-900"> <div class="bg-zinc-900">
<LibraryDirectory /> <DirectoryLibrary />
</div> </div>
</DialogPanel> </DialogPanel>
</TransitionChild> </TransitionChild>
@ -64,7 +64,7 @@
class="hidden lg:flex lg:inset-y-0 lg:z-50 lg:shrink-0 lg:basis-[18rem] lg:flex-col lg:border-r-2 lg:border-zinc-800" class="hidden lg:flex lg:inset-y-0 lg:z-50 lg:shrink-0 lg:basis-[18rem] lg:flex-col lg:border-r-2 lg:border-zinc-800"
> >
<!-- Sidebar component, swap this element with another sidebar if you like --> <!-- Sidebar component, swap this element with another sidebar if you like -->
<LibraryDirectory /> <DirectoryLibrary />
</div> </div>
<div <div

View File

@ -57,7 +57,7 @@
:to="`/store/${game.id}`" :to="`/store/${game.id}`"
class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600" class="inline-flex items-center justify-center gap-x-2 rounded-md bg-zinc-800 px-3.5 py-2.5 text-base font-semibold font-display text-white shadow-sm transition-all duration-200 hover:bg-zinc-700 hover:scale-105 hover:shadow-lg active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-600"
> >
{{ $t("store.view") }} {{ $t("store.viewInStore") }}
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" /> <ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</NuxtLink> </NuxtLink>
</div> </div>

View File

@ -88,8 +88,8 @@
</div> </div>
</div> </div>
<CreateCollectionModal v-model="collectionCreateOpen" /> <ModalCreateCollection v-model="collectionCreateOpen" />
<DeleteCollectionModal v-model="currentlyDeleting" /> <ModalDeleteCollection v-model="currentlyDeleting" />
</div> </div>
</template> </template>

View File

@ -51,7 +51,7 @@
</TransitionChild> </TransitionChild>
<div class="bg-zinc-900"> <div class="bg-zinc-900">
<NewsArticleCreateButton /> <NewsArticleCreateButton />
<NewsDirectory :articles="news" /> <DirectoryNews />
</div> </div>
</DialogPanel> </DialogPanel>
</TransitionChild> </TransitionChild>
@ -64,7 +64,7 @@
class="hidden lg:block lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col lg:border-r-2 lg:border-zinc-800" class="hidden lg:block lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col lg:border-r-2 lg:border-zinc-800"
> >
<NewsArticleCreateButton /> <NewsArticleCreateButton />
<NewsDirectory /> <DirectoryNews />
</div> </div>
<div <div

View File

@ -76,7 +76,7 @@
/> />
</div> </div>
<DeleteNewsModal v-model="currentlyDeleting" /> <ModalDeleteNews v-model="currentlyDeleting" />
</div> </div>
</template> </template>

View File

@ -108,6 +108,72 @@
}}</span> }}</span>
</td> </td>
</tr> </tr>
<tr>
<td
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ $t("store.tags") }}
</td>
<td class="flex flex-col gap-1 px-3 py-4 text-sm text-zinc-400">
<NuxtLink
v-for="tag in game.tags"
:key="tag.id"
:href="`/store/t/${tag.id}`"
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
>
{{ tag.name }}
</NuxtLink>
<span
v-if="game.tags.length == 0"
class="text-zinc-700 font-bold uppercase font-display"
>{{ $t("store.noTags") }}</span
>
</td>
</tr>
<tr>
<td
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ $t("store.developers", game.developers.length) }}
</td>
<td class="flex flex-col px-3 py-4 text-sm text-zinc-400">
<NuxtLink
v-for="developer in game.developers"
:key="developer.id"
:href="`/store/c/${developer.id}`"
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
>
{{ developer.mName }}
</NuxtLink>
<span
v-if="game.developers.length == 0"
class="text-zinc-700 font-bold uppercase font-display"
>{{ $t("store.noDevelopers") }}</span
>
</td>
</tr>
<tr>
<td
class="whitespace-nowrap align-top py-4 pl-4 pr-3 text-sm font-medium text-zinc-100 sm:pl-3"
>
{{ $t("store.publishers", game.publishers.length) }}
</td>
<td class="flex flex-col px-3 py-4 text-sm text-zinc-400">
<NuxtLink
v-for="publisher in game.publishers"
:key="publisher.id"
:href="`/store/c/${publisher.id}`"
class="w-min hover:underline hover:text-zinc-100 whitespace-nowrap"
>
{{ publisher.mName }}
</NuxtLink>
<span
v-if="game.publishers.length == 0"
class="text-zinc-700 font-bold uppercase font-display"
>{{ $t("store.noPublishers") }}</span
>
</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@ -225,6 +291,7 @@ const ratingArray = Array(5)
useHead({ useHead({
title: game.mName, title: game.mName,
link: [{ rel: "icon", href: useObject(game.mIconObjectId) }],
}); });
</script> </script>

View File

@ -0,0 +1,65 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="w-full overflow-x-hidden">
<div class="relative overflow-hidden bg-zinc-900">
<!-- Decorative background image and gradient -->
<div aria-hidden="true" class="absolute inset-0">
<div class="absolute inset-0 overflow-hidden">
<img
:src="useObject(company.mBannerObjectId)"
alt=""
class="size-full object-cover"
/>
</div>
<div class="absolute inset-0 bg-zinc-900/75" />
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
</div>
<!-- Callout -->
<section
aria-labelledby="sale-heading"
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
>
<div class="mx-auto max-w-2xl lg:max-w-none">
<h2
id="sale-heading"
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
>
{{ company.mName }}
</h2>
<p class="mx-auto line-clamp-3 mt-4 max-w-xl text-xl text-zinc-400">
{{ company.mDescription }}
</p>
</div>
</section>
</div>
<StoreView
:extra-options="[
{
name: 'Company',
param: 'companyActions',
multiple: true,
options: [
{ name: 'Published', param: 'published' },
{ name: 'Developed', param: 'developed' },
],
},
]"
:params="{
company: company.id,
}"
/>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const companyId = route.params.id;
const { company } = await $dropFetch(`/api/v1/companies/${companyId}`);
useHead({
title: company.mName,
link: [{ rel: "icon", href: useObject(company.mLogoObjectId) }],
});
</script>

View File

@ -24,14 +24,16 @@
> >
<div class="relative text-center"> <div class="relative text-center">
<h3 class="text-base/7 font-semibold text-blue-300"> <h3 class="text-base/7 font-semibold text-blue-300">
{{ $t("store.recentlyAdded") }} {{ $t("store.featured") }}
</h3> </h3>
<h2 <h2
class="text-3xl font-bold tracking-tight text-white sm:text-5xl" class="text-3xl font-bold tracking-tight text-white sm:text-5xl"
> >
{{ game.mName }} {{ game.mName }}
</h2> </h2>
<p class="mt-3 text-lg text-zinc-300 line-clamp-2 max-w-xl"> <p
class="mt-3 text-lg text-zinc-300 line-clamp-2 max-w-xl mx-auto"
>
{{ game.mShortDescription }} {{ game.mShortDescription }}
</p> </p>
<div> <div>
@ -66,49 +68,12 @@
</h2> </h2>
</div> </div>
<!-- new releases --> <StoreView />
<div class="px-4 sm:px-12 py-4">
<h1 class="text-zinc-100 text-2xl font-bold font-display">
{{ $t("store.recentlyReleased") }}
</h1>
<NuxtLink class="text-blue-600 font-semibold">
<i18n-t keypath="store.exploreMore" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<div class="mt-4">
<GameCarousel :items="released" :min="12" />
</div>
</div>
<!-- recently updated -->
<div class="px-4 sm:px-12 py-4" hydrate-on-visible>
<h1 class="text-zinc-100 text-2xl font-bold font-display">
{{ $t("store.recentlyUpdated") }}
</h1>
<NuxtLink class="text-blue-600 font-semibold">
<i18n-t keypath="store.exploreMore" tag="span" scope="global">
<template #arrow>
<span aria-hidden="true">{{ $t("chars.arrow") }}</span>
</template>
</i18n-t>
</NuxtLink>
<div class="mt-4">
<GameCarousel :items="updated" :min="12" />
</div>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const recent = await $dropFetch("/api/v1/store/recent"); const recent = await $dropFetch("/api/v1/store/featured");
const updated = await $dropFetch("/api/v1/store/updated");
const released = await $dropFetch("/api/v1/store/released");
// const developers = await $dropFetch("/api/v1/store/developers");
// const publishers = await $dropFetch("/api/v1/store/publishers");
const { t } = useI18n(); const { t } = useI18n();

View File

@ -0,0 +1,48 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div class="w-full overflow-x-hidden">
<div class="relative overflow-hidden bg-zinc-900">
<!-- Decorative background image and gradient -->
<div aria-hidden="true" class="absolute inset-0">
<div class="absolute inset-0 overflow-hidden">
<img alt="" class="size-full object-cover" />
</div>
<div class="absolute inset-0 bg-zinc-900/75" />
<div class="absolute inset-0 bg-linear-to-t from-zinc-900" />
</div>
<!-- Callout -->
<section
aria-labelledby="sale-heading"
class="relative mx-auto flex max-w-7xl flex-col items-center px-4 pt-32 pb-8 text-center sm:px-6 lg:px-8"
>
<div class="mx-auto max-w-2xl lg:max-w-none">
<h2
id="sale-heading"
class="text-4xl font-bold font-display tracking-tight text-zinc-100 sm:text-5xl lg:text-6xl"
>
{{ tag.name }}
</h2>
</div>
</section>
</div>
<StoreView
:prefilled="{
tags: {
[tag.id]: true,
} as any,
}"
/>
</div>
</template>
<script setup lang="ts">
const route = useRoute();
const tagId = route.params.id;
const { tag } = await $dropFetch(`/api/v1/tags/${tagId}`);
useHead({
title: tag.name,
});
</script>

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "Genre" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RealTimeStrategy', 'CardGame', 'BoardGame', 'Compilation', 'MMORPG', 'MinigameCollection', 'Puzzle', 'MusicRhythm');
-- AlterTable
ALTER TABLE "Game" ADD COLUMN "genres" "Genre"[];

View File

@ -0,0 +1,14 @@
/*
Warnings:
- The values [RealTimeStrategy,CardGame,BoardGame,MinigameCollection,MusicRhythm] on the enum `Genre` will be removed. If these variants are still used in the database, this will fail.
*/
-- AlterEnum
BEGIN;
CREATE TYPE "Genre_new" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RTS', 'Card', 'Board', 'Compilation', 'MMORPG', 'Minigames', 'Puzzle', 'Rhythm');
ALTER TABLE "Game" ALTER COLUMN "genres" TYPE "Genre_new"[] USING ("genres"::text::"Genre_new"[]);
ALTER TYPE "Genre" RENAME TO "Genre_old";
ALTER TYPE "Genre_new" RENAME TO "Genre";
DROP TYPE "Genre_old";
COMMIT;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Game" ADD COLUMN "featured" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `genres` on the `Game` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Game" DROP COLUMN "genres";
-- DropEnum
DROP TYPE "Genre";

View File

@ -0,0 +1,5 @@
-- Add pg_trgm
CREATE EXTENSION pg_trgm;
-- Create index for tag names
-- CREATE INDEX trgm_tag_name ON "Tag" USING GIST (name gist_trgm_ops(siglen=32));

View File

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "Tag_name_idx" ON "Tag" USING GIST ("name" gist_trgm_ops(siglen=32));

View File

@ -0,0 +1,87 @@
/*
Warnings:
- You are about to drop the `Tag` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_ArticleToTag` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `_GameToTag` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropForeignKey
ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_A_fkey";
-- DropForeignKey
ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_B_fkey";
-- DropForeignKey
ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_A_fkey";
-- DropForeignKey
ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_B_fkey";
-- DropTable
DROP TABLE "Tag";
-- DropTable
DROP TABLE "_ArticleToTag";
-- DropTable
DROP TABLE "_GameToTag";
-- CreateTable
CREATE TABLE "GameTag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "GameTag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "NewsTag" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "NewsTag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "_GameToGameTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_GameToGameTag_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateTable
CREATE TABLE "_ArticleToNewsTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL,
CONSTRAINT "_ArticleToNewsTag_AB_pkey" PRIMARY KEY ("A","B")
);
-- CreateIndex
CREATE UNIQUE INDEX "GameTag_name_key" ON "GameTag"("name");
-- CreateIndex
CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32));
-- CreateIndex
CREATE UNIQUE INDEX "NewsTag_name_key" ON "NewsTag"("name");
-- CreateIndex
CREATE INDEX "_GameToGameTag_B_index" ON "_GameToGameTag"("B");
-- CreateIndex
CREATE INDEX "_ArticleToNewsTag_B_index" ON "_ArticleToNewsTag"("B");
-- AddForeignKey
ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_B_fkey" FOREIGN KEY ("B") REFERENCES "GameTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_B_fkey" FOREIGN KEY ("B") REFERENCES "NewsTag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -23,6 +23,8 @@ model Game {
ratings GameRating[] ratings GameRating[]
featured Boolean @default(false)
mIconObjectId String // linked to objects in s3 mIconObjectId String // linked to objects in s3
mBannerObjectId String // linked to objects in s3 mBannerObjectId String // linked to objects in s3
mCoverObjectId String mCoverObjectId String
@ -40,7 +42,7 @@ model Game {
collections CollectionEntry[] collections CollectionEntry[]
saves SaveSlot[] saves SaveSlot[]
screenshots Screenshot[] screenshots Screenshot[]
tags Tag[] tags GameTag[]
playtime Playtime[] playtime Playtime[]
developers Company[] @relation(name: "developers") developers Company[] @relation(name: "developers")
@ -50,6 +52,16 @@ model Game {
@@unique([libraryId, libraryPath], name: "libraryKey") @@unique([libraryId, libraryPath], name: "libraryKey")
} }
model GameTag {
id String @id @default(uuid())
name String @unique
games Game[]
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
}
model GameRating { model GameRating {
id String @id @default(uuid()) id String @id @default(uuid())

View File

@ -1,9 +1,8 @@
model Tag { model NewsTag {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
articles Article[] articles Article[]
games Game[]
} }
model Article { model Article {
@ -12,7 +11,7 @@ model Article {
description String description String
content String @db.Text content String @db.Text
tags Tag[] tags NewsTag[]
imageObjectId String? // Object ID imageObjectId String? // Object ID
publishedAt DateTime @default(now()) publishedAt DateTime @default(now())

View File

@ -0,0 +1,51 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import objectHandler from "~/server/internal/objects";
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const companyId = getRouterParam(h3, "id")!;
const company = await prisma.company.findUnique({
where: {
id: companyId,
},
});
if (!company)
throw createError({ statusCode: 400, statusMessage: "Invalid company id" });
const result = await handleFileUpload(h3, {}, ["internal:read"], 1);
if (!result)
throw createError({
statusCode: 400,
statusMessage: "File upload required (multipart form)",
});
const [ids, , pull, dump] = result;
const id = ids.at(0);
if (!id)
throw createError({
statusCode: 400,
statusMessage: "Upload at least one file.",
});
try {
await objectHandler.deleteAsSystem(company.mBannerObjectId);
await prisma.company.update({
where: {
id: companyId,
},
data: {
mBannerObjectId: id,
},
});
await pull();
} catch {
await dump();
}
return { id: id };
});

View File

@ -0,0 +1,37 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const GameDelete = type({
id: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const companyId = getRouterParam(h3, "id")!;
const body = await readDropValidatedBody(h3, GameDelete);
await prisma.game.update({
where: {
id: body.id,
},
data: {
publishers: {
disconnect: {
id: companyId,
},
},
developers: {
disconnect: {
id: companyId,
},
},
},
});
return;
});

View File

@ -0,0 +1,37 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const GamePatch = type({
action: "'developed' | 'published'",
enabled: "boolean",
id: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const companyId = getRouterParam(h3, "id")!;
const body = await readDropValidatedBody(h3, GamePatch);
const action = body.action === "developed" ? "developers" : "publishers";
const actionType = body.enabled ? "connect" : "disconnect";
await prisma.game.update({
where: {
id: body.id,
},
data: {
[action]: {
[actionType]: {
id: companyId,
},
},
},
});
return;
});

View File

@ -0,0 +1,69 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const GamePost = type({
published: "boolean",
developed: "boolean",
id: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const companyId = getRouterParam(h3, "id")!;
const body = await readDropValidatedBody(h3, GamePost);
if (!body.published && !body.developed)
throw createError({
statusCode: 400,
statusMessage: "Must be related (either developed or published).",
});
const publisherConnect = body.published
? {
publishers: {
connect: {
id: companyId,
},
},
}
: undefined;
const developerConnect = body.developed
? {
developers: {
connect: {
id: companyId,
},
},
}
: undefined;
const game = await prisma.game.update({
where: {
id: body.id,
},
data: {
...publisherConnect,
...developerConnect,
},
include: {
publishers: {
select: {
id: true,
},
},
developers: {
select: {
id: true,
},
},
},
});
return game;
});

View File

@ -0,0 +1,51 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import objectHandler from "~/server/internal/objects";
import { handleFileUpload } from "~/server/internal/utils/handlefileupload";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const companyId = getRouterParam(h3, "id")!;
const company = await prisma.company.findUnique({
where: {
id: companyId,
},
});
if (!company)
throw createError({ statusCode: 400, statusMessage: "Invalid company id" });
const result = await handleFileUpload(h3, {}, ["internal:read"], 1);
if (!result)
throw createError({
statusCode: 400,
statusMessage: "File upload required (multipart form)",
});
const [ids, , pull, dump] = result;
const id = ids.at(0);
if (!id)
throw createError({
statusCode: 400,
statusMessage: "Upload at least one file.",
});
try {
await objectHandler.deleteAsSystem(company.mLogoObjectId);
await prisma.company.update({
where: {
id: companyId,
},
data: {
mLogoObjectId: id,
},
});
await pull();
} catch {
await dump();
}
return { id: id };
});

View File

@ -0,0 +1,14 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:delete"]);
if (!allowed) throw createError({ statusCode: 403 });
const id = getRouterParam(h3, "id")!;
const company = await prisma.company.deleteMany({ where: { id } });
if (company.count == 0)
throw createError({ statusCode: 404, statusMessage: "Company not found" });
return;
});

View File

@ -0,0 +1,54 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const id = getRouterParam(h3, "id")!;
const company = await prisma.company.findUnique({
where: { id },
include: {
published: {
select: {
id: true,
},
},
developed: {
select: {
id: true,
},
},
},
});
if (!company)
throw createError({ statusCode: 404, statusMessage: "Company not found" });
const games = await prisma.game.findMany({
where: {
OR: [
{
developers: {
some: {
id: company.id,
},
},
},
{
publishers: {
some: {
id: company.id,
},
},
},
],
},
distinct: ["id"],
});
const companyFlatten = {
...company,
developed: company.developed.map((e) => e.id),
published: company.published.map((e) => e.id),
};
return { company: companyFlatten, games };
});

View File

@ -0,0 +1,23 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readBody(h3);
const id = getRouterParam(h3, "id")!;
const restOfTheBody = { ...body };
delete restOfTheBody["id"];
const newObj = await prisma.company.update({
where: {
id: id,
},
data: restOfTheBody,
// I would put a select here, but it would be based on the body, and muck up the types
});
return newObj;
});

View File

@ -0,0 +1,10 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["company:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const companies = await prisma.company.findMany({});
return companies;
});

View File

@ -1,17 +1,11 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
export default defineEventHandler<{ query: { id: string } }>(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]); const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const query = getQuery(h3); const gameId = getRouterParam(h3, "id")!;
const gameId = query.id?.toString();
if (!gameId)
throw createError({
statusCode: 400,
statusMessage: "Missing id in query",
});
await prisma.game.delete({ await prisma.game.delete({
where: { where: {

View File

@ -0,0 +1,40 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const gameId = getRouterParam(h3, "id")!;
const game = await prisma.game.findUnique({
where: {
id: gameId,
},
include: {
versions: {
orderBy: {
versionIndex: "asc",
},
select: {
versionIndex: true,
versionName: true,
platform: true,
delta: true,
},
},
tags: true,
},
});
if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game ID not found" });
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
game.libraryId,
game.libraryPath,
);
return { game, unimportedVersions };
});

View File

@ -6,9 +6,7 @@ export default defineEventHandler(async (h3) => {
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const body = await readBody(h3); const body = await readBody(h3);
const id = body.id; const id = getRouterParam(h3, "id")!;
if (!id)
throw createError({ statusCode: 400, statusMessage: "Missing id in body" });
const restOfTheBody = { ...body }; const restOfTheBody = { ...body };
delete restOfTheBody["id"]; delete restOfTheBody["id"];

View File

@ -14,6 +14,8 @@ export default defineEventHandler(async (h3) => {
statusMessage: "This endpoint requires multipart form data.", statusMessage: "This endpoint requires multipart form data.",
}); });
const gameId = getRouterParam(h3, "id")!;
const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1); const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1);
if (!uploadResult) if (!uploadResult)
throw createError({ throw createError({
@ -28,7 +30,6 @@ export default defineEventHandler(async (h3) => {
// handleFileUpload reads the rest of the options for us. // handleFileUpload reads the rest of the options for us.
const name = options.name; const name = options.name;
const description = options.description; const description = options.description;
const gameId = options.id;
const updateModel: Prisma.GameUpdateInput = { const updateModel: Prisma.GameUpdateInput = {
mName: name, mName: name,

View File

@ -0,0 +1,29 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const PatchTags = type({
tags: "string[]",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:update"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, PatchTags);
const id = getRouterParam(h3, "id")!;
await prisma.game.update({
where: {
id,
},
data: {
tags: {
connect: body.tags.map((e) => ({ id: e })),
},
},
});
return;
});

View File

@ -1,45 +1,16 @@
import aclManager from "~/server/internal/acls"; import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
import libraryManager from "~/server/internal/library";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); const allowed = await aclManager.allowSystemACL(h3, ["game:read"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const query = getQuery(h3); return await prisma.game.findMany({
const gameId = query.id?.toString(); select: {
if (!gameId) id: true,
throw createError({ mName: true,
statusCode: 400, mShortDescription: true,
statusMessage: "Missing id in query", mIconObjectId: true,
});
const game = await prisma.game.findUnique({
where: {
id: gameId,
},
include: {
versions: {
orderBy: {
versionIndex: "asc",
},
select: {
versionIndex: true,
versionName: true,
platform: true,
delta: true,
},
},
}, },
}); });
if (!game || !game.libraryId)
throw createError({ statusCode: 404, statusMessage: "Game ID not found" });
const unimportedVersions = await libraryManager.fetchUnimportedGameVersions(
game.libraryId,
game.libraryPath,
);
return { game, unimportedVersions };
}); });

View File

@ -5,10 +5,13 @@ export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]); const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const unimportedGames = await libraryManager.fetchAllUnimportedGames(); const unimportedGames = await libraryManager.fetchUnimportedGames();
const libraries = Object.fromEntries(
(await libraryManager.fetchLibraries()).map((e) => [e.id, e]),
);
const iterableUnimportedGames = Object.entries(unimportedGames) const iterableUnimportedGames = Object.entries(unimportedGames)
.map(([libraryId, gameArray]) => .map(([libraryId, gameArray]) =>
gameArray.map((e) => ({ game: e, library: libraryId })), gameArray.map((e) => ({ game: e, library: libraries[libraryId] })),
) )
.flat(); .flat();
return { unimportedGames: iterableUnimportedGames }; return { unimportedGames: iterableUnimportedGames };

View File

@ -5,7 +5,7 @@ export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["library:read"]); const allowed = await aclManager.allowSystemACL(h3, ["library:read"]);
if (!allowed) throw createError({ statusCode: 403 }); if (!allowed) throw createError({ statusCode: 403 });
const unimportedGames = await libraryManager.fetchAllUnimportedGames(); const unimportedGames = await libraryManager.fetchUnimportedGames();
const games = await libraryManager.fetchGamesWithStatus(); const games = await libraryManager.fetchGamesWithStatus();
// Fetch other library data here // Fetch other library data here

View File

@ -0,0 +1,14 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["tags:delete"]);
if (!allowed) throw createError({ statusCode: 403 });
const id = getRouterParam(h3, "id")!;
const tag = await prisma.gameTag.deleteMany({ where: { id } });
if (tag.count == 0)
throw createError({ statusCode: 404, statusMessage: "Tag not found" });
return;
});

View File

@ -0,0 +1,10 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const tags = await prisma.gameTag.findMany({ orderBy: { name: "asc" } });
return tags;
});

View File

@ -0,0 +1,22 @@
import { type } from "arktype";
import { readDropValidatedBody, throwingArktype } from "~/server/arktype";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
const CreateTag = type({
name: "string",
}).configure(throwingArktype);
export default defineEventHandler(async (h3) => {
const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]);
if (!allowed) throw createError({ statusCode: 403 });
const body = await readDropValidatedBody(h3, CreateTag);
const tag = await prisma.gameTag.create({
data: {
...body,
},
});
return tag;
});

View File

@ -0,0 +1,23 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 });
const companyId = getRouterParam(h3, "id");
if (!companyId)
throw createError({
statusCode: 400,
statusMessage: "Missing gameId in route params (somehow...?)",
});
const company = await prisma.company.findUnique({
where: { id: companyId },
});
if (!company)
throw createError({ statusCode: 404, statusMessage: "Company not found" });
return { company };
});

View File

@ -16,6 +16,23 @@ export default defineEventHandler(async (h3) => {
where: { id: gameId }, where: { id: gameId },
include: { include: {
versions: true, versions: true,
publishers: {
select: {
id: true,
mName: true,
mShortDescription: true,
mLogoObjectId: true,
},
},
developers: {
select: {
id: true,
mName: true,
mShortDescription: true,
mLogoObjectId: true,
},
},
tags: true,
}, },
}); });

View File

@ -6,6 +6,9 @@ export default defineEventHandler(async (h3) => {
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const games = await prisma.game.findMany({ const games = await prisma.game.findMany({
where: {
featured: true,
},
select: { select: {
id: true, id: true,
mName: true, mName: true,
@ -28,7 +31,6 @@ export default defineEventHandler(async (h3) => {
orderBy: { orderBy: {
created: "desc", created: "desc",
}, },
take: 8,
}); });
return games; return games;

View File

@ -0,0 +1,122 @@
import { ArkErrors, type } from "arktype";
import type { Prisma } from "~/prisma/client/client";
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
import { parsePlatform } from "~/server/internal/utils/parseplatform";
const StoreRead = type({
skip: type("string")
.pipe((s) => Number.parseInt(s))
.default("0"),
take: type("string")
.pipe((s) => Number.parseInt(s))
.default("10"),
tags: "string?",
platform: "string?",
company: "string?",
companyActions: "string = 'published,developed'",
sort: "'default' | 'newest' | 'recent' = 'default'",
});
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 });
const query = getQuery(h3);
const options = StoreRead(query);
if (options instanceof ArkErrors)
throw createError({ statusCode: 400, statusMessage: options.summary });
/**
* Generic filters
*/
const tagFilter = options.tags
? {
tags: {
some: {
id: {
in: options.tags.split(","),
},
},
},
}
: undefined;
const platformFilter = options.platform
? {
versions: {
some: {
platform: {
in: options.platform
.split(",")
.map(parsePlatform)
.filter((e) => e !== undefined),
},
},
},
}
: undefined;
/**
* Company filtering
*/
const companyActions = options.companyActions.split(",");
const developedFilter = companyActions.includes("developed")
? {
developers: {
some: {
id: options.company!,
},
},
}
: undefined;
const publishedFilter = companyActions.includes("published")
? {
publishers: {
some: {
id: options.company!,
},
},
}
: undefined;
const companyFilter = options.company
? ({
OR: [developedFilter, publishedFilter].filter((e) => e !== undefined),
} satisfies Prisma.GameWhereInput)
: undefined;
/**
* Query
*/
const finalFilter: Prisma.GameWhereInput = {
...tagFilter,
...platformFilter,
...companyFilter,
};
const sort: Prisma.GameOrderByWithRelationInput = {};
switch (options.sort) {
case "default":
case "newest":
sort.mReleased = "desc";
break;
case "recent":
sort.created = "desc";
break;
}
const [results, count] = await prisma.$transaction([
prisma.game.findMany({
skip: options.skip,
take: Math.min(options.take, 50),
where: finalFilter,
orderBy: sort,
}),
prisma.game.count({ where: finalFilter }),
]);
return { results, count };
});

View File

@ -2,15 +2,9 @@ import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database"; import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => { export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserACL(h3, ["store:read"]); const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 }); if (!userId) throw createError({ statusCode: 403 });
const games = await prisma.game.findMany({ const tags = await prisma.gameTag.findMany({ orderBy: { name: "asc" } });
orderBy: { return tags;
mReleased: "desc",
},
take: 12,
});
return games;
}); });

View File

@ -1,28 +0,0 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 });
const versions = await prisma.gameVersion.findMany({
where: {
versionIndex: {
gte: 1,
},
},
select: {
game: true,
},
orderBy: {
created: "desc",
},
take: 12,
});
const games = versions
.map((e) => e.game)
.filter((v, i, a) => a.findIndex((e) => e.id === v.id) === i);
return games;
});

View File

@ -0,0 +1,23 @@
import aclManager from "~/server/internal/acls";
import prisma from "~/server/internal/db/database";
export default defineEventHandler(async (h3) => {
const userId = await aclManager.getUserIdACL(h3, ["store:read"]);
if (!userId) throw createError({ statusCode: 403 });
const tagId = getRouterParam(h3, "id");
if (!tagId)
throw createError({
statusCode: 400,
statusMessage: "Missing gameId in route params (somehow...?)",
});
const tag = await prisma.gameTag.findUnique({
where: { id: tagId },
});
if (!tag)
throw createError({ statusCode: 404, statusMessage: "Tag not found" });
return { tag };
});

View File

@ -70,6 +70,11 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"game:image:new": "Upload an image for a game.", "game:image:new": "Upload an image for a game.",
"game:image:delete": "Delete an image for a game.", "game:image:delete": "Delete an image for a game.",
"company:read": "Fetch companies.",
"company:create": "Create a new company.",
"company:update": "Update existing companies.",
"company:delete": "Delete companies.",
"import:version:read": "import:version:read":
"Fetch versions to be imported, and information about versions to be imported.", "Fetch versions to be imported, and information about versions to be imported.",
"import:version:new": "Import a game version.", "import:version:new": "Import a game version.",
@ -77,6 +82,10 @@ export const systemACLDescriptions: ObjectFromList<typeof systemACLs> = {
"Fetch games to be imported, and search the metadata for games.", "Fetch games to be imported, and search the metadata for games.",
"import:game:new": "Import a game.", "import:game:new": "Import a game.",
"tags:read": "Fetch all tags",
"tags:create": "Create a tag",
"tags:delete": "Delete a tag",
"user:read": "Fetch any user's information.", "user:read": "Fetch any user's information.",
"user:delete": "Delete a user.", "user:delete": "Delete a user.",

View File

@ -65,6 +65,11 @@ export const systemACLs = [
"game:image:new", "game:image:new",
"game:image:delete", "game:image:delete",
"company:read",
"company:update",
"company:create",
"company:delete",
"import:version:read", "import:version:read",
"import:version:new", "import:version:new",
@ -78,6 +83,10 @@ export const systemACLs = [
"news:create", "news:create",
"news:delete", "news:delete",
"tags:read",
"tags:create",
"tags:delete",
"task:read", "task:read",
"task:start", "task:start",

View File

@ -11,11 +11,15 @@ import { fuzzy } from "fast-fuzzy";
import taskHandler from "../tasks"; import taskHandler from "../tasks";
import { parsePlatform } from "../utils/parseplatform"; import { parsePlatform } from "../utils/parseplatform";
import notificationSystem from "../notifications"; import notificationSystem from "../notifications";
import type { LibraryProvider } from "./provider"; import { GameNotFoundError, type LibraryProvider } from "./provider";
import { logger } from "../logging";
class LibraryManager { class LibraryManager {
private libraries: Map<string, LibraryProvider<unknown>> = new Map(); private libraries: Map<string, LibraryProvider<unknown>> = new Map();
private gameImportLocks: Map<string, Array<string>> = new Map(); // Library ID to Library Path
private versionImportLocks: Map<string, Array<string>> = new Map(); // Game ID to Version Name
addLibrary(library: LibraryProvider<unknown>) { addLibrary(library: LibraryProvider<unknown>) {
this.libraries.set(library.id(), library); this.libraries.set(library.id(), library);
} }
@ -33,7 +37,7 @@ class LibraryManager {
return libraryWithMetadata; return libraryWithMetadata;
} }
async fetchAllUnimportedGames() { async fetchUnimportedGames() {
const unimportedGames: { [key: string]: string[] } = {}; const unimportedGames: { [key: string]: string[] } = {};
for (const [id, library] of this.libraries.entries()) { for (const [id, library] of this.libraries.entries()) {
@ -48,7 +52,9 @@ class LibraryManager {
}, },
}); });
const providerUnimportedGames = games.filter( const providerUnimportedGames = games.filter(
(e) => validGames.findIndex((v) => v.libraryPath == e) == -1, (e) =>
validGames.findIndex((v) => v.libraryPath == e) == -1 &&
!(this.gameImportLocks.get(id) ?? []).includes(e),
); );
unimportedGames[id] = providerUnimportedGames; unimportedGames[id] = providerUnimportedGames;
} }
@ -67,30 +73,34 @@ class LibraryManager {
}, },
}, },
select: { select: {
id: true,
versions: true, versions: true,
}, },
}); });
if (!game) return undefined; if (!game) return undefined;
const versions = await provider.listVersions(libraryPath); try {
const unimportedVersions = versions.filter( const versions = await provider.listVersions(libraryPath);
(e) => game.versions.findIndex((v) => v.versionName == e) == -1, const unimportedVersions = versions.filter(
); (e) =>
game.versions.findIndex((v) => v.versionName == e) == -1 &&
return unimportedVersions; !(this.versionImportLocks.get(game.id) ?? []).includes(e),
);
return unimportedVersions;
} catch (e) {
if (e instanceof GameNotFoundError) {
logger.warn(e);
return undefined;
}
throw e;
}
} }
async fetchGamesWithStatus() { async fetchGamesWithStatus() {
const games = await prisma.game.findMany({ const games = await prisma.game.findMany({
select: { include: {
id: true,
versions: true, versions: true,
mName: true, library: true,
mShortDescription: true,
metadataSource: true,
mIconObjectId: true,
libraryId: true,
libraryPath: true,
}, },
orderBy: { orderBy: {
mName: "asc", mName: "asc",
@ -98,19 +108,30 @@ class LibraryManager {
}); });
return await Promise.all( return await Promise.all(
games.map(async (e) => ({ games.map(async (e) => {
game: e, const versions = await this.fetchUnimportedGameVersions(
status: { e.libraryId ?? "",
noVersions: e.versions.length == 0, e.libraryPath,
unimportedVersions: (await this.fetchUnimportedGameVersions( );
e.libraryId ?? "", return {
e.libraryPath, game: e,
))!, status: versions
}, ? {
})), noVersions: e.versions.length == 0,
unimportedVersions: versions,
}
: ("offline" as const),
};
}),
); );
} }
/**
* Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported.
* @param gameId
* @param versionName
* @returns
*/
async fetchUnimportedVersionInformation(gameId: string, versionName: string) { async fetchUnimportedVersionInformation(gameId: string, versionName: string) {
const game = await prisma.game.findUnique({ const game = await prisma.game.findUnique({
where: { id: gameId }, where: { id: gameId },
@ -130,10 +151,7 @@ class LibraryManager {
// No extension is common for Linux binaries // No extension is common for Linux binaries
"", "",
], ],
Windows: [ Windows: [".exe", ".bat"],
// Pretty much the only one
".exe",
],
macOS: [ macOS: [
// App files // App files
".app", ".app",
@ -188,6 +206,70 @@ class LibraryManager {
} }
*/ */
/**
* Locks the game so you can't be imported
* @param libraryId
* @param libraryPath
*/
async lockGame(libraryId: string, libraryPath: string) {
let games = this.gameImportLocks.get(libraryId);
if (!games) this.gameImportLocks.set(libraryId, (games = []));
if (!games.includes(libraryPath)) games.push(libraryPath);
this.gameImportLocks.set(libraryId, games);
}
/**
* Unlocks the game, call once imported
* @param libraryId
* @param libraryPath
*/
async unlockGame(libraryId: string, libraryPath: string) {
let games = this.gameImportLocks.get(libraryId);
if (!games) this.gameImportLocks.set(libraryId, (games = []));
if (games.includes(libraryPath))
games.splice(
games.findIndex((e) => e === libraryPath),
1,
);
this.gameImportLocks.set(libraryId, games);
}
/**
* Locks a version so it can't be imported
* @param gameId
* @param versionName
*/
async lockVersion(gameId: string, versionName: string) {
let versions = this.versionImportLocks.get(gameId);
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
if (!versions.includes(versionName)) versions.push(versionName);
this.versionImportLocks.set(gameId, versions);
}
/**
* Unlocks the version, call once imported
* @param libraryId
* @param libraryPath
*/
async unlockVersion(gameId: string, versionName: string) {
let versions = this.versionImportLocks.get(gameId);
if (!versions) this.versionImportLocks.set(gameId, (versions = []));
if (versions.includes(gameId))
versions.splice(
versions.findIndex((e) => e === versionName),
1,
);
this.versionImportLocks.set(gameId, versions);
}
async importVersion( async importVersion(
gameId: string, gameId: string,
versionName: string, versionName: string,
@ -218,6 +300,8 @@ class LibraryManager {
const library = this.libraries.get(game.libraryId); const library = this.libraries.get(game.libraryId);
if (!library) return undefined; if (!library) return undefined;
await this.lockVersion(gameId, versionName);
taskHandler.create({ taskHandler.create({
id: taskId, id: taskId,
taskGroup: "import:game", taskGroup: "import:game",
@ -294,6 +378,9 @@ class LibraryManager {
progress(100); progress(100);
}, },
async finally() {
await libraryManager.unlockVersion(gameId, versionName);
},
}); });
return taskId; return taskId;

View File

@ -65,6 +65,11 @@ interface GameResult {
reviews?: Array<{ reviews?: Array<{
api_detail_url: string; api_detail_url: string;
}>; }>;
genres?: Array<{
name: string;
id: number;
}>;
} }
interface ReviewResult { interface ReviewResult {
@ -189,7 +194,7 @@ export class GiantBombProvider implements MetadataProvider {
context?.logger.warn(`Failed to import publisher "${pub}"`); context?.logger.warn(`Failed to import publisher "${pub}"`);
continue; continue;
} }
context?.logger.info(`Imported publisher "${pub}"`); context?.logger.info(`Imported publisher "${pub.name}"`);
publishers.push(res); publishers.push(res);
} }
} }
@ -224,11 +229,7 @@ export class GiantBombProvider implements MetadataProvider {
const releaseDate = gameData.original_release_date const releaseDate = gameData.original_release_date
? DateTime.fromISO(gameData.original_release_date).toJSDate() ? DateTime.fromISO(gameData.original_release_date).toJSDate()
: DateTime.fromISO( : new Date();
`${gameData.expected_release_year ?? new Date().getFullYear()}-${
gameData.expected_release_month ?? 1
}-${gameData.expected_release_day ?? 1}`,
).toJSDate();
context?.progress(85); context?.progress(85);
@ -249,6 +250,8 @@ export class GiantBombProvider implements MetadataProvider {
} }
} }
const tags = (gameData.genres ?? []).map((e) => e.name);
const metadata: GameMetadata = { const metadata: GameMetadata = {
id: gameData.guid, id: gameData.guid,
name: gameData.name, name: gameData.name,
@ -256,7 +259,7 @@ export class GiantBombProvider implements MetadataProvider {
description: longDescription, description: longDescription,
released: releaseDate, released: releaseDate,
tags: [], tags,
reviews, reviews,

View File

@ -450,7 +450,7 @@ export class IGDBProvider implements MetadataProvider {
mReviewHref: currentGame.url, mReviewHref: currentGame.url,
}; };
const tags = await this.getGenres(currentGame.genres); const genres = await this.getGenres(currentGame.genres);
const deck = this.trimMessage(currentGame.summary, 280); const deck = this.trimMessage(currentGame.summary, 280);
@ -461,12 +461,13 @@ export class IGDBProvider implements MetadataProvider {
description: currentGame.summary, description: currentGame.summary,
released, released,
genres,
reviews: [review], reviews: [review],
publishers, publishers,
developers, developers,
tags, tags: [],
icon, icon,
bannerId: banner, bannerId: banner,

View File

@ -18,6 +18,8 @@ import taskHandler, { wrapTaskContext } from "../tasks";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { fuzzy } from "fast-fuzzy"; import { fuzzy } from "fast-fuzzy";
import { logger } from "~/server/internal/logging"; import { logger } from "~/server/internal/logging";
import libraryManager from "../library";
import type { GameTagModel } from "~/prisma/client/models";
export class MissingMetadataProviderConfig extends Error { export class MissingMetadataProviderConfig extends Error {
private providerName: string; private providerName: string;
@ -124,19 +126,22 @@ export class MetadataHandler {
); );
} }
private parseTags(tags: string[]) { private async parseTags(tags: string[]) {
const results: Array<Prisma.TagCreateOrConnectWithoutGamesInput> = []; const results: Array<GameTagModel> = [];
tags.forEach((t) => for (const tag of tags) {
results.push({ const rawResults: GameTagModel[] =
where: { await prisma.$queryRaw`SELECT * FROM "GameTag" WHERE SIMILARITY(name, ${tag}) > 0.45;`;
name: t, let resultTag = rawResults.at(0);
}, if (!resultTag) {
create: { resultTag = await prisma.gameTag.create({
name: t, data: {
}, name: tag,
}), },
); });
}
results.push(resultTag);
}
return results; return results;
} }
@ -180,6 +185,8 @@ export class MetadataHandler {
}); });
if (existing) return undefined; if (existing) return undefined;
await libraryManager.lockGame(libraryId, libraryPath);
const gameId = randomUUID(); const gameId = randomUUID();
const taskId = `import:${gameId}`; const taskId = `import:${gameId}`;
@ -262,7 +269,7 @@ export class MetadataHandler {
connectOrCreate: metadataHandler.parseRatings(metadata.reviews), connectOrCreate: metadataHandler.parseRatings(metadata.reviews),
}, },
tags: { tags: {
connectOrCreate: metadataHandler.parseTags(metadata.tags), connect: await metadataHandler.parseTags(metadata.tags),
}, },
libraryId, libraryId,
@ -271,6 +278,10 @@ export class MetadataHandler {
}); });
logger.info(`Finished game import.`); logger.info(`Finished game import.`);
progress(100);
},
async finally() {
await libraryManager.unlockGame(libraryId, libraryPath);
}, },
}); });

View File

@ -206,6 +206,8 @@ class TaskHandler {
}; };
} }
if (task.finally) await task.finally();
taskEntry.endTime = new Date().toISOString(); taskEntry.endTime = new Date().toISOString();
await updateAllClients(); await updateAllClients();
@ -427,6 +429,7 @@ export interface Task {
taskGroup: TaskGroup; taskGroup: TaskGroup;
name: string; name: string;
run: (context: TaskRunContext) => Promise<void>; run: (context: TaskRunContext) => Promise<void>;
finally?: () => Promise<void> | void;
acls: GlobalACL[]; acls: GlobalACL[];
} }

View File

@ -2436,6 +2436,11 @@
resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f" resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f"
integrity sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w== integrity sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==
"@types/web-bluetooth@^0.0.21":
version "0.0.21"
resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63"
integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==
"@types/yauzl@^2.9.1": "@types/yauzl@^2.9.1":
version "2.10.3" version "2.10.3"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
@ -2893,6 +2898,35 @@
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.18.tgz#529f24a88d3ed678d50fd5c07455841fbe8ac95e" resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.18.tgz#529f24a88d3ed678d50fd5c07455841fbe8ac95e"
integrity sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA== integrity sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA==
"@vueuse/core@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.6.0.tgz#4137f63dc4cef2ff8ae74ee146d6b6070d707878"
integrity sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A==
dependencies:
"@types/web-bluetooth" "^0.0.21"
"@vueuse/metadata" "13.6.0"
"@vueuse/shared" "13.6.0"
"@vueuse/metadata@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-13.6.0.tgz#49196025c96c7daeb591c20a54b61cc336af99b6"
integrity sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ==
"@vueuse/nuxt@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@vueuse/nuxt/-/nuxt-13.6.0.tgz#96dfa26021bc17e1c5020c1c42ba425a9d00112f"
integrity sha512-zOZ5XkA7Svsx90934UWwKUsThAjKSD48Ks/mjEzl2gJm5d5zYJg+CJxPi7Wv5XECtCBOX18GpmTKqanWlbA1aQ==
dependencies:
"@nuxt/kit" "^4.0.1"
"@vueuse/core" "13.6.0"
"@vueuse/metadata" "13.6.0"
local-pkg "^1.1.1"
"@vueuse/shared@13.6.0":
version "13.6.0"
resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.6.0.tgz#872fdbd725fb4e3a12bd5aab85af9a5db0b1e481"
integrity sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg==
"@whatwg-node/disposablestack@^0.0.6": "@whatwg-node/disposablestack@^0.0.6":
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz#2064a1425ea66194def6df0c7a1851b6939c82bb" resolved "https://registry.yarnpkg.com/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz#2064a1425ea66194def6df0c7a1851b6939c82bb"