mirror of
https://github.com/Drop-OSS/drop.git
synced 2025-11-15 17:21:13 +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:
@ -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(
|
||||
|
||||
Reference in New Issue
Block a user