diff --git a/.prettierignore b/.prettierignore index 1e7121f..3c9727c 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 1cc3faf..5bb8636 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 526e2eb..0db4d0e 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 40ddf8c..ced13de 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 4190b85..6b2cc3e 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 0494901..5dfa85f 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 0000000..97e91fb --- /dev/null +++ b/components/StoreView.vue @@ -0,0 +1,488 @@ + + + diff --git a/components/UserFooter.vue b/components/UserFooter.vue index b62cb77..3060021 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 af42dfc..963de6d 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 0000000..8b29867 --- /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 a14d1b7..04125e8 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 b4c5e3c..0576c43 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 7857762..36001de 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 0df185c..67408f9 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 aaf5dba..24cef9b 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 e3384e1..2c73239 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 479038f..84a1836 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 62d9f27..9d592d1 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 5f32f54..d6eede4 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 3adf807..f113509 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 046cf88..251da85 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 0000000..12698eb --- /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 0000000..ab43365 --- /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 0000000..a41cba2 --- /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 caf4609..3a80db9 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 710fd39..29b9740 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 9704f2e..d2820ae 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 336f371..bdaa344 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 3f17099..8d87bd7 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 110ea65..a984018 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 2a2ef25..4bcd942 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 0000000..a4c62c3 --- /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 b9c1f80..4254e1a 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 0000000..df31459 --- /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 0000000..63accfe --- /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 0000000..89aeeb4 --- /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 0000000..4f4f1df --- /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 0000000..567fbd0 --- /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 0000000..02b93b5 --- /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 0000000..cbd80c5 --- /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 f53d612..587f2a6 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 79363f0..496994f 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 0000000..0c8744a --- /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 0000000..0a9fe21 --- /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 0000000..9e0260f --- /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 0000000..f873118 --- /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 0000000..0a4cefd --- /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 0000000..b27d393 --- /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 0000000..50c4115 --- /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 0000000..74511e2 --- /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 0000000..dac5ae2 --- /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 730763c..2a0645d 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 0000000..44693dc --- /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 238dc8e..410adee 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 b6f4e21..cd1dcbc 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 0000000..d7192ad --- /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 e52a495..c7ca363 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 95373ef..8ef65fb 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 192a7c2..d0a0047 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 0000000..4279257 --- /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 0000000..d8f1c1e --- /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 0000000..15698e7 --- /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 0000000..a326371 --- /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 f12e7ba..029b9b2 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 f4bb39a..fb353e4 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 0000000..9a61e67 --- /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 37c729c..07f4030 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 520033e..0000000 --- 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 0000000..5d5d686 --- /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 59d7d27..98b919d 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 169d34c..2c2f1c6 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 75a1140..ad6b17e 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 621aa93..aa7e6d6 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 20f1a5f..52e47b2 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 e202bc0..05a2267 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 44d8ef4..bb79301 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 0d93798..d6bd269 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"