diff --git a/.prettierignore b/.prettierignore index 1e7121ff..3c9727cb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1 @@ -drop-base/ +drop-base/ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1cc3faf2..5bb86362 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -148,7 +148,6 @@ type(scope)!: subject ``` - `type`: the type of the commit is one of the following: - - `feat`: new features. - `fix`: bug fixes. - `docs`: documentation changes. @@ -165,7 +164,6 @@ type(scope)!: subject - `scope`: section of the codebase that the commit makes changes to. If it makes changes to many sections, or if no section in particular is modified, leave blank without the parentheses. Examples: - - Commit that changes the `git` plugin: ``` @@ -179,7 +177,6 @@ type(scope)!: subject ``` For changes to plugins or themes, the scope should be the plugin or theme name: - - ✅ `fix(agnoster): commit subject` - ❌ `fix(theme/agnoster): commit subject` @@ -209,7 +206,6 @@ type(scope)!: subject to specify other details, you can use the commit body, but it won't be visible. Formatting tricks: the commit subject may contain: - - Links to related issues or PRs by writing `#issue`. This will be highlighted by the changelog tool: ``` diff --git a/components/AddLibraryButton.vue b/components/AddLibraryButton.vue index 526e2eb2..0db4d0ea 100644 --- a/components/AddLibraryButton.vue +++ b/components/AddLibraryButton.vue @@ -84,7 +84,7 @@ - @@ -122,20 +122,9 @@ async function toggleLibrary() { body: { id: props.gameId, }, + failTitle: t("errors.library.add.title"), }); await refreshLibrary(); - } catch (e) { - createModal( - ModalType.Notification, - { - title: t("errors.library.add.title"), - description: t("errors.library.add.desc", [ - // @ts-expect-error attempt to display statusMessage on error - e?.statusMessage ?? t("errors.unknown"), - ]), - }, - (_, c) => c(), - ); } finally { isLibraryLoading.value = false; } @@ -147,26 +136,18 @@ async function toggleCollection(id: string) { if (!collection) return; const index = collection.entries.findIndex((e) => e.gameId == props.gameId); - await $dropFetch(`/api/v1/collection/${id}/entry`, { + await $dropFetch(`/api/v1/collection/:id/entry`, { method: index == -1 ? "POST" : "DELETE", + params: { id }, body: { id: props.gameId, }, + failTitle: t("errors.library.add.title"), }); await refreshCollection(id); - } catch (e) { - createModal( - ModalType.Notification, - { - title: t("errors.library.add.title"), - description: t("errors.library.add.desc", [ - // @ts-expect-error attempt to display statusMessage on error - e?.statusMessage ?? t("errors.unknown"), - ]), - }, - (_, c) => c(), - ); + } finally { + /* empty */ } } diff --git a/components/LibraryDirectory.vue b/components/Directory/Library.vue similarity index 89% rename from components/LibraryDirectory.vue rename to components/Directory/Library.vue index 40ddf8ce..ced13de6 100644 --- a/components/LibraryDirectory.vue +++ b/components/Directory/Library.vue @@ -31,11 +31,11 @@
  • diff --git a/components/NewsDirectory.vue b/components/Directory/News.vue similarity index 100% rename from components/NewsDirectory.vue rename to components/Directory/News.vue diff --git a/components/GameCarousel.vue b/components/GameCarousel.vue index 4190b85b..6b2cc3eb 100644 --- a/components/GameCarousel.vue +++ b/components/GameCarousel.vue @@ -44,9 +44,7 @@ const props = defineProps<{ width?: number; }>(); -const { showGamePanelTextDecoration } = await $dropFetch( - `/api/v1/admin/settings`, -); +const { showGamePanelTextDecoration } = await $dropFetch(`/api/v1/settings`); const currentComponent = ref(); diff --git a/components/GameEditor/Metadata.vue b/components/GameEditor/Metadata.vue index 04949015..5dfa85fa 100644 --- a/components/GameEditor/Metadata.vue +++ b/components/GameEditor/Metadata.vue @@ -23,10 +23,14 @@ class="relative inline-flex gap-x-3 items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" @click="() => (showEditCoreMetadata = true)" > - {{ $t("edit") }} + {{ $t("common.edit") }}
    +
    + +
    +
    @@ -268,7 +272,7 @@
    - - {{ $t("close") }} + {{ $t("common.close") }} @@ -335,7 +339,7 @@ class="inline-flex items-center gap-x-1.5 rounded-md bg-blue-600 px-1.5 py-0.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 transition-all duration-200 hover:scale-105 hover:shadow-lg hover:shadow-blue-500/25 active:scale-95 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" @click="() => insertImageAtCursor(image)" > - {{ $t("insert") }} + {{ $t("common.insert") }} @@ -424,7 +428,7 @@ :class="['inline-flex w-full shadow-sm sm:ml-3 sm:w-auto']" @click="() => coreMetadataUpdate_wrapper()" > - {{ $t("save") }} + {{ $t("common.save") }} @@ -49,8 +49,11 @@ import type { NotificationModel } from "~/prisma/client/models"; const props = defineProps<{ notification: NotificationModel }>(); async function deleteMe() { - await $dropFetch(`/api/v1/notifications/${props.notification.id}`, { + await $dropFetch(`/api/v1/notifications/:id`, { method: "DELETE", + params: { + id: props.notification.id, + }, }); const notifications = useNotifications(); const indexOfMe = notifications.value.findIndex( diff --git a/components/StoreView.vue b/components/StoreView.vue new file mode 100644 index 00000000..97e91fb9 --- /dev/null +++ b/components/StoreView.vue @@ -0,0 +1,488 @@ + + + diff --git a/components/UserFooter.vue b/components/UserFooter.vue index b62cb77e..3060021d 100644 --- a/components/UserFooter.vue +++ b/components/UserFooter.vue @@ -116,7 +116,7 @@ const { t } = useI18n(); const versionInfo = await $dropFetch("/api/v1"); -const navigation = { +const navigation = computed(() => ({ games: [ { name: t("store.recentlyAdded"), href: "#" }, { name: t("store.recentlyReleased"), href: "#" }, @@ -156,5 +156,5 @@ const navigation = { icon: IconsDiscordLogo, }, ], -}; +})); diff --git a/composables/request.ts b/composables/request.ts index af42dfce..963de6d1 100644 --- a/composables/request.ts +++ b/composables/request.ts @@ -4,6 +4,7 @@ import type { NitroFetchRequest, TypedInternalResponse, } from "nitropack/types"; +import type { FetchError } from "ofetch"; interface DropFetch< DefaultT = unknown, @@ -15,7 +16,7 @@ interface DropFetch< O extends NitroFetchOptions = NitroFetchOptions, >( request: R, - opts?: O, + opts?: O & { failTitle?: string }, ): Promise< // sometimes there is an error, other times there isn't // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -28,12 +29,29 @@ interface DropFetch< >; } -export const $dropFetch: DropFetch = async (request, opts) => { +export const $dropFetch: DropFetch = async (rawRequest, opts) => { + const requestParts = rawRequest.toString().split("/"); + requestParts.forEach((part, index) => { + if (!part.startsWith(":")) { + return; + } + const partName = part.slice(1); + const replacement = opts?.params?.[partName] as string | undefined; + if (!replacement) { + return; + } + requestParts[index] = replacement; + + delete opts?.params?.[partName]; + }); + const request = requestParts.join("/"); + if (!getCurrentInstance()?.proxy) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore Excessive stack depth comparing types return await $fetch(request, opts); } + const id = request.toString(); const state = useState(id); @@ -41,15 +59,31 @@ export const $dropFetch: DropFetch = async (request, opts) => { // Deep copy const object = JSON.parse(JSON.stringify(state.value)); // Never use again on client - state.value = undefined; + if (import.meta.client) state.value = undefined; return object; } const headers = useRequestHeaders(["cookie", "authorization"]); - const data = await $fetch(request, { - ...opts, - headers: { ...opts?.headers, ...headers }, - }); - if (import.meta.server) state.value = data; - return data; + try { + const data = await $fetch(request, { + ...opts, + headers: { ...opts?.headers, ...headers }, + }); + if (import.meta.server) state.value = data; + return data; + } catch (e) { + if (import.meta.client && opts?.failTitle) { + createModal( + ModalType.Notification, + { + title: opts.failTitle, + description: + (e as FetchError)?.statusMessage ?? (e as string).toString(), + buttonText: $t("common.close"), + }, + (_, c) => c(), + ); + } + throw e; + } }; diff --git a/composables/store.ts b/composables/store.ts new file mode 100644 index 00000000..8b298671 --- /dev/null +++ b/composables/store.ts @@ -0,0 +1,11 @@ +export type StoreFilterOption = { + name: string; + param: string; + options: Array; + multiple?: boolean; +}; + +export type StoreSortOption = { + name: string; + param: string; +}; diff --git a/drop-base b/drop-base index a14d1b70..04125e89 160000 --- a/drop-base +++ b/drop-base @@ -1 +1 @@ -Subproject commit a14d1b7081cf2e6aa5174e3cfd7b7fe6904ab7bf +Subproject commit 04125e89bef517411e103cdabcfa64a1bb563423 diff --git a/eslint.config.mjs b/eslint.config.mjs index b4c5e3c2..0576c43b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -18,6 +18,7 @@ export default withNuxt([ extensions: [".js", ".vue", ".ts"], }, ], + "@intlify/vue-i18n/no-missing-keys": "error", }, settings: { "vue-i18n": { diff --git a/i18n/locales/en_pirate.json b/i18n/locales/en_pirate.json index 78577624..36001dec 100644 --- a/i18n/locales/en_pirate.json +++ b/i18n/locales/en_pirate.json @@ -24,8 +24,8 @@ }, "actions": "Deeds", "add": "Add", - "adminTitle": "Cap'n's Quarters | Drop", - "adminTitleTemplate": "{0} | Cap'n | Drop", + "adminTitle": "Cap'n's Quarters - Drop", + "adminTitleTemplate": "{0} - Cap'n - Drop", "auth": { "callback": { "authClient": "Grant passage to this scallywag?", @@ -70,27 +70,30 @@ "quoted": "\"\"", "srComma": ", {0}" }, - "close": "Shut yer trap!", "common": { "cannotUndo": "This deed cannot be undone, ye hear!", + "close": "Shut yer trap!", + "create": "Forge!", "date": "Date", "deleteConfirm": "Are ye sure ye want to scuttle \"{0}\", ye rogue?", "divider": "{'|'}", + "edit": "Amend", "friends": "Shipmates", "groups": "Crews", + "insert": "Insert", + "name": "Name, argh!", "noResults": "No plunder found!", + "save": "Stow it!", "servers": "Ships", "srLoading": "Loading, loading, argh...", "tags": "Marks", "today": "Today" }, - "create": "Forge!", "delete": "Scuttle!", "drop": { "desc": "An open-source game distribution platform built for speed, flexibility and beauty, like a swift brigantine!", "drop": "Drop" }, - "edit": "Amend", "editor": { "bold": "Bold, like a cannonball!", "boldPlaceholder": "bold text, matey", @@ -214,7 +217,6 @@ "helpUsTranslate": "Help us translate Drop {arrow}, argh!", "highest": "highest", "home": "Home Port", - "insert": "Insert", "library": { "addGames": "All Plunder", "addToLib": "Add to Yer Treasure Hoard", @@ -327,7 +329,6 @@ "subheader": "Sort yer plunder into collections for easy access, and get to all yer plunder, savvy!" }, "lowest": "lowest", - "name": "Name, argh!", "news": { "article": { "add": "Add, ye dog!", @@ -360,7 +361,6 @@ "title": "Latest News from the High Seas" }, "options": "Options, matey!", - "save": "Stow it!", "security": "Safety", "selectLanguage": "Pick yer tongue", "settings": "Settings", diff --git a/i18n/locales/en_us.json b/i18n/locales/en_us.json index 0df185cd..67408f92 100644 --- a/i18n/locales/en_us.json +++ b/i18n/locales/en_us.json @@ -23,10 +23,9 @@ "title": "Account Settings" }, "actions": "Actions", - "adminTitle": "Admin Dashboard | Drop", - "adminTitleTemplate": "{0} | Admin | Drop", - "title": "Drop", - "titleTemplate": "{0} | Drop", + "add": "Add", + "adminTitle": "Admin Dashboard - Drop", + "adminTitleTemplate": "{0} - Admin - Drop", "auth": { "callback": { "authClient": "Authorize client?", @@ -71,27 +70,34 @@ "quoted": "\"\"", "srComma": ", {0}" }, - "close": "Close", "common": { "cannotUndo": "This action cannot be undone.", + "close": "Close", + "create": "Create", "date": "Date", "deleteConfirm": "Are you sure you want to delete \"{0}\"?", + "divider": "{'|'}", + "edit": "Edit", "friends": "Friends", "groups": "Groups", + "insert": "Insert", + "name": "Name", "noResults": "No results", + "noSelected": "No items selected.", + "remove": "Remove", + "save": "Save", + "saved": "Saved", "servers": "Servers", + "srLoading": "Loading...", "tags": "Tags", "today": "Today", - "divider": "{'|'}", - "srLoading": "Loading..." + "add": "Add" }, - "create": "Create", "delete": "Delete", "drop": { "desc": "An open-source game distribution platform built for speed, flexibility and beauty.", "drop": "Drop" }, - "edit": "Edit", "editor": { "bold": "Bold", "boldPlaceholder": "bold text", @@ -107,17 +113,6 @@ "listItemPlaceholder": "list item" }, "errors": { - "auth": { - "method": { - "signinDisabled": "Sign in method not enabled" - }, - "invalidUserOrPass": "Invalid username or password.", - "disabled": "Invalid or disabled account. Please contact the server administrator.", - "invalidPassState": "Invalid password state. Please contact the server administrator.", - "inviteIdRequired": "id required in fetching invitation", - "invalidInvite": "Invalid or expired invitation", - "usernameTaken": "Username already taken." - }, "admin": { "user": { "delete": { @@ -126,7 +121,44 @@ } } }, + "auth": { + "disabled": "Invalid or disabled account. Please contact the server administrator.", + "invalidInvite": "Invalid or expired invitation", + "invalidPassState": "Invalid password state. Please contact the server administrator.", + "invalidUserOrPass": "Invalid username or password.", + "inviteIdRequired": "id required in fetching invitation", + "method": { + "signinDisabled": "Sign in method not enabled" + }, + "usernameTaken": "Username already taken." + }, "backHome": "{arrow} Back to home", + "game": { + "banner": { + "description": "Drop failed to update the banner image: {0}", + "title": "Failed to update the banner image" + }, + "carousel": { + "description": "Drop failed to update the image carousel: {0}", + "title": "Failed to update image carousel" + }, + "cover": { + "description": "Drop failed to update the cover image: {0}", + "title": "Failed to update the cover image" + }, + "deleteImage": { + "description": "Drop failed to delete the image: {0}", + "title": "Failed to delete the image" + }, + "description": { + "description": "Drop failed to update the game description: {0}", + "title": "Failed to update game description" + }, + "metadata": { + "description": "Drop failed to update the game's metadata: {0}", + "title": "Failed to update metadata" + } + }, "invalidBody": "Invalid request body: {0}", "inviteRequired": "Invitation required to sign up.", "library": { @@ -163,6 +195,10 @@ "signIn": "Sign in {arrow}", "support": "Support Discord", "unknown": "An unknown error occurred", + "upload": { + "description": "Drop couldn't upload the file: {0}", + "title": "Failed to upload file" + }, "version": { "delete": { "desc": "Drop encountered an error while deleting the version: {error}", @@ -172,47 +208,17 @@ "desc": "Drop encountered an error while updating the version: {error}", "title": "There an error while updating the version order" } - }, - "upload": { - "title": "Failed to upload file", - "description": "Drop couldn't upload the file: {0}" - }, - "game": { - "metadata": { - "title": "Failed to update metadata", - "description": "Drop failed to update the game's metadata: {0}" - }, - "description": { - "title": "Failed to update game description", - "description": "Drop failed to update the game description: {0}" - }, - "banner": { - "title": "Failed to update the banner image", - "description": "Drop failed to update the banner image: {0}" - }, - "cover": { - "title": "Failed to update the cover image", - "description": "Drop failed to update the cover image: {0}" - }, - "deleteImage": { - "title": "Failed to delete the image", - "description": "Drop failed to delete the image: {0}" - }, - "carousel": { - "title": "Failed to update image carousel", - "description": "Drop failed to update the image carousel: {0}" - } } }, "footer": { "about": "About", "aboutDrop": "About Drop", + "comparison": "Comparison", "docs": { "client": "Client Docs", "server": "Server Docs" }, "documentation": "Documentation", - "comparison": "Comparison", "findGame": "Find a Game", "footer": "Footer", "games": "Games", @@ -226,81 +232,43 @@ "header": { "admin": { "admin": "Admin", + "metadata": "Meta", + "settings": "Settings", "tasks": "Tasks", - "users": "Users", - "settings": "Settings" + "users": "Users" }, "back": "Back", "openSidebar": "Open sidebar" }, + "helpUsTranslate": "Help us translate Drop {arrow}", "highest": "highest", "home": "Home", - "users": { - "admin": { - "description": "Manage the users on your Drop instance, and configure your authentication methods.", - "authLink": "Authentication {arrow}", - "displayNameHeader": "Display Name", - "usernameHeader": "Username", - "emailHeader": "Email", - "adminHeader": "Admin?", - "authoptionsHeader": "Auth Options", - "srEditLabel": "Edit", - "adminUserLabel": "Admin user", - "normalUserLabel": "Normal user", - - "delete": "Delete", - "deleteUser": "Delete user {0}", - - "authentication": { - "title": "Authentication", - "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", - "enabledKey": "Enabled?", - "enabled": "Enabled", - "disabled": "Disabled", - "srOpenOptions": "Open options", - "configure": "Configure", - "simple": "Simple (username/password)", - "oidc": "OpenID Connect" - }, - "simple": { - "title": "Simple authentication", - "description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.", - "invitationTitle": "invitations", - "createInvitation": "Create invitation", - "noUsernameEnforced": "No username enforced.", - "noEmailEnforced": "No email enforced.", - "adminInvitation": "Admin invitation", - "userInvitation": "User invitation", - "expires": "Expires: {expiry}", - "neverExpires": "Never expires.", - "noInvitations": "No invitations.", - "inviteTitle": "Invite user to Drop", - "inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.", - "inviteUsernameLabel": "Username (optional)", - "inviteUsernameFormat": "Must be 5 or more characters", - "inviteUsernamePlaceholder": "myUsername", - "inviteEmailLabel": "Email address (optional)", - "inviteEmailDescription": "Must be in the format user{'@'}example.com", - "inviteEmailPlaceholder": "me{'@'}example.com", - "inviteAdminSwitchLabel": "Admin invitation", - "inviteAdminSwitchDescription": "Create this user as an administrator", - "inviteExpiryLabel": "Expires", - "inviteButton": "Invite", - "invite3Days": "3 days", - "inviteWeek": "1 week", - "inviteMonth": "1 month", - "invite6Months": "6 months", - "inviteYear": "1 year", - "inviteNever": "Never" - } - } - }, "library": { "addGames": "All Games", "addToLib": "Add to Library", "admin": { "detectedGame": "Drop has detected you have new games to import.", "detectedVersion": "Drop has detected you have new verions of this game to import.", + "offlineTitle": "Game offline", + "offline": "Drop couldn't access this game.", + "game": { + "addCarouselNoImages": "No images to add.", + "addDescriptionNoImages": "No images to add.", + "addImageCarousel": "Add from image library", + "currentBanner": "banner", + "currentCover": "cover", + "deleteImage": "Delete image", + "editGameDescription": "Game Description", + "editGameName": "Game Name", + "imageCarousel": "Image Carousel", + "imageCarouselDescription": "Customise what images and what order are shown on the store page.", + "imageCarouselEmpty": "No images added to the carousel yet.", + "imageLibrary": "Image library", + "imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.", + "removeImageCarousel": "Remove image", + "setBanner": "Set as banner", + "setCover": "Set as cover" + }, "gameLibrary": "Game Library", "import": { "import": "Import", @@ -313,6 +281,8 @@ "selectGamePlaceholder": "Please select a game...", "selectGameSearch": "Select game", "selectPlatform": "Please select a platform...", + "bulkImportTitle": "Bulk import mode", + "bulkImportDescription": "When on, this page won't redirect you to the import task, so you can import multiple games in succession.", "version": { "advancedOptions": "Advanced options", "import": "Import version", @@ -343,38 +313,16 @@ "openEditor": "Open in Editor {arrow}", "openStore": "Open in Store", "shortDesc": "Short Description", - "version": { - "noVersions": "You have no versions of this game available.", - "noVersionsAdded": "no versions added", - "delta": "Upgrade mode" - }, - "game": { - "imageCarousel": "Image Carousel", - "imageCarouselDescription": "Customise what images and what order are shown on the store page.", - "addImageCarousel": "Add from image library", - "imageCarouselEmpty": "No images added to the carousel yet.", - "removeImageCarousel": "Remove image", - "addCarouselNoImages": "No images to add.", - "imageLibrary": "Image library", - "imageLibraryDescription": "Please note all images uploaded are accessible to all users through browser dev-tools.", - "setBanner": "Set as banner", - "setCover": "Set as cover", - "deleteImage": "Delete image", - "currentBanner": "banner", - "currentCover": "cover", - "addDescriptionNoImages": "No images to add.", - "editGameName": "Game Name", - "editGameDescription": "Game Description" - }, "sources": { "create": "Create source", + "edit": "Edit source", "createDesc": "Drop will use this source to access your game library, and make them available.", "desc": "Configure your library sources, where Drop will look for new games and versions to import.", "fsDesc": "Imports games from a path on disk. Requires version-based folder structure, and supports archived games.", + "fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.", "fsPath": "Path", "fsPathDesc": "An absolute path to your game library.", "fsPathPlaceholder": "/mnt/games", - "fsFlatDesc": "Imports games from a path on disk, but without a separate version subfolder. Useful when migrating an existing library to Drop.", "link": "Sources {arrow}", "nameDesc": "The name of your source, for reference.", "namePlaceholder": "My New Source", @@ -384,7 +332,58 @@ }, "subheader": "As you add folders to your library sources, Drop will detect it and prompt you to import it. Each game needs to be imported before you can import a version.", "title": "Libraries", - "versionPriority": "Version priority" + "version": { + "delta": "Upgrade mode", + "noVersions": "You have no versions of this game available.", + "noVersionsAdded": "no versions added" + }, + "versionPriority": "Version priority", + "metadata": { + "tags": { + "title": "Tags", + "description": "Tags are automatically created from imported genres. You can add custom tags to add categorisation to your game library.", + "action": "Manage {arrow}", + "create": "Create", + "modal": { + "title": "Create Tag", + "description": "Create a tag to organize your library." + } + }, + "companies": { + "title": "Companies", + "description": "Companies organize games by who they were developed or published by.", + "action": "Manage {arrow}", + "search": "Search companies...", + "searchGames": "Search company games...", + "noCompanies": "No companies", + "noGames": "No games", + "editor": { + "libraryTitle": "Game Library", + "libraryDescription": "Add, remove, or customise what this company has developed and/or published.", + "action": "Add Game {plus}", + "published": "Published", + "developed": "Developed", + "uploadIcon": "Upload icon", + "uploadBanner": "Upload banner", + "noDescription": "(no description)" + }, + "addGame": { + "title": "Connect game to this company", + "description": "Pick a game to add to the company, and whether it should be listed as a developer, publisher, or both.", + "publisher": "Publisher?", + "developer": "Developer?", + "noGames": "No games to add" + }, + "modals": { + "nameTitle": "Edit company name", + "nameDescription": "Edit the company's name. Used to match to new game imports.", + "shortDeckTitle": "Edit company description", + "shortDeckDescription": "Edit the company's description. Doesn't affect long (markdown) description.", + "websiteTitle": "Edit company website", + "websiteDescription": "Edit the company's website. Note: this will be a link, and won't have redirect protection." + } + } + } }, "back": "Back to Library", "collection": { @@ -407,29 +406,7 @@ "search": "Search library...", "subheader": "Organize your games into collections for easy access, and access all your games." }, - "tasks": { - "admin": { - "scheduled": { - "cleanupInvitationsName": "Clean up invitations", - "cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.", - "cleanupObjectsName": "Clean up objects", - "cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.", - "cleanupSessionsName": "Clean up sessions.", - "cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.", - "checkUpdateName": "Check update.", - "checkUpdateDescription": "Check if Drop has an update." - }, - "runningTasksTitle": "Running tasks", - "noTasksRunning": "No tasks currently running", - "completedTasksTitle": "Completed tasks", - "dailyScheduledTitle": "Daily scheduled tasks", - "weeklyScheduledTitle": "Weekly scheduled tasks", - "viewTask": "View {arrow}", - "back": "{arrow} Back to Tasks" - } - }, "lowest": "lowest", - "name": "Name", "news": { "article": { "add": "Add", @@ -462,34 +439,37 @@ "title": "Latest News" }, "options": "Options", - "save": "Save", - "saved": "Saved", - "add": "Add", - "insert": "Insert", "security": "Security", + "selectLanguage": "Select language", "settings": { "admin": { - "title": "Settings", "description": "Configure Drop settings", - "store": { - "title": "Store", - "showGamePanelTextDecoration": "Show title and description on game tiles (default: on)", - "dropGameNamePlaceholder": "Example Game", + "dropGameAltPlaceholder": "Example Game icon", "dropGameDescriptionPlaceholder": "This is an example game. It will be replaced if you import a game.", - "dropGameAltPlaceholder": "Example Game icon" - } + "dropGameNamePlaceholder": "Example Game", + "showGamePanelTextDecoration": "Show title and description on game tiles (default: on)", + "title": "Store" + }, + "title": "Settings" } }, "store": { + "about": "About", "commingSoon": "coming soon", + "developers": "Developers | Developer | Developers", "exploreMore": "Explore more {arrow}", + "featured": "Featured", "images": "Game Images", "lookAt": "Check it out", + "noDevelopers": "No developers", "noGame": "no game", "noImages": "No images", + "noPublishers": "No publishers.", + "noTags": "No tags", "openAdminDashboard": "Open in Admin Dashboard", "platform": "Platform | Platform | Platforms", + "publishers": "Publishers | Publisher | Publishers", "rating": "Rating", "readLess": "Click to read less", "readMore": "Click to read more", @@ -498,9 +478,41 @@ "recentlyUpdated": "Recently Updated", "released": "Released", "reviews": "({0} Reviews)", + "tags": "Tags", "title": "Store", - "view": "View in Store" + "view": { + "sort": "Sort", + "srFilters": "Filters", + "srGames": "Games", + "srViewGrid": "View grid" + }, + "viewInStore": "View in Store", + "website": "Website" + }, + "tasks": { + "admin": { + "back": "{arrow} Back to Tasks", + "completedTasksTitle": "Completed tasks", + "dailyScheduledTitle": "Daily scheduled tasks", + "noTasksRunning": "No tasks currently running", + "runningTasksTitle": "Running tasks", + "scheduled": { + "checkUpdateDescription": "Check if Drop has an update.", + "checkUpdateName": "Check update.", + "cleanupInvitationsDescription": "Cleans up expired invitations from the database to save space.", + "cleanupInvitationsName": "Clean up invitations", + "cleanupObjectsDescription": "Detects and deletes unreferenced and unused objects to save space.", + "cleanupObjectsName": "Clean up objects", + "cleanupSessionsDescription": "Cleans up expired sessions to save space and ensure security.", + "cleanupSessionsName": "Clean up sessions." + }, + "viewTask": "View {arrow}", + "weeklyScheduledTitle": "Weekly scheduled tasks" + } }, + "title": "Drop", + "titleTemplate": "{0} - Drop", + "todo": "Todo", "type": "Type", "upload": "Upload", "uploadFile": "Upload file", @@ -516,8 +528,63 @@ "settings": "Account settings" } }, - "todo": "Todo", - "selectLanguage": "Select language", - "helpUsTranslate": "Help us translate Drop {arrow}", + "users": { + "admin": { + "adminHeader": "Admin?", + "adminUserLabel": "Admin user", + "authentication": { + "configure": "Configure", + "description": "Drop supports a variety of \"authentication mechanisms\". As you enable or disable them, they are shown on the sign in screen for users to select from. Click the dot menu to configure the authentication mechanism.", + "disabled": "Disabled", + "enabled": "Enabled", + "enabledKey": "Enabled?", + "oidc": "OpenID Connect", + "simple": "Simple (username/password)", + "srOpenOptions": "Open options", + "title": "Authentication" + }, + "authLink": "Authentication {arrow}", + "authoptionsHeader": "Auth Options", + "delete": "Delete", + "deleteUser": "Delete user {0}", + "description": "Manage the users on your Drop instance, and configure your authentication methods.", + "displayNameHeader": "Display Name", + "emailHeader": "Email", + "normalUserLabel": "Normal user", + "simple": { + "adminInvitation": "Admin invitation", + "createInvitation": "Create invitation", + "description": "Simple authentication uses a system of 'invitations' to create users. You can create an invitation, and optionally specify a username or email for the user, and then it will generate a magic URL that can be used to create an account.", + "expires": "Expires: {expiry}", + "invitationTitle": "invitations", + "invite3Days": "3 days", + "invite6Months": "6 months", + "inviteAdminSwitchDescription": "Create this user as an administrator", + "inviteAdminSwitchLabel": "Admin invitation", + "inviteButton": "Invite", + "inviteDescription": "Drop will generate a URL that you can send to the person you want to invite. You can optionally specify a username or email for them to use.", + "inviteEmailDescription": "Must be in the format user{'@'}example.com", + "inviteEmailLabel": "Email address (optional)", + "inviteEmailPlaceholder": "me{'@'}example.com", + "inviteExpiryLabel": "Expires", + "inviteMonth": "1 month", + "inviteNever": "Never", + "inviteTitle": "Invite user to Drop", + "inviteUsernameFormat": "Must be 5 or more characters", + "inviteUsernameLabel": "Username (optional)", + "inviteUsernamePlaceholder": "myUsername", + "inviteWeek": "1 week", + "inviteYear": "1 year", + "neverExpires": "Never expires.", + "noEmailEnforced": "No email enforced.", + "noInvitations": "No invitations.", + "noUsernameEnforced": "No username enforced.", + "title": "Simple authentication", + "userInvitation": "User invitation" + }, + "srEditLabel": "Edit", + "usernameHeader": "Username" + } + }, "welcome": "American, Welcome!" } diff --git a/layouts/admin.vue b/layouts/admin.vue index aaf5dba7..24cef9b6 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -164,6 +164,7 @@ import { Cog6ToothIcon, UserGroupIcon, RectangleStackIcon, + DocumentIcon, } from "@heroicons/vue/24/outline"; import type { NavigationItem } from "~/composables/types"; import { useCurrentNavigationIndex } from "~/composables/current-page-engine"; @@ -180,6 +181,12 @@ const navigation: Array = [ prefix: "/admin/library", icon: ServerStackIcon, }, + { + label: $t("header.admin.metadata"), + route: "/admin/metadata", + prefix: "/admin/metadata", + icon: DocumentIcon, + }, { label: $t("header.admin.users"), route: "/admin/users", diff --git a/nuxt.config.ts b/nuxt.config.ts index e3384e15..2c732396 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -36,11 +36,11 @@ export default defineNuxtConfig({ modules: [ "vue3-carousel-nuxt", - "nuxt-security", - // "@nuxt/image", + "nuxt-security", // "@nuxt/image", "@nuxt/fonts", "@nuxt/eslint", "@nuxtjs/i18n", + "@vueuse/nuxt", ], // Nuxt-only config diff --git a/package.json b/package.json index 479038fb..84a18367 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@nuxtjs/i18n": "^9.5.5", "@prisma/client": "^6.11.1", "@tailwindcss/vite": "^4.0.6", + "@vueuse/nuxt": "13.6.0", "argon2": "^0.43.0", "arktype": "^2.1.10", "axios": "^1.7.7", diff --git a/pages/account/devices.vue b/pages/account/devices.vue index 62d9f278..9d592d14 100644 --- a/pages/account/devices.vue +++ b/pages/account/devices.vue @@ -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") }} (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}`, + }); +}); diff --git a/pages/admin/library/import.vue b/pages/admin/library/import.vue index 5f32f544..d6eede49 100644 --- a/pages/admin/library/import.vue +++ b/pages/admin/library/import.vue @@ -12,9 +12,15 @@ - {{ - games.unimportedGames[currentlySelectedGame].game - }} + {{ games.unimportedGames[currentlySelectedGame].game }} + {{ + games.unimportedGames[currentlySelectedGame].library.name + }} {{ $t("library.admin.import.selectDir") }} @@ -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" > @@ -51,14 +57,20 @@ > {{ game }}{{ game }} + {{ library.name }} + + + {{ + $t("library.admin.import.bulkImportDescription") + }} + +
    + + +
    +
    @@ -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(); 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; diff --git a/pages/admin/library/index.vue b/pages/admin/library/index.vue index 3adf807e..f1135099 100644 --- a/pages/admin/library/index.vue +++ b/pages/admin/library/index.vue @@ -78,20 +78,55 @@
  • -

    +

    {{ game.mName }} + {{ game.metadataSource }}{{ game.library!.name }}

    @@ -180,6 +215,24 @@
    +
    +
    +
    +
    +
    +

    + {{ $t("library.admin.offline") }} +

    +
    +
    +
  • diff --git a/pages/admin/library/sources/index.vue b/pages/admin/library/sources/index.vue index 046cf880..251da854 100644 --- a/pages/admin/library/sources/index.vue +++ b/pages/admin/library/sources/index.vue @@ -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") }} @@ -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") }} - {{ $t("edit") }} + {{ $t("common.edit") }} @@ -84,7 +84,7 @@ class="text-blue-500 hover:text-blue-400" @click="() => edit(sourceIdx)" > - {{ $t("edit") }} + {{ $t("common.edit") }} {{ $t("chars.srComma", [source.name]) }} @@ -110,9 +110,20 @@ + + diff --git a/pages/admin/metadata/companies/index.vue b/pages/admin/metadata/companies/index.vue new file mode 100644 index 00000000..12698eb5 --- /dev/null +++ b/pages/admin/metadata/companies/index.vue @@ -0,0 +1,150 @@ + + + diff --git a/pages/admin/metadata/index.vue b/pages/admin/metadata/index.vue new file mode 100644 index 00000000..ab43365f --- /dev/null +++ b/pages/admin/metadata/index.vue @@ -0,0 +1,62 @@ + + + diff --git a/pages/admin/metadata/tags.vue b/pages/admin/metadata/tags.vue new file mode 100644 index 00000000..a41cba2e --- /dev/null +++ b/pages/admin/metadata/tags.vue @@ -0,0 +1,75 @@ + + + diff --git a/pages/admin/settings/index.vue b/pages/admin/settings/index.vue index caf4609b..3a80db97 100644 --- a/pages/admin/settings/index.vue +++ b/pages/admin/settings/index.vue @@ -59,7 +59,7 @@ :loading="saving" :disabled="!allowSave" > - {{ allowSave ? $t("save") : $t("saved") }} + {{ allowSave ? $t("common.save") : $t("common.saved") }} @@ -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); diff --git a/pages/admin/task/index.vue b/pages/admin/task/index.vue index 710fd395..29b9740c 100644 --- a/pages/admin/task/index.vue +++ b/pages/admin/task/index.vue @@ -47,7 +47,7 @@ />

    - {{ task.value.log.at(-1) }} + {{ parseTaskLog(task.value.log.at(-1) ?? "").message }}

    - + diff --git a/pages/auth/register.vue b/pages/auth/register.vue index 9704f2ee..d2820ae5 100644 --- a/pages/auth/register.vue +++ b/pages/auth/register.vue @@ -157,7 +157,7 @@
    - {{ $t("create") }} + {{ $t("common.create") }}
    diff --git a/pages/library.vue b/pages/library.vue index 336f3717..bdaa344e 100644 --- a/pages/library.vue +++ b/pages/library.vue @@ -51,7 +51,7 @@
    - +
    @@ -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" > - +
    - {{ $t("store.view") }} + {{ $t("store.viewInStore") }}
    diff --git a/pages/library/index.vue b/pages/library/index.vue index 3f170994..8d87bd7a 100644 --- a/pages/library/index.vue +++ b/pages/library/index.vue @@ -88,8 +88,8 @@ - - + + diff --git a/pages/news.vue b/pages/news.vue index 110ea65b..a984018e 100644 --- a/pages/news.vue +++ b/pages/news.vue @@ -51,7 +51,7 @@
    - +
    @@ -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" > - +
    - + diff --git a/pages/store/[id]/index.vue b/pages/store/[id]/index.vue index 2a2ef257..4bcd942d 100644 --- a/pages/store/[id]/index.vue +++ b/pages/store/[id]/index.vue @@ -108,6 +108,72 @@ }} + + + {{ $t("store.tags") }} + + + + {{ tag.name }} + + {{ $t("store.noTags") }} + + + + + {{ $t("store.developers", game.developers.length) }} + + + + {{ developer.mName }} + + {{ $t("store.noDevelopers") }} + + + + + {{ $t("store.publishers", game.publishers.length) }} + + + + {{ publisher.mName }} + + {{ $t("store.noPublishers") }} + + @@ -225,6 +291,7 @@ const ratingArray = Array(5) useHead({ title: game.mName, + link: [{ rel: "icon", href: useObject(game.mIconObjectId) }], }); diff --git a/pages/store/c/[id]/index.vue b/pages/store/c/[id]/index.vue new file mode 100644 index 00000000..a4c62c36 --- /dev/null +++ b/pages/store/c/[id]/index.vue @@ -0,0 +1,65 @@ + + + + diff --git a/pages/store/index.vue b/pages/store/index.vue index b9c1f80c..4254e1a8 100644 --- a/pages/store/index.vue +++ b/pages/store/index.vue @@ -24,14 +24,16 @@ >

    - {{ $t("store.recentlyAdded") }} + {{ $t("store.featured") }}

    {{ game.mName }}

    -

    +

    {{ game.mShortDescription }}

    @@ -66,49 +68,12 @@
    - -
    -

    - {{ $t("store.recentlyReleased") }} -

    - - - - - -
    - -
    -
    - - -
    -

    - {{ $t("store.recentlyUpdated") }} -

    - - - - - -
    - -
    -
    +
    diff --git a/prisma/migrations/20250720070939_static_genres/migration.sql b/prisma/migrations/20250720070939_static_genres/migration.sql new file mode 100644 index 00000000..df314598 --- /dev/null +++ b/prisma/migrations/20250720070939_static_genres/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Genre" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RealTimeStrategy', 'CardGame', 'BoardGame', 'Compilation', 'MMORPG', 'MinigameCollection', 'Puzzle', 'MusicRhythm'); + +-- AlterTable +ALTER TABLE "Game" ADD COLUMN "genres" "Genre"[]; diff --git a/prisma/migrations/20250721053244_update_genre_names/migration.sql b/prisma/migrations/20250721053244_update_genre_names/migration.sql new file mode 100644 index 00000000..63accfe1 --- /dev/null +++ b/prisma/migrations/20250721053244_update_genre_names/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [RealTimeStrategy,CardGame,BoardGame,MinigameCollection,MusicRhythm] on the enum `Genre` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "Genre_new" AS ENUM ('Action', 'Strategy', 'Sports', 'Adventure', 'Roleplay', 'Racing', 'Simulation', 'Educational', 'Fighting', 'Shooter', 'RTS', 'Card', 'Board', 'Compilation', 'MMORPG', 'Minigames', 'Puzzle', 'Rhythm'); +ALTER TABLE "Game" ALTER COLUMN "genres" TYPE "Genre_new"[] USING ("genres"::text::"Genre_new"[]); +ALTER TYPE "Genre" RENAME TO "Genre_old"; +ALTER TYPE "Genre_new" RENAME TO "Genre"; +DROP TYPE "Genre_old"; +COMMIT; diff --git a/prisma/migrations/20250721053514_add_featured/migration.sql b/prisma/migrations/20250721053514_add_featured/migration.sql new file mode 100644 index 00000000..89aeeb49 --- /dev/null +++ b/prisma/migrations/20250721053514_add_featured/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Game" ADD COLUMN "featured" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20250721061200_remove_genres/migration.sql b/prisma/migrations/20250721061200_remove_genres/migration.sql new file mode 100644 index 00000000..4f4f1dfe --- /dev/null +++ b/prisma/migrations/20250721061200_remove_genres/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - You are about to drop the column `genres` on the `Game` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "Game" DROP COLUMN "genres"; + +-- DropEnum +DROP TYPE "Genre"; diff --git a/prisma/migrations/20250721062509_add_pg_trgm/migration.sql b/prisma/migrations/20250721062509_add_pg_trgm/migration.sql new file mode 100644 index 00000000..567fbd0b --- /dev/null +++ b/prisma/migrations/20250721062509_add_pg_trgm/migration.sql @@ -0,0 +1,5 @@ +-- Add pg_trgm +CREATE EXTENSION pg_trgm; + +-- Create index for tag names +-- CREATE INDEX trgm_tag_name ON "Tag" USING GIST (name gist_trgm_ops(siglen=32)); \ No newline at end of file diff --git a/prisma/migrations/20250721063518_add_index_for_tag_name/migration.sql b/prisma/migrations/20250721063518_add_index_for_tag_name/migration.sql new file mode 100644 index 00000000..02b93b5a --- /dev/null +++ b/prisma/migrations/20250721063518_add_index_for_tag_name/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "Tag_name_idx" ON "Tag" USING GIST ("name" gist_trgm_ops(siglen=32)); diff --git a/prisma/migrations/20250721070713_split_game_and_news_tags/migration.sql b/prisma/migrations/20250721070713_split_game_and_news_tags/migration.sql new file mode 100644 index 00000000..cbd80c51 --- /dev/null +++ b/prisma/migrations/20250721070713_split_game_and_news_tags/migration.sql @@ -0,0 +1,87 @@ +/* + Warnings: + + - You are about to drop the `Tag` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_ArticleToTag` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the `_GameToTag` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_ArticleToTag" DROP CONSTRAINT "_ArticleToTag_B_fkey"; + +-- DropForeignKey +ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_A_fkey"; + +-- DropForeignKey +ALTER TABLE "_GameToTag" DROP CONSTRAINT "_GameToTag_B_fkey"; + +-- DropTable +DROP TABLE "Tag"; + +-- DropTable +DROP TABLE "_ArticleToTag"; + +-- DropTable +DROP TABLE "_GameToTag"; + +-- CreateTable +CREATE TABLE "GameTag" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "GameTag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "NewsTag" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + + CONSTRAINT "NewsTag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_GameToGameTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_GameToGameTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateTable +CREATE TABLE "_ArticleToNewsTag" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL, + + CONSTRAINT "_ArticleToNewsTag_AB_pkey" PRIMARY KEY ("A","B") +); + +-- CreateIndex +CREATE UNIQUE INDEX "GameTag_name_key" ON "GameTag"("name"); + +-- CreateIndex +CREATE INDEX "GameTag_name_idx" ON "GameTag" USING GIST ("name" gist_trgm_ops(siglen=32)); + +-- CreateIndex +CREATE UNIQUE INDEX "NewsTag_name_key" ON "NewsTag"("name"); + +-- CreateIndex +CREATE INDEX "_GameToGameTag_B_index" ON "_GameToGameTag"("B"); + +-- CreateIndex +CREATE INDEX "_ArticleToNewsTag_B_index" ON "_ArticleToNewsTag"("B"); + +-- AddForeignKey +ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Game"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_GameToGameTag" ADD CONSTRAINT "_GameToGameTag_B_fkey" FOREIGN KEY ("B") REFERENCES "GameTag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Article"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_ArticleToNewsTag" ADD CONSTRAINT "_ArticleToNewsTag_B_fkey" FOREIGN KEY ("B") REFERENCES "NewsTag"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/content.prisma b/prisma/models/content.prisma index f53d6129..587f2a60 100644 --- a/prisma/models/content.prisma +++ b/prisma/models/content.prisma @@ -23,6 +23,8 @@ model Game { ratings GameRating[] + featured Boolean @default(false) + mIconObjectId String // linked to objects in s3 mBannerObjectId String // linked to objects in s3 mCoverObjectId String @@ -40,7 +42,7 @@ model Game { collections CollectionEntry[] saves SaveSlot[] screenshots Screenshot[] - tags Tag[] + tags GameTag[] playtime Playtime[] developers Company[] @relation(name: "developers") @@ -50,6 +52,16 @@ model Game { @@unique([libraryId, libraryPath], name: "libraryKey") } +model GameTag { + id String @id @default(uuid()) + name String @unique + + games Game[] + + @@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist) +} + + model GameRating { id String @id @default(uuid()) diff --git a/prisma/models/news.prisma b/prisma/models/news.prisma index 79363f03..496994f3 100644 --- a/prisma/models/news.prisma +++ b/prisma/models/news.prisma @@ -1,9 +1,8 @@ -model Tag { +model NewsTag { id String @id @default(uuid()) name String @unique articles Article[] - games Game[] } model Article { @@ -12,7 +11,7 @@ model Article { description String content String @db.Text - tags Tag[] + tags NewsTag[] imageObjectId String? // Object ID publishedAt DateTime @default(now()) diff --git a/server/api/v1/admin/company/[id]/banner.post.ts b/server/api/v1/admin/company/[id]/banner.post.ts new file mode 100644 index 00000000..0c8744a5 --- /dev/null +++ b/server/api/v1/admin/company/[id]/banner.post.ts @@ -0,0 +1,51 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import objectHandler from "~/server/internal/objects"; +import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + const company = await prisma.company.findUnique({ + where: { + id: companyId, + }, + }); + + if (!company) + throw createError({ statusCode: 400, statusMessage: "Invalid company id" }); + + const result = await handleFileUpload(h3, {}, ["internal:read"], 1); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "File upload required (multipart form)", + }); + + const [ids, , pull, dump] = result; + const id = ids.at(0); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "Upload at least one file.", + }); + + try { + await objectHandler.deleteAsSystem(company.mBannerObjectId); + await prisma.company.update({ + where: { + id: companyId, + }, + data: { + mBannerObjectId: id, + }, + }); + await pull(); + } catch { + await dump(); + } + + return { id: id }; +}); diff --git a/server/api/v1/admin/company/[id]/game.delete.ts b/server/api/v1/admin/company/[id]/game.delete.ts new file mode 100644 index 00000000..0a9fe211 --- /dev/null +++ b/server/api/v1/admin/company/[id]/game.delete.ts @@ -0,0 +1,37 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const GameDelete = type({ + id: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + + const body = await readDropValidatedBody(h3, GameDelete); + + await prisma.game.update({ + where: { + id: body.id, + }, + data: { + publishers: { + disconnect: { + id: companyId, + }, + }, + developers: { + disconnect: { + id: companyId, + }, + }, + }, + }); + + return; +}); diff --git a/server/api/v1/admin/company/[id]/game.patch.ts b/server/api/v1/admin/company/[id]/game.patch.ts new file mode 100644 index 00000000..9e0260f4 --- /dev/null +++ b/server/api/v1/admin/company/[id]/game.patch.ts @@ -0,0 +1,37 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const GamePatch = type({ + action: "'developed' | 'published'", + enabled: "boolean", + id: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + + const body = await readDropValidatedBody(h3, GamePatch); + + const action = body.action === "developed" ? "developers" : "publishers"; + const actionType = body.enabled ? "connect" : "disconnect"; + + await prisma.game.update({ + where: { + id: body.id, + }, + data: { + [action]: { + [actionType]: { + id: companyId, + }, + }, + }, + }); + + return; +}); diff --git a/server/api/v1/admin/company/[id]/game.post.ts b/server/api/v1/admin/company/[id]/game.post.ts new file mode 100644 index 00000000..f8731186 --- /dev/null +++ b/server/api/v1/admin/company/[id]/game.post.ts @@ -0,0 +1,69 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const GamePost = type({ + published: "boolean", + developed: "boolean", + id: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + + const body = await readDropValidatedBody(h3, GamePost); + + if (!body.published && !body.developed) + throw createError({ + statusCode: 400, + statusMessage: "Must be related (either developed or published).", + }); + + const publisherConnect = body.published + ? { + publishers: { + connect: { + id: companyId, + }, + }, + } + : undefined; + + const developerConnect = body.developed + ? { + developers: { + connect: { + id: companyId, + }, + }, + } + : undefined; + + const game = await prisma.game.update({ + where: { + id: body.id, + }, + data: { + ...publisherConnect, + ...developerConnect, + }, + include: { + publishers: { + select: { + id: true, + }, + }, + developers: { + select: { + id: true, + }, + }, + }, + }); + + return game; +}); diff --git a/server/api/v1/admin/company/[id]/icon.post.ts b/server/api/v1/admin/company/[id]/icon.post.ts new file mode 100644 index 00000000..0a4cefdb --- /dev/null +++ b/server/api/v1/admin/company/[id]/icon.post.ts @@ -0,0 +1,51 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import objectHandler from "~/server/internal/objects"; +import { handleFileUpload } from "~/server/internal/utils/handlefileupload"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id")!; + const company = await prisma.company.findUnique({ + where: { + id: companyId, + }, + }); + + if (!company) + throw createError({ statusCode: 400, statusMessage: "Invalid company id" }); + + const result = await handleFileUpload(h3, {}, ["internal:read"], 1); + if (!result) + throw createError({ + statusCode: 400, + statusMessage: "File upload required (multipart form)", + }); + + const [ids, , pull, dump] = result; + const id = ids.at(0); + if (!id) + throw createError({ + statusCode: 400, + statusMessage: "Upload at least one file.", + }); + + try { + await objectHandler.deleteAsSystem(company.mLogoObjectId); + await prisma.company.update({ + where: { + id: companyId, + }, + data: { + mLogoObjectId: id, + }, + }); + await pull(); + } catch { + await dump(); + } + + return { id: id }; +}); diff --git a/server/api/v1/admin/company/[id]/index.delete.ts b/server/api/v1/admin/company/[id]/index.delete.ts new file mode 100644 index 00000000..b27d3939 --- /dev/null +++ b/server/api/v1/admin/company/[id]/index.delete.ts @@ -0,0 +1,14 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const company = await prisma.company.deleteMany({ where: { id } }); + if (company.count == 0) + throw createError({ statusCode: 404, statusMessage: "Company not found" }); + return; +}); diff --git a/server/api/v1/admin/company/[id]/index.get.ts b/server/api/v1/admin/company/[id]/index.get.ts new file mode 100644 index 00000000..50c4115d --- /dev/null +++ b/server/api/v1/admin/company/[id]/index.get.ts @@ -0,0 +1,54 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const company = await prisma.company.findUnique({ + where: { id }, + include: { + published: { + select: { + id: true, + }, + }, + developed: { + select: { + id: true, + }, + }, + }, + }); + if (!company) + throw createError({ statusCode: 404, statusMessage: "Company not found" }); + const games = await prisma.game.findMany({ + where: { + OR: [ + { + developers: { + some: { + id: company.id, + }, + }, + }, + { + publishers: { + some: { + id: company.id, + }, + }, + }, + ], + }, + distinct: ["id"], + }); + const companyFlatten = { + ...company, + developed: company.developed.map((e) => e.id), + published: company.published.map((e) => e.id), + }; + return { company: companyFlatten, games }; +}); diff --git a/server/api/v1/admin/company/[id]/index.patch.ts b/server/api/v1/admin/company/[id]/index.patch.ts new file mode 100644 index 00000000..74511e29 --- /dev/null +++ b/server/api/v1/admin/company/[id]/index.patch.ts @@ -0,0 +1,23 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readBody(h3); + const id = getRouterParam(h3, "id")!; + + const restOfTheBody = { ...body }; + delete restOfTheBody["id"]; + + const newObj = await prisma.company.update({ + where: { + id: id, + }, + data: restOfTheBody, + // I would put a select here, but it would be based on the body, and muck up the types + }); + + return newObj; +}); diff --git a/server/api/v1/admin/company/index.get.ts b/server/api/v1/admin/company/index.get.ts new file mode 100644 index 00000000..dac5ae25 --- /dev/null +++ b/server/api/v1/admin/company/index.get.ts @@ -0,0 +1,10 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["company:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const companies = await prisma.company.findMany({}); + return companies; +}); diff --git a/server/api/v1/admin/game/index.delete.ts b/server/api/v1/admin/game/[id]/index.delete.ts similarity index 55% rename from server/api/v1/admin/game/index.delete.ts rename to server/api/v1/admin/game/[id]/index.delete.ts index 730763ca..2a0645d8 100644 --- a/server/api/v1/admin/game/index.delete.ts +++ b/server/api/v1/admin/game/[id]/index.delete.ts @@ -1,17 +1,11 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -export default defineEventHandler<{ query: { id: string } }>(async (h3) => { +export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:delete"]); if (!allowed) throw createError({ statusCode: 403 }); - const query = getQuery(h3); - const gameId = query.id?.toString(); - if (!gameId) - throw createError({ - statusCode: 400, - statusMessage: "Missing id in query", - }); + const gameId = getRouterParam(h3, "id")!; await prisma.game.delete({ where: { diff --git a/server/api/v1/admin/game/[id]/index.get.ts b/server/api/v1/admin/game/[id]/index.get.ts new file mode 100644 index 00000000..44693dc5 --- /dev/null +++ b/server/api/v1/admin/game/[id]/index.get.ts @@ -0,0 +1,40 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import libraryManager from "~/server/internal/library"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const gameId = getRouterParam(h3, "id")!; + + const game = await prisma.game.findUnique({ + where: { + id: gameId, + }, + include: { + versions: { + orderBy: { + versionIndex: "asc", + }, + select: { + versionIndex: true, + versionName: true, + platform: true, + delta: true, + }, + }, + tags: true, + }, + }); + + if (!game || !game.libraryId) + throw createError({ statusCode: 404, statusMessage: "Game ID not found" }); + + const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( + game.libraryId, + game.libraryPath, + ); + + return { game, unimportedVersions }; +}); diff --git a/server/api/v1/admin/game/index.patch.ts b/server/api/v1/admin/game/[id]/index.patch.ts similarity index 84% rename from server/api/v1/admin/game/index.patch.ts rename to server/api/v1/admin/game/[id]/index.patch.ts index 238dc8e6..410adeeb 100644 --- a/server/api/v1/admin/game/index.patch.ts +++ b/server/api/v1/admin/game/[id]/index.patch.ts @@ -6,9 +6,7 @@ export default defineEventHandler(async (h3) => { if (!allowed) throw createError({ statusCode: 403 }); const body = await readBody(h3); - const id = body.id; - if (!id) - throw createError({ statusCode: 400, statusMessage: "Missing id in body" }); + const id = getRouterParam(h3, "id")!; const restOfTheBody = { ...body }; delete restOfTheBody["id"]; diff --git a/server/api/v1/admin/game/metadata.post.ts b/server/api/v1/admin/game/[id]/metadata.post.ts similarity index 97% rename from server/api/v1/admin/game/metadata.post.ts rename to server/api/v1/admin/game/[id]/metadata.post.ts index b6f4e21d..cd1dcbc6 100644 --- a/server/api/v1/admin/game/metadata.post.ts +++ b/server/api/v1/admin/game/[id]/metadata.post.ts @@ -14,6 +14,8 @@ export default defineEventHandler(async (h3) => { statusMessage: "This endpoint requires multipart form data.", }); + const gameId = getRouterParam(h3, "id")!; + const uploadResult = await handleFileUpload(h3, {}, ["internal:read"], 1); if (!uploadResult) throw createError({ @@ -28,7 +30,6 @@ export default defineEventHandler(async (h3) => { // handleFileUpload reads the rest of the options for us. const name = options.name; const description = options.description; - const gameId = options.id; const updateModel: Prisma.GameUpdateInput = { mName: name, diff --git a/server/api/v1/admin/game/[id]/tags.patch.ts b/server/api/v1/admin/game/[id]/tags.patch.ts new file mode 100644 index 00000000..d7192ad5 --- /dev/null +++ b/server/api/v1/admin/game/[id]/tags.patch.ts @@ -0,0 +1,29 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const PatchTags = type({ + tags: "string[]", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["game:update"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, PatchTags); + const id = getRouterParam(h3, "id")!; + + await prisma.game.update({ + where: { + id, + }, + data: { + tags: { + connect: body.tags.map((e) => ({ id: e })), + }, + }, + }); + + return; +}); diff --git a/server/api/v1/admin/game/index.get.ts b/server/api/v1/admin/game/index.get.ts index e52a4954..c7ca363f 100644 --- a/server/api/v1/admin/game/index.get.ts +++ b/server/api/v1/admin/game/index.get.ts @@ -1,45 +1,16 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; -import libraryManager from "~/server/internal/library"; export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["game:read"]); if (!allowed) throw createError({ statusCode: 403 }); - const query = getQuery(h3); - const gameId = query.id?.toString(); - if (!gameId) - throw createError({ - statusCode: 400, - statusMessage: "Missing id in query", - }); - - const game = await prisma.game.findUnique({ - where: { - id: gameId, - }, - include: { - versions: { - orderBy: { - versionIndex: "asc", - }, - select: { - versionIndex: true, - versionName: true, - platform: true, - delta: true, - }, - }, + return await prisma.game.findMany({ + select: { + id: true, + mName: true, + mShortDescription: true, + mIconObjectId: true, }, }); - - if (!game || !game.libraryId) - throw createError({ statusCode: 404, statusMessage: "Game ID not found" }); - - const unimportedVersions = await libraryManager.fetchUnimportedGameVersions( - game.libraryId, - game.libraryPath, - ); - - return { game, unimportedVersions }; }); diff --git a/server/api/v1/admin/import/game/index.get.ts b/server/api/v1/admin/import/game/index.get.ts index 95373ef2..8ef65fb0 100644 --- a/server/api/v1/admin/import/game/index.get.ts +++ b/server/api/v1/admin/import/game/index.get.ts @@ -5,10 +5,13 @@ export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["import:game:read"]); if (!allowed) throw createError({ statusCode: 403 }); - const unimportedGames = await libraryManager.fetchAllUnimportedGames(); + const unimportedGames = await libraryManager.fetchUnimportedGames(); + const libraries = Object.fromEntries( + (await libraryManager.fetchLibraries()).map((e) => [e.id, e]), + ); const iterableUnimportedGames = Object.entries(unimportedGames) .map(([libraryId, gameArray]) => - gameArray.map((e) => ({ game: e, library: libraryId })), + gameArray.map((e) => ({ game: e, library: libraries[libraryId] })), ) .flat(); return { unimportedGames: iterableUnimportedGames }; diff --git a/server/api/v1/admin/library/index.get.ts b/server/api/v1/admin/library/index.get.ts index 192a7c25..d0a00473 100644 --- a/server/api/v1/admin/library/index.get.ts +++ b/server/api/v1/admin/library/index.get.ts @@ -5,7 +5,7 @@ export default defineEventHandler(async (h3) => { const allowed = await aclManager.allowSystemACL(h3, ["library:read"]); if (!allowed) throw createError({ statusCode: 403 }); - const unimportedGames = await libraryManager.fetchAllUnimportedGames(); + const unimportedGames = await libraryManager.fetchUnimportedGames(); const games = await libraryManager.fetchGamesWithStatus(); // Fetch other library data here diff --git a/server/api/v1/admin/tags/[id]/index.delete.ts b/server/api/v1/admin/tags/[id]/index.delete.ts new file mode 100644 index 00000000..42792579 --- /dev/null +++ b/server/api/v1/admin/tags/[id]/index.delete.ts @@ -0,0 +1,14 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["tags:delete"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const id = getRouterParam(h3, "id")!; + + const tag = await prisma.gameTag.deleteMany({ where: { id } }); + if (tag.count == 0) + throw createError({ statusCode: 404, statusMessage: "Tag not found" }); + return; +}); diff --git a/server/api/v1/admin/tags/index.get.ts b/server/api/v1/admin/tags/index.get.ts new file mode 100644 index 00000000..d8f1c1e1 --- /dev/null +++ b/server/api/v1/admin/tags/index.get.ts @@ -0,0 +1,10 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const tags = await prisma.gameTag.findMany({ orderBy: { name: "asc" } }); + return tags; +}); diff --git a/server/api/v1/admin/tags/index.post.ts b/server/api/v1/admin/tags/index.post.ts new file mode 100644 index 00000000..15698e78 --- /dev/null +++ b/server/api/v1/admin/tags/index.post.ts @@ -0,0 +1,22 @@ +import { type } from "arktype"; +import { readDropValidatedBody, throwingArktype } from "~/server/arktype"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +const CreateTag = type({ + name: "string", +}).configure(throwingArktype); + +export default defineEventHandler(async (h3) => { + const allowed = await aclManager.allowSystemACL(h3, ["tags:read"]); + if (!allowed) throw createError({ statusCode: 403 }); + + const body = await readDropValidatedBody(h3, CreateTag); + + const tag = await prisma.gameTag.create({ + data: { + ...body, + }, + }); + return tag; +}); diff --git a/server/api/v1/companies/[id]/index.get.ts b/server/api/v1/companies/[id]/index.get.ts new file mode 100644 index 00000000..a3263711 --- /dev/null +++ b/server/api/v1/companies/[id]/index.get.ts @@ -0,0 +1,23 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const companyId = getRouterParam(h3, "id"); + if (!companyId) + throw createError({ + statusCode: 400, + statusMessage: "Missing gameId in route params (somehow...?)", + }); + + const company = await prisma.company.findUnique({ + where: { id: companyId }, + }); + + if (!company) + throw createError({ statusCode: 404, statusMessage: "Company not found" }); + + return { company }; +}); diff --git a/server/api/v1/games/[id]/index.get.ts b/server/api/v1/games/[id]/index.get.ts index f12e7ba2..029b9b29 100644 --- a/server/api/v1/games/[id]/index.get.ts +++ b/server/api/v1/games/[id]/index.get.ts @@ -16,6 +16,23 @@ export default defineEventHandler(async (h3) => { where: { id: gameId }, include: { versions: true, + publishers: { + select: { + id: true, + mName: true, + mShortDescription: true, + mLogoObjectId: true, + }, + }, + developers: { + select: { + id: true, + mName: true, + mShortDescription: true, + mLogoObjectId: true, + }, + }, + tags: true, }, }); diff --git a/server/api/v1/admin/settings/index.get.ts b/server/api/v1/settings/index.get.ts similarity index 100% rename from server/api/v1/admin/settings/index.get.ts rename to server/api/v1/settings/index.get.ts diff --git a/server/api/v1/store/recent.get.ts b/server/api/v1/store/featured.get.ts similarity index 94% rename from server/api/v1/store/recent.get.ts rename to server/api/v1/store/featured.get.ts index f4bb39ad..fb353e41 100644 --- a/server/api/v1/store/recent.get.ts +++ b/server/api/v1/store/featured.get.ts @@ -6,6 +6,9 @@ export default defineEventHandler(async (h3) => { if (!userId) throw createError({ statusCode: 403 }); const games = await prisma.game.findMany({ + where: { + featured: true, + }, select: { id: true, mName: true, @@ -28,7 +31,6 @@ export default defineEventHandler(async (h3) => { orderBy: { created: "desc", }, - take: 8, }); return games; diff --git a/server/api/v1/store/index.get.ts b/server/api/v1/store/index.get.ts new file mode 100644 index 00000000..9a61e676 --- /dev/null +++ b/server/api/v1/store/index.get.ts @@ -0,0 +1,122 @@ +import { ArkErrors, type } from "arktype"; +import type { Prisma } from "~/prisma/client/client"; +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; +import { parsePlatform } from "~/server/internal/utils/parseplatform"; + +const StoreRead = type({ + skip: type("string") + .pipe((s) => Number.parseInt(s)) + .default("0"), + take: type("string") + .pipe((s) => Number.parseInt(s)) + .default("10"), + + tags: "string?", + platform: "string?", + + company: "string?", + companyActions: "string = 'published,developed'", + + sort: "'default' | 'newest' | 'recent' = 'default'", +}); + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const query = getQuery(h3); + const options = StoreRead(query); + if (options instanceof ArkErrors) + throw createError({ statusCode: 400, statusMessage: options.summary }); + + /** + * Generic filters + */ + const tagFilter = options.tags + ? { + tags: { + some: { + id: { + in: options.tags.split(","), + }, + }, + }, + } + : undefined; + const platformFilter = options.platform + ? { + versions: { + some: { + platform: { + in: options.platform + .split(",") + .map(parsePlatform) + .filter((e) => e !== undefined), + }, + }, + }, + } + : undefined; + + /** + * Company filtering + */ + const companyActions = options.companyActions.split(","); + const developedFilter = companyActions.includes("developed") + ? { + developers: { + some: { + id: options.company!, + }, + }, + } + : undefined; + const publishedFilter = companyActions.includes("published") + ? { + publishers: { + some: { + id: options.company!, + }, + }, + } + : undefined; + const companyFilter = options.company + ? ({ + OR: [developedFilter, publishedFilter].filter((e) => e !== undefined), + } satisfies Prisma.GameWhereInput) + : undefined; + + /** + * Query + */ + + const finalFilter: Prisma.GameWhereInput = { + ...tagFilter, + ...platformFilter, + ...companyFilter, + }; + + const sort: Prisma.GameOrderByWithRelationInput = {}; + switch (options.sort) { + case "default": + case "newest": + sort.mReleased = "desc"; + break; + case "recent": + sort.created = "desc"; + break; + } + + const [results, count] = await prisma.$transaction([ + prisma.game.findMany({ + skip: options.skip, + take: Math.min(options.take, 50), + where: finalFilter, + orderBy: sort, + }), + prisma.game.count({ where: finalFilter }), + ]); + + return { results, count }; +}); diff --git a/server/api/v1/store/released.get.ts b/server/api/v1/store/tags.get.ts similarity index 52% rename from server/api/v1/store/released.get.ts rename to server/api/v1/store/tags.get.ts index 37c729c2..07f4030f 100644 --- a/server/api/v1/store/released.get.ts +++ b/server/api/v1/store/tags.get.ts @@ -2,15 +2,9 @@ import aclManager from "~/server/internal/acls"; import prisma from "~/server/internal/db/database"; export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserACL(h3, ["store:read"]); + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); if (!userId) throw createError({ statusCode: 403 }); - const games = await prisma.game.findMany({ - orderBy: { - mReleased: "desc", - }, - take: 12, - }); - - return games; + const tags = await prisma.gameTag.findMany({ orderBy: { name: "asc" } }); + return tags; }); diff --git a/server/api/v1/store/updated.get.ts b/server/api/v1/store/updated.get.ts deleted file mode 100644 index 520033e3..00000000 --- a/server/api/v1/store/updated.get.ts +++ /dev/null @@ -1,28 +0,0 @@ -import aclManager from "~/server/internal/acls"; -import prisma from "~/server/internal/db/database"; - -export default defineEventHandler(async (h3) => { - const userId = await aclManager.getUserACL(h3, ["store:read"]); - if (!userId) throw createError({ statusCode: 403 }); - - const versions = await prisma.gameVersion.findMany({ - where: { - versionIndex: { - gte: 1, - }, - }, - select: { - game: true, - }, - orderBy: { - created: "desc", - }, - take: 12, - }); - - const games = versions - .map((e) => e.game) - .filter((v, i, a) => a.findIndex((e) => e.id === v.id) === i); - - return games; -}); diff --git a/server/api/v1/tags/[id]/index.get.ts b/server/api/v1/tags/[id]/index.get.ts new file mode 100644 index 00000000..5d5d686c --- /dev/null +++ b/server/api/v1/tags/[id]/index.get.ts @@ -0,0 +1,23 @@ +import aclManager from "~/server/internal/acls"; +import prisma from "~/server/internal/db/database"; + +export default defineEventHandler(async (h3) => { + const userId = await aclManager.getUserIdACL(h3, ["store:read"]); + if (!userId) throw createError({ statusCode: 403 }); + + const tagId = getRouterParam(h3, "id"); + if (!tagId) + throw createError({ + statusCode: 400, + statusMessage: "Missing gameId in route params (somehow...?)", + }); + + const tag = await prisma.gameTag.findUnique({ + where: { id: tagId }, + }); + + if (!tag) + throw createError({ statusCode: 404, statusMessage: "Tag not found" }); + + return { tag }; +}); diff --git a/server/internal/acls/descriptions.ts b/server/internal/acls/descriptions.ts index 59d7d27c..98b919da 100644 --- a/server/internal/acls/descriptions.ts +++ b/server/internal/acls/descriptions.ts @@ -70,6 +70,11 @@ export const systemACLDescriptions: ObjectFromList = { "game:image:new": "Upload an image for a game.", "game:image:delete": "Delete an image for a game.", + "company:read": "Fetch companies.", + "company:create": "Create a new company.", + "company:update": "Update existing companies.", + "company:delete": "Delete companies.", + "import:version:read": "Fetch versions to be imported, and information about versions to be imported.", "import:version:new": "Import a game version.", @@ -77,6 +82,10 @@ export const systemACLDescriptions: ObjectFromList = { "Fetch games to be imported, and search the metadata for games.", "import:game:new": "Import a game.", + "tags:read": "Fetch all tags", + "tags:create": "Create a tag", + "tags:delete": "Delete a tag", + "user:read": "Fetch any user's information.", "user:delete": "Delete a user.", diff --git a/server/internal/acls/index.ts b/server/internal/acls/index.ts index 169d34c5..2c2f1c61 100644 --- a/server/internal/acls/index.ts +++ b/server/internal/acls/index.ts @@ -65,6 +65,11 @@ export const systemACLs = [ "game:image:new", "game:image:delete", + "company:read", + "company:update", + "company:create", + "company:delete", + "import:version:read", "import:version:new", @@ -78,6 +83,10 @@ export const systemACLs = [ "news:create", "news:delete", + "tags:read", + "tags:create", + "tags:delete", + "task:read", "task:start", diff --git a/server/internal/library/index.ts b/server/internal/library/index.ts index 75a1140f..ad6b17e6 100644 --- a/server/internal/library/index.ts +++ b/server/internal/library/index.ts @@ -11,11 +11,15 @@ import { fuzzy } from "fast-fuzzy"; import taskHandler from "../tasks"; import { parsePlatform } from "../utils/parseplatform"; import notificationSystem from "../notifications"; -import type { LibraryProvider } from "./provider"; +import { GameNotFoundError, type LibraryProvider } from "./provider"; +import { logger } from "../logging"; class LibraryManager { private libraries: Map> = new Map(); + private gameImportLocks: Map> = new Map(); // Library ID to Library Path + private versionImportLocks: Map> = new Map(); // Game ID to Version Name + addLibrary(library: LibraryProvider) { this.libraries.set(library.id(), library); } @@ -33,7 +37,7 @@ class LibraryManager { return libraryWithMetadata; } - async fetchAllUnimportedGames() { + async fetchUnimportedGames() { const unimportedGames: { [key: string]: string[] } = {}; for (const [id, library] of this.libraries.entries()) { @@ -48,7 +52,9 @@ class LibraryManager { }, }); const providerUnimportedGames = games.filter( - (e) => validGames.findIndex((v) => v.libraryPath == e) == -1, + (e) => + validGames.findIndex((v) => v.libraryPath == e) == -1 && + !(this.gameImportLocks.get(id) ?? []).includes(e), ); unimportedGames[id] = providerUnimportedGames; } @@ -67,30 +73,34 @@ class LibraryManager { }, }, select: { + id: true, versions: true, }, }); if (!game) return undefined; - const versions = await provider.listVersions(libraryPath); - const unimportedVersions = versions.filter( - (e) => game.versions.findIndex((v) => v.versionName == e) == -1, - ); - - return unimportedVersions; + try { + const versions = await provider.listVersions(libraryPath); + const unimportedVersions = versions.filter( + (e) => + game.versions.findIndex((v) => v.versionName == e) == -1 && + !(this.versionImportLocks.get(game.id) ?? []).includes(e), + ); + return unimportedVersions; + } catch (e) { + if (e instanceof GameNotFoundError) { + logger.warn(e); + return undefined; + } + throw e; + } } async fetchGamesWithStatus() { const games = await prisma.game.findMany({ - select: { - id: true, + include: { versions: true, - mName: true, - mShortDescription: true, - metadataSource: true, - mIconObjectId: true, - libraryId: true, - libraryPath: true, + library: true, }, orderBy: { mName: "asc", @@ -98,19 +108,30 @@ class LibraryManager { }); return await Promise.all( - games.map(async (e) => ({ - game: e, - status: { - noVersions: e.versions.length == 0, - unimportedVersions: (await this.fetchUnimportedGameVersions( - e.libraryId ?? "", - e.libraryPath, - ))!, - }, - })), + games.map(async (e) => { + const versions = await this.fetchUnimportedGameVersions( + e.libraryId ?? "", + e.libraryPath, + ); + return { + game: e, + status: versions + ? { + noVersions: e.versions.length == 0, + unimportedVersions: versions, + } + : ("offline" as const), + }; + }), ); } + /** + * Fetches recommendations and extra data about the version. Doesn't actually check if it's been imported. + * @param gameId + * @param versionName + * @returns + */ async fetchUnimportedVersionInformation(gameId: string, versionName: string) { const game = await prisma.game.findUnique({ where: { id: gameId }, @@ -130,10 +151,7 @@ class LibraryManager { // No extension is common for Linux binaries "", ], - Windows: [ - // Pretty much the only one - ".exe", - ], + Windows: [".exe", ".bat"], macOS: [ // App files ".app", @@ -188,6 +206,70 @@ class LibraryManager { } */ + /** + * Locks the game so you can't be imported + * @param libraryId + * @param libraryPath + */ + async lockGame(libraryId: string, libraryPath: string) { + let games = this.gameImportLocks.get(libraryId); + if (!games) this.gameImportLocks.set(libraryId, (games = [])); + + if (!games.includes(libraryPath)) games.push(libraryPath); + + this.gameImportLocks.set(libraryId, games); + } + + /** + * Unlocks the game, call once imported + * @param libraryId + * @param libraryPath + */ + async unlockGame(libraryId: string, libraryPath: string) { + let games = this.gameImportLocks.get(libraryId); + if (!games) this.gameImportLocks.set(libraryId, (games = [])); + + if (games.includes(libraryPath)) + games.splice( + games.findIndex((e) => e === libraryPath), + 1, + ); + + this.gameImportLocks.set(libraryId, games); + } + + /** + * Locks a version so it can't be imported + * @param gameId + * @param versionName + */ + async lockVersion(gameId: string, versionName: string) { + let versions = this.versionImportLocks.get(gameId); + if (!versions) this.versionImportLocks.set(gameId, (versions = [])); + + if (!versions.includes(versionName)) versions.push(versionName); + + this.versionImportLocks.set(gameId, versions); + } + + /** + * Unlocks the version, call once imported + * @param libraryId + * @param libraryPath + */ + async unlockVersion(gameId: string, versionName: string) { + let versions = this.versionImportLocks.get(gameId); + if (!versions) this.versionImportLocks.set(gameId, (versions = [])); + + if (versions.includes(gameId)) + versions.splice( + versions.findIndex((e) => e === versionName), + 1, + ); + + this.versionImportLocks.set(gameId, versions); + } + async importVersion( gameId: string, versionName: string, @@ -218,6 +300,8 @@ class LibraryManager { const library = this.libraries.get(game.libraryId); if (!library) return undefined; + await this.lockVersion(gameId, versionName); + taskHandler.create({ id: taskId, taskGroup: "import:game", @@ -294,6 +378,9 @@ class LibraryManager { progress(100); }, + async finally() { + await libraryManager.unlockVersion(gameId, versionName); + }, }); return taskId; diff --git a/server/internal/metadata/giantbomb.ts b/server/internal/metadata/giantbomb.ts index 621aa93a..aa7e6d60 100644 --- a/server/internal/metadata/giantbomb.ts +++ b/server/internal/metadata/giantbomb.ts @@ -65,6 +65,11 @@ interface GameResult { reviews?: Array<{ api_detail_url: string; }>; + + genres?: Array<{ + name: string; + id: number; + }>; } interface ReviewResult { @@ -189,7 +194,7 @@ export class GiantBombProvider implements MetadataProvider { context?.logger.warn(`Failed to import publisher "${pub}"`); continue; } - context?.logger.info(`Imported publisher "${pub}"`); + context?.logger.info(`Imported publisher "${pub.name}"`); publishers.push(res); } } @@ -224,11 +229,7 @@ export class GiantBombProvider implements MetadataProvider { const releaseDate = gameData.original_release_date ? DateTime.fromISO(gameData.original_release_date).toJSDate() - : DateTime.fromISO( - `${gameData.expected_release_year ?? new Date().getFullYear()}-${ - gameData.expected_release_month ?? 1 - }-${gameData.expected_release_day ?? 1}`, - ).toJSDate(); + : new Date(); context?.progress(85); @@ -249,6 +250,8 @@ export class GiantBombProvider implements MetadataProvider { } } + const tags = (gameData.genres ?? []).map((e) => e.name); + const metadata: GameMetadata = { id: gameData.guid, name: gameData.name, @@ -256,7 +259,7 @@ export class GiantBombProvider implements MetadataProvider { description: longDescription, released: releaseDate, - tags: [], + tags, reviews, diff --git a/server/internal/metadata/igdb.ts b/server/internal/metadata/igdb.ts index 20f1a5f9..52e47b23 100644 --- a/server/internal/metadata/igdb.ts +++ b/server/internal/metadata/igdb.ts @@ -450,7 +450,7 @@ export class IGDBProvider implements MetadataProvider { mReviewHref: currentGame.url, }; - const tags = await this.getGenres(currentGame.genres); + const genres = await this.getGenres(currentGame.genres); const deck = this.trimMessage(currentGame.summary, 280); @@ -461,12 +461,13 @@ export class IGDBProvider implements MetadataProvider { description: currentGame.summary, released, + genres, reviews: [review], publishers, developers, - tags, + tags: [], icon, bannerId: banner, diff --git a/server/internal/metadata/index.ts b/server/internal/metadata/index.ts index e202bc07..05a22674 100644 --- a/server/internal/metadata/index.ts +++ b/server/internal/metadata/index.ts @@ -18,6 +18,8 @@ import taskHandler, { wrapTaskContext } from "../tasks"; import { randomUUID } from "crypto"; import { fuzzy } from "fast-fuzzy"; import { logger } from "~/server/internal/logging"; +import libraryManager from "../library"; +import type { GameTagModel } from "~/prisma/client/models"; export class MissingMetadataProviderConfig extends Error { private providerName: string; @@ -124,19 +126,22 @@ export class MetadataHandler { ); } - private parseTags(tags: string[]) { - const results: Array = []; + private async parseTags(tags: string[]) { + const results: Array = []; - tags.forEach((t) => - results.push({ - where: { - name: t, - }, - create: { - name: t, - }, - }), - ); + for (const tag of tags) { + const rawResults: GameTagModel[] = + await prisma.$queryRaw`SELECT * FROM "GameTag" WHERE SIMILARITY(name, ${tag}) > 0.45;`; + let resultTag = rawResults.at(0); + if (!resultTag) { + resultTag = await prisma.gameTag.create({ + data: { + name: tag, + }, + }); + } + results.push(resultTag); + } return results; } @@ -180,6 +185,8 @@ export class MetadataHandler { }); if (existing) return undefined; + await libraryManager.lockGame(libraryId, libraryPath); + const gameId = randomUUID(); const taskId = `import:${gameId}`; @@ -262,7 +269,7 @@ export class MetadataHandler { connectOrCreate: metadataHandler.parseRatings(metadata.reviews), }, tags: { - connectOrCreate: metadataHandler.parseTags(metadata.tags), + connect: await metadataHandler.parseTags(metadata.tags), }, libraryId, @@ -271,6 +278,10 @@ export class MetadataHandler { }); logger.info(`Finished game import.`); + progress(100); + }, + async finally() { + await libraryManager.unlockGame(libraryId, libraryPath); }, }); diff --git a/server/internal/tasks/index.ts b/server/internal/tasks/index.ts index 44d8ef41..bb79301c 100644 --- a/server/internal/tasks/index.ts +++ b/server/internal/tasks/index.ts @@ -206,6 +206,8 @@ class TaskHandler { }; } + if (task.finally) await task.finally(); + taskEntry.endTime = new Date().toISOString(); await updateAllClients(); @@ -427,6 +429,7 @@ export interface Task { taskGroup: TaskGroup; name: string; run: (context: TaskRunContext) => Promise; + finally?: () => Promise | void; acls: GlobalACL[]; } diff --git a/yarn.lock b/yarn.lock index 0d93798f..d6bd269f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2436,6 +2436,11 @@ resolved "https://registry.yarnpkg.com/@types/turndown/-/turndown-5.0.5.tgz#614de24fc9ace4d8c0d9483ba81dc8c1976dd26f" integrity sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w== +"@types/web-bluetooth@^0.0.21": + version "0.0.21" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63" + integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== + "@types/yauzl@^2.9.1": version "2.10.3" resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" @@ -2893,6 +2898,35 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.5.18.tgz#529f24a88d3ed678d50fd5c07455841fbe8ac95e" integrity sha512-cZy8Dq+uuIXbxCZpuLd2GJdeSO/lIzIspC2WtkqIpje5QyFbvLaI5wZtdUjLHjGZrlVX6GilejatWwVYYRc8tA== +"@vueuse/core@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/core/-/core-13.6.0.tgz#4137f63dc4cef2ff8ae74ee146d6b6070d707878" + integrity sha512-DJbD5fV86muVmBgS9QQPddVX7d9hWYswzlf4bIyUD2dj8GC46R1uNClZhVAmsdVts4xb2jwp1PbpuiA50Qee1A== + dependencies: + "@types/web-bluetooth" "^0.0.21" + "@vueuse/metadata" "13.6.0" + "@vueuse/shared" "13.6.0" + +"@vueuse/metadata@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/metadata/-/metadata-13.6.0.tgz#49196025c96c7daeb591c20a54b61cc336af99b6" + integrity sha512-rnIH7JvU7NjrpexTsl2Iwv0V0yAx9cw7+clymjKuLSXG0QMcLD0LDgdNmXic+qL0SGvgSVPEpM9IDO/wqo1vkQ== + +"@vueuse/nuxt@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/nuxt/-/nuxt-13.6.0.tgz#96dfa26021bc17e1c5020c1c42ba425a9d00112f" + integrity sha512-zOZ5XkA7Svsx90934UWwKUsThAjKSD48Ks/mjEzl2gJm5d5zYJg+CJxPi7Wv5XECtCBOX18GpmTKqanWlbA1aQ== + dependencies: + "@nuxt/kit" "^4.0.1" + "@vueuse/core" "13.6.0" + "@vueuse/metadata" "13.6.0" + local-pkg "^1.1.1" + +"@vueuse/shared@13.6.0": + version "13.6.0" + resolved "https://registry.yarnpkg.com/@vueuse/shared/-/shared-13.6.0.tgz#872fdbd725fb4e3a12bd5aab85af9a5db0b1e481" + integrity sha512-pDykCSoS2T3fsQrYqf9SyF0QXWHmcGPQ+qiOVjlYSzlWd9dgppB2bFSM1GgKKkt7uzn0BBMV3IbJsUfHG2+BCg== + "@whatwg-node/disposablestack@^0.0.6": version "0.0.6" resolved "https://registry.yarnpkg.com/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz#2064a1425ea66194def6df0c7a1851b6939c82bb"