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

@ -24,7 +24,7 @@
scope="col"
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
scope="col"

View File

@ -131,7 +131,8 @@ import type { Component } from "vue";
const route = useRoute();
const gameId = route.params.id.toString();
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);
@ -139,11 +140,6 @@ definePageMeta({
layout: "admin",
});
useHead({
// To do a title with the game name in it, we need some sort of watch
title: "Game Editor",
});
enum GameEditorMode {
Metadata = "Metadata",
Versions = "Versions",
@ -160,4 +156,15 @@ const components: {
};
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>

View File

@ -12,9 +12,15 @@
<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"
>
<span v-if="currentlySelectedGame != -1" class="block truncate">{{
games.unimportedGames[currentlySelectedGame].game
}}</span>
<span v-if="currentlySelectedGame != -1" class="block truncate"
>{{ games.unimportedGames[currentlySelectedGame].game }}
<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">{{
$t("library.admin.import.selectDir")
}}</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"
>
<ListboxOption
v-for="({ game }, gameIdx) in games.unimportedGames"
v-for="({ game, library }, gameIdx) in games.unimportedGames"
:key="game"
v-slot="{ active, selected }"
v-slot="{ active }"
as="template"
:value="gameIdx"
>
@ -51,14 +57,20 @@
>
<span
:class="[
selected ? 'font-semibold' : 'font-normal',
'block truncate',
gameIdx === currentlySelectedGame
? '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
v-if="selected"
v-if="gameIdx === currentlySelectedGame"
:class="[
active ? 'text-white' : 'text-blue-600',
'absolute inset-y-0 right-0 flex items-center pr-4',
@ -72,6 +84,34 @@
</transition>
</div>
</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">
<!-- without metadata option -->
@ -277,18 +317,20 @@ definePageMeta({
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 gameSearchResultsLoading = ref(false);
const gameSearchResultsError = ref<string | undefined>();
const gameSearchTerm = ref("");
const gameSearchLoading = ref(false);
const bulkImportMode = ref(false);
async function updateSelectedGame(value: number) {
if (currentlySelectedGame.value == value) return;
currentlySelectedGame.value = value;
if (currentlySelectedGame.value == -1) return;
const option = games.unimportedGames[currentlySelectedGame.value];
const option = games.value.unimportedGames[currentlySelectedGame.value];
if (!option) return;
metadataResults.value = undefined;
@ -299,12 +341,19 @@ async function updateSelectedGame(value: number) {
}
async function searchGame() {
gameSearchResultsError.value = undefined;
gameSearchLoading.value = true;
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
try {
const results = await $dropFetch(
`/api/v1/admin/import/game/search?q=${encodeURIComponent(gameSearchTerm.value)}`,
);
metadataResults.value = results;
gameSearchLoading.value = false;
} catch (e) {
gameSearchLoading.value = false;
throw e;
}
}
function updateSelectedGame_wrapper(value: number) {
@ -332,18 +381,24 @@ async function importGame(useMetadata: boolean) {
useMetadata && metadataResults.value
? metadataResults.value[currentlySelectedMetadata.value]
: undefined;
const option = games.unimportedGames[currentlySelectedGame.value];
const option = games.value.unimportedGames[currentlySelectedGame.value];
const { taskId } = await $dropFetch("/api/v1/admin/import/game", {
method: "POST",
body: {
path: option.game,
library: option.library,
library: option.library.id,
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) {
importLoading.value = true;

View File

@ -78,20 +78,55 @@
<li
v-for="game in filteredLibraryGames"
: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">
<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)"
alt=""
/>
<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 }}
<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
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"
>{{ game.metadataSource }}</span
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.library!.name }}</span
>
</h3>
<dl class="mt-1 flex flex-col justify-between">
@ -180,6 +215,24 @@
</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>
</li>
<p
@ -199,8 +252,11 @@
</template>
<script setup lang="ts">
import { ExclamationTriangleIcon } from "@heroicons/vue/16/solid";
import { InformationCircleIcon } from "@heroicons/vue/20/solid";
import {
ExclamationTriangleIcon,
ExclamationCircleIcon,
} from "@heroicons/vue/16/solid";
import { InformationCircleIcon, StarIcon } from "@heroicons/vue/20/solid";
import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline";
const { t } = useI18n();
@ -216,13 +272,37 @@ useHead({
const searchQuery = ref("");
const libraryState = await $dropFetch("/api/v1/admin/library");
type LibraryStateGame = (typeof libraryState.games)[number]["game"];
const toImport = ref(
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) => {
if (e.status == "offline") {
return {
...e.game,
status: "offline" as const,
hasNotifications: true,
notifications: {
offline: true,
},
};
}
const noVersions = e.status.noVersions;
const toImport = e.status.unimportedVersions.length > 0;
@ -233,6 +313,7 @@ const libraryGames = ref(
toImport,
},
hasNotifications: noVersions || toImport,
status: "online" as const,
};
}),
);
@ -251,9 +332,31 @@ const filteredLibraryGames = computed(() =>
);
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);
libraryGames.value.splice(index, 1);
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>

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"
@click="() => (actionSourceOpen = true)"
>
{{ $t("create") }}
{{ $t("common.create") }}
</button>
</div>
</div>
@ -28,7 +28,7 @@
scope="col"
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
scope="col"
@ -49,7 +49,7 @@
{{ $t("options") }}
</th>
<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>
</tr>
</thead>
@ -84,7 +84,7 @@
class="text-blue-500 hover:text-blue-400"
@click="() => edit(sourceIdx)"
>
{{ $t("edit") }}
{{ $t("common.edit") }}
<span class="sr-only">
{{ $t("chars.srComma", [source.name]) }}
</span>
@ -110,9 +110,20 @@
<ModalTemplate v-model="actionSourceOpen">
<template #default>
<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") }}
</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">
{{ $t("library.admin.sources.createDesc") }}
</p>
@ -125,7 +136,7 @@
<label
for="name"
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">
{{ $t("library.admin.sources.nameDesc") }}
@ -228,7 +239,7 @@
class="w-full sm:w-fit"
@click="() => performActionSource_wrapper()"
>
{{ createMode ? $t("create") : $t("save") }}
{{ createMode ? $t("common.create") : $t("common.save") }}
</LoadingButton>
<button
ref="cancelButtonRef"
@ -279,6 +290,10 @@ definePageMeta({
layout: "admin",
});
useHead({
title: "Library Sources",
});
const { t } = useI18n();
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"
:disabled="!allowSave"
>
{{ allowSave ? $t("save") : $t("saved") }}
{{ allowSave ? $t("common.save") : $t("common.saved") }}
</LoadingButton>
</form>
</div>
@ -78,7 +78,7 @@ useHead({
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 allowSave = ref(false);

View File

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

View File

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

View File

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

View File

@ -51,7 +51,7 @@
</TransitionChild>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div class="bg-zinc-900">
<LibraryDirectory />
<DirectoryLibrary />
</div>
</DialogPanel>
</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"
>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<LibraryDirectory />
<DirectoryLibrary />
</div>
<div

View File

@ -57,7 +57,7 @@
: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"
>
{{ $t("store.view") }}
{{ $t("store.viewInStore") }}
<ArrowUpRightIcon class="-mr-0.5 h-5 w-5" aria-hidden="true" />
</NuxtLink>
</div>

View File

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

View File

@ -51,7 +51,7 @@
</TransitionChild>
<div class="bg-zinc-900">
<NewsArticleCreateButton />
<NewsDirectory :articles="news" />
<DirectoryNews />
</div>
</DialogPanel>
</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"
>
<NewsArticleCreateButton />
<NewsDirectory />
<DirectoryNews />
</div>
<div

View File

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

View File

@ -108,6 +108,72 @@
}}</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.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>
</table>
</div>
@ -225,6 +291,7 @@ const ratingArray = Array(5)
useHead({
title: game.mName,
link: [{ rel: "icon", href: useObject(game.mIconObjectId) }],
});
</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">
<h3 class="text-base/7 font-semibold text-blue-300">
{{ $t("store.recentlyAdded") }}
{{ $t("store.featured") }}
</h3>
<h2
class="text-3xl font-bold tracking-tight text-white sm:text-5xl"
>
{{ game.mName }}
</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 }}
</p>
<div>
@ -66,49 +68,12 @@
</h2>
</div>
<!-- new releases -->
<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>
<StoreView />
</div>
</template>
<script setup lang="ts">
const recent = await $dropFetch("/api/v1/store/recent");
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 recent = await $dropFetch("/api/v1/store/featured");
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>