mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-10 04:22:09 +10:00
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:
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(
|
||||
|
||||
419
pages/admin/metadata/companies/[id]/index.vue
Normal file
419
pages/admin/metadata/companies/[id]/index.vue
Normal 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>
|
||||
150
pages/admin/metadata/companies/index.vue
Normal file
150
pages/admin/metadata/companies/index.vue
Normal 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>
|
||||
62
pages/admin/metadata/index.vue
Normal file
62
pages/admin/metadata/index.vue
Normal 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>
|
||||
75
pages/admin/metadata/tags.vue
Normal file
75
pages/admin/metadata/tags.vue
Normal 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>
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -138,7 +138,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DeleteUserModal v-model="userToDelete" />
|
||||
<ModalDeleteUser v-model="userToDelete" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -157,7 +157,7 @@
|
||||
|
||||
<div>
|
||||
<LoadingButton type="submit" :loading="loading" class="w-full">
|
||||
{{ $t("create") }}
|
||||
{{ $t("common.create") }}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -88,8 +88,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CreateCollectionModal v-model="collectionCreateOpen" />
|
||||
<DeleteCollectionModal v-model="currentlyDeleting" />
|
||||
<ModalCreateCollection v-model="collectionCreateOpen" />
|
||||
<ModalDeleteCollection v-model="currentlyDeleting" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DeleteNewsModal v-model="currentlyDeleting" />
|
||||
<ModalDeleteNews v-model="currentlyDeleting" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
65
pages/store/c/[id]/index.vue
Normal file
65
pages/store/c/[id]/index.vue
Normal 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>
|
||||
@ -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();
|
||||
|
||||
|
||||
48
pages/store/t/[id]/index.vue
Normal file
48
pages/store/t/[id]/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user