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

@@ -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"
@click="() => (showEditCoreMetadata = true)"
>
{{ $t("edit") }} <PencilIcon class="size-4" />
{{ $t("common.edit") }} <PencilIcon class="size-4" />
</button>
</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 -->
<div class="border-b border-zinc-700">
<div class="border-b border-zinc-700 py-4">
@@ -268,7 +272,7 @@
</div>
</div>
</div>
<UploadFileDialog
<ModalUploadFile
v-model="showUploadModal"
:options="{ id: game.id }"
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"
@click="showAddCarouselModal = false"
>
{{ $t("close") }}
{{ $t("common.close") }}
</button>
</template>
</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"
@click="() => insertImageAtCursor(image)"
>
{{ $t("insert") }}
{{ $t("common.insert") }}
</button>
</div>
</div>
@@ -424,7 +428,7 @@
:class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']"
@click="() => coreMetadataUpdate_wrapper()"
>
{{ $t("save") }}
{{ $t("common.save") }}
</LoadingButton>
<button
ref="cancelButtonRef"
@@ -440,7 +444,7 @@
</template>
<script setup lang="ts">
import type { GameModel } from "~/prisma/client/models";
import type { GameModel, GameTagModel } from "~/prisma/client/models";
import { micromark } from "micromark";
import {
CheckIcon,
@@ -451,25 +455,42 @@ import {
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
definePageMeta({
layout: "admin",
});
const showUploadModal = ref(false);
const showAddCarouselModal = ref(false);
const showAddImageDescriptionModal = ref(false);
const showEditCoreMetadata = ref(false);
const mobileShowFinalDescription = ref(true);
const game = defineModel<SerializeObject<GameModel>>() as Ref<
SerializeObject<GameModel>
>;
type ModelType = SerializeObject<GameModel & { tags: Array<GameTagModel> }>;
const game = defineModel<ModelType>() as Ref<ModelType>;
if (!game.value)
throw createError({
statusCode: 500,
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();
// I don't know why I split these fields off.
@@ -493,7 +514,7 @@ function coreMetadataUploadFiles(e: InputEvent) {
{
title: t("errors.upload.title"),
description: t("errors.upload.description", [t("errors.unknown")]),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);
@@ -510,14 +531,16 @@ async function coreMetadataUpdate() {
formData.append("icon", newIcon);
}
formData.append("id", game.value.id);
formData.append("name", coreMetadataName.value);
formData.append("description", coreMetadataDescription.value);
const result = await $dropFetch(`/api/v1/admin/game/metadata`, {
method: "POST",
body: formData,
});
const result = await $dropFetch(
`/api/v1/admin/game/${game.value.id}/metadata`,
{
method: "POST",
body: formData,
},
);
return result;
}
@@ -532,14 +555,16 @@ function coreMetadataUpdate_wrapper() {
description: t("errors.game.metadata.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);
})
.then((newGame) => {
console.log(newGame);
if (!newGame) return;
Object.assign(game.value, newGame);
coreMetadataIconUrl.value = useObject(newGame.mIconObjectId);
})
.finally(() => {
coreMetadataLoading.value = false;
@@ -573,10 +598,12 @@ watch(descriptionHTML, (_v) => {
savingTimeout = setTimeout(async () => {
try {
descriptionSaving.value = DescriptionSavingState.Loading;
await $dropFetch("/api/v1/admin/game", {
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
body: {
params: {
id: game.value.id,
},
body: {
mDescription: game.value.mDescription,
} satisfies PatchGameBody,
});
@@ -589,7 +616,7 @@ watch(descriptionHTML, (_v) => {
description: t("errors.game.description.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);
@@ -617,10 +644,12 @@ function insertImageAtCursor(id: string) {
async function updateBannerImage(id: string) {
try {
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",
body: {
params: {
id: game.value.id,
},
body: {
mBannerObjectId: id,
} satisfies PatchGameBody,
});
@@ -633,7 +662,7 @@ async function updateBannerImage(id: string) {
description: t("errors.game.banner.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);
@@ -643,10 +672,12 @@ async function updateBannerImage(id: string) {
async function updateCoverImage(id: string) {
try {
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",
body: {
params: {
id: game.value.id,
},
body: {
mCoverObjectId: id,
} satisfies PatchGameBody,
});
@@ -659,7 +690,7 @@ async function updateCoverImage(id: string) {
description: t("errors.game.cover.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);
@@ -688,7 +719,7 @@ async function deleteImage(id: string) {
description: t("errors.game.deleteImage.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);
@@ -715,10 +746,12 @@ function removeImageFromCarousel(id: string) {
async function updateImageCarousel() {
try {
await $dropFetch("/api/v1/admin/game", {
await $dropFetch(`/api/v1/admin/game/:id`, {
method: "PATCH",
body: {
params: {
id: game.value.id,
},
body: {
mImageCarouselObjectIds: game.value.mImageCarouselObjectIds,
} satisfies PatchGameBody,
});
@@ -730,7 +763,7 @@ async function updateImageCarousel() {
description: t("errors.game.carousel.description", [
(e as H3Error)?.statusMessage ?? t("errors.unknown"),
]),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);

View File

@@ -1,6 +1,6 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div v-if="game">
<div v-if="game && unimportedVersions">
<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
@@ -22,7 +22,6 @@
<!-- import games button -->
<NuxtLink
v-if="unimportedVersions !== undefined"
:href="
unimportedVersions.length > 0
? `/admin/library/${game.id}/import`
@@ -96,6 +95,24 @@
</div>
</div>
</div>
<div v-else class="grow w-full flex items-center justify-center">
<div class="flex flex-col items-center">
<ExclamationCircleIcon
class="h-12 w-12 text-red-600"
aria-hidden="true"
/>
<div class="mt-3 text-center sm:mt-5">
<h1 class="text-3xl font-semibold font-display leading-6 text-zinc-100">
{{ $t("library.admin.offlineTitle") }}
</h1>
<div class="mt-4">
<p class="text-sm text-zinc-400 max-w-md">
{{ $t("library.admin.offline") }}
</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
@@ -103,10 +120,7 @@ import type { GameModel, GameVersionModel } from "~/prisma/client/models";
import { Bars3Icon, TrashIcon } from "@heroicons/vue/24/solid";
import type { SerializeObject } from "nitropack";
import type { H3Error } from "h3";
definePageMeta({
layout: "admin",
});
import { ExclamationCircleIcon } from "@heroicons/vue/24/outline";
// TODO implement UI for this page
@@ -142,7 +156,7 @@ async function updateVersionOrder() {
description: t("errors.version.order.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);
@@ -170,7 +184,7 @@ async function deleteVersion(versionName: string) {
description: t("errors.version.delete.desc", {
error: (e as H3Error)?.statusMessage ?? t("errors.unknown"),
}),
buttonText: t("close"),
buttonText: t("common.close"),
},
(e, c) => c(),
);