diff --git a/server/.vscode/settings.json b/server/.vscode/settings.json index 0570fe39..0660ee64 100644 --- a/server/.vscode/settings.json +++ b/server/.vscode/settings.json @@ -4,11 +4,7 @@ "strings": "on" }, "i18n-ally.extract.autoDetect": true, - "i18n-ally.extract.ignored": [ - "string >= 14", - "string.alphanumeric >= 5", - "/api/v1/admin/import/version/preload?id=${encodeURIComponent(\n gameId,\n )}&version=${encodeURIComponent(version)}" - ], + "i18n-ally.extract.ignored": ["string >= 14", "string.alphanumeric >= 5"], "i18n-ally.extract.ignoredByFiles": { "components/NewsArticleCreateButton.vue": ["[", "`", "Enter"], "pages/admin/library/sources/index.vue": ["Filesystem"], @@ -19,7 +15,7 @@ "i18n-ally.localesPaths": ["i18n", "i18n/locales"], // i18n Ally settings "i18n-ally.sortKeys": true, - "prisma.pinToPrisma6": true, + "prisma.pinToPrisma6": false, "spellchecker.ignoreWordsList": ["mTLS", "Wireguard"], "sqltools.connections": [ { diff --git a/server/components/GameEditor/Version.vue b/server/components/GameEditor/Version.vue index 28165fad..c2696565 100644 --- a/server/components/GameEditor/Version.vue +++ b/server/components/GameEditor/Version.vue @@ -46,6 +46,12 @@ > {{ $t("library.admin.version.table.path") }} + + {{ $t("library.admin.version.table.delta") }} + {{ version.versionPath }} + + {{ version.delta }} + + + @@ -293,8 +536,23 @@ import { ArrowTopRightOnSquareIcon, InformationCircleIcon, StarIcon, + WrenchScrewdriverIcon, + ArrowLongLeftIcon, + ArrowLongRightIcon, + ChevronDownIcon, + FunnelIcon, } from "@heroicons/vue/20/solid"; import { MagnifyingGlassIcon } from "@heroicons/vue/24/outline"; +import type { AdminLibraryGame } from "~/server/api/v1/admin/library/index.get"; +import { + Disclosure, + DisclosureButton, + DisclosurePanel, + Menu, + MenuButton, + MenuItem, + MenuItems, +} from "@headlessui/vue"; const { t } = useI18n(); @@ -306,33 +564,46 @@ useHead({ title: t("library.admin.title"), }); -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 { unimportedGames, hasLibraries } = await $dropFetch( + "/api/v1/admin/library/libraries", ); -const libraryGames = ref< - Array< - LibraryStateGame & { - status: "online" | "offline"; - hasNotifications?: boolean; - notifications: { - noVersions?: boolean; - toImport?: boolean; - offline?: boolean; - }; - } - > ->( - libraryState.games.map((e) => { +const route = useRoute(); +const router = useRouter(); + +// Hard limit on server +const pageSize = 24; +const currentIndex = ref( + route.query.page ? parseInt(route.query.page.toString()) - 1 : 0, +); +const maxIndex = ref(0); +const maxPages = computed(() => Math.ceil(maxIndex.value / pageSize)); + +const games = ref([]); +const gamesLoading = ref(false); + +const searchQuery = ref(""); + +function nextPage() { + if (currentIndex.value < maxPages.value - 1) { + currentIndex.value++; + } +} + +function previousPage() { + if (currentIndex.value > 0) { + currentIndex.value--; + } +} + +const toImport = ref(Object.values(unimportedGames).flat().length > 0); + +const libraryGames = computed(() => + games.value.map((e) => { if (e.status == "offline") { return { ...e.game, - status: "offline" as const, + status: "offline", hasNotifications: true, notifications: { offline: true, @@ -355,19 +626,6 @@ const libraryGames = ref< }), ); -const filteredLibraryGames = computed(() => - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore excessively deep ts - libraryGames.value.filter((e) => { - 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 deleteGame(id: string) { await $dropFetch(`/api/v1/admin/game/${id}`, { method: "DELETE", @@ -396,4 +654,147 @@ async function featureGame(id: string) { libraryGames.value[gameIndex].featured = !game.featured; gameFeatureLoading.value[game.id] = false; } + +const currentFilters = ref<{ [key: string]: boolean }>({}); + +function createFilterKey( + filter: { value: string }, + subfilter: { value: string }, +) { + return `${filter.value}.${subfilter.value}`; +} + +const filters = computed( + () => + ({ + version: [ + { + value: "none", + label: t("library.admin.nav.filters.version.none"), + }, + /*{ + value: "available", + label: t("library.admin.nav.filters.version.available"), + },*/ + ], + metadata: [ + { + value: "featured", + label: t("library.admin.nav.filters.metadata.featured"), + }, + { + value: "noCarousel", + label: t("library.admin.nav.filters.metadata.noCarousel"), + }, + { + value: "emptyDescription", + label: t("library.admin.nav.filters.metadata.emptyDescription"), + }, + ], + }) as const, +); + +const filterScaffold = computed( + () => + ({ + version: { + title: t("library.admin.nav.filters.version.title"), + value: "version", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + values: filters.value.version as any, + }, + metadata: { + title: t("library.admin.nav.filters.metadata.title"), + value: "metadata", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + values: filters.value.metadata as any, + }, + }) satisfies { + [key in keyof typeof filters.value]: { + title: string; + value: string; + values: Array<{ value: string; label: string }>; + }; + }, +); + +const sorts: Array = [ + { + name: "Default", + param: "default", + }, + { + name: "Newest", + param: "newest", + }, + { + name: "Recently Added", + param: "recent", + }, + { + name: "Name", + param: "name", + }, +]; + +const currentSort = ref(sorts[0].param); +const sortOrder = ref<"asc" | "desc">("desc"); + +function handleSortClick(option: StoreSortOption, event: MouseEvent) { + event.stopPropagation(); + if (currentSort.value === option.param) { + sortOrder.value = sortOrder.value === "asc" ? "desc" : "asc"; + } else { + currentSort.value = option.param; + sortOrder.value = option.param === "name" ? "asc" : "desc"; + } +} + +async function fetchPage() { + gamesLoading.value = true; + const { results, count } = await $dropFetch("/api/v1/admin/library", { + query: { + skip: currentIndex.value * pageSize, + limit: pageSize, + sort: currentSort.value, + order: sortOrder.value, + filters: Object.entries(currentFilters.value) + .filter(([_, enabled]) => enabled) + .map(([name, _]) => name) + .join(","), + query: searchQuery.value ? searchQuery.value : undefined, + }, + failTitle: "Failed to fetch game library", + }); + maxIndex.value = count; + games.value = results; + gamesLoading.value = false; + router.push({ + path: route.path, + query: { + ...route.query, + page: currentIndex.value + 1, + }, + }); +} + +function watchHandler() { + fetchPage(); + document.body.scrollTop = document.documentElement.scrollTop = 0; +} + +watch([currentIndex, currentSort, sortOrder], watchHandler); + +watch(currentFilters, watchHandler, { deep: true }); + +let searchTimeout: NodeJS.Timeout | undefined; +watch(searchQuery, () => { + if (searchTimeout) clearTimeout(searchTimeout); + gamesLoading.value = true; + searchTimeout = setTimeout(() => { + watchHandler(); + }, 80); +}); + +await fetchPage(); diff --git a/server/pages/admin/library/mass-import.vue b/server/pages/admin/library/mass-import.vue new file mode 100644 index 00000000..4e8c4c48 --- /dev/null +++ b/server/pages/admin/library/mass-import.vue @@ -0,0 +1,363 @@ + + + diff --git a/server/pages/admin/task/[id]/index.vue b/server/pages/admin/task/[id]/index.vue index bcdb0a4f..79d52346 100644 --- a/server/pages/admin/task/[id]/index.vue +++ b/server/pages/admin/task/[id]/index.vue @@ -40,7 +40,7 @@ -