From 3c8bb0923d67288e46a62c83bb77182c576b1145 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:16:05 -0700 Subject: [PATCH 001/145] fix authorized apps settings, redesign, fix retro theme vars (#6900) * fix authorized apps settings, redesign, fix retro theme vars * merge * fix: retro in app --------- Co-authored-by: Calum H. (IMB11) --- .../ui/settings/AppearanceSettings.vue | 9 +- apps/app-frontend/src/helpers/types.d.ts | 2 +- apps/app-frontend/src/store/theme.ts | 2 +- apps/frontend/src/assets/styles/global.scss | 10 +- .../components/ui/AdsConsentNotification.vue | 1 + .../src/components/ui/AuthorizationCard.vue | 165 +++++++++++ apps/frontend/src/locales/cs-CZ/index.json | 3 - apps/frontend/src/locales/da-DK/index.json | 3 - apps/frontend/src/locales/de-CH/index.json | 6 - apps/frontend/src/locales/de-DE/index.json | 6 - apps/frontend/src/locales/en-US/index.json | 17 +- apps/frontend/src/locales/es-419/index.json | 6 - apps/frontend/src/locales/es-ES/index.json | 6 - apps/frontend/src/locales/fil-PH/index.json | 6 - apps/frontend/src/locales/fr-FR/index.json | 6 - apps/frontend/src/locales/hu-HU/index.json | 6 - apps/frontend/src/locales/it-IT/index.json | 6 - apps/frontend/src/locales/ja-JP/index.json | 6 - apps/frontend/src/locales/ko-KR/index.json | 6 - apps/frontend/src/locales/ms-MY/index.json | 6 - apps/frontend/src/locales/nl-NL/index.json | 6 - apps/frontend/src/locales/no-NO/index.json | 6 - apps/frontend/src/locales/pl-PL/index.json | 6 - apps/frontend/src/locales/pt-BR/index.json | 6 - apps/frontend/src/locales/pt-PT/index.json | 6 - apps/frontend/src/locales/ru-RU/index.json | 6 - apps/frontend/src/locales/sr-CS/index.json | 3 - apps/frontend/src/locales/sv-SE/index.json | 3 - apps/frontend/src/locales/tr-TR/index.json | 6 - apps/frontend/src/locales/uk-UA/index.json | 6 - apps/frontend/src/locales/vi-VN/index.json | 6 - apps/frontend/src/locales/zh-CN/index.json | 6 - apps/frontend/src/locales/zh-TW/index.json | 6 - apps/frontend/src/pages/settings.vue | 29 +- .../src/pages/settings/authorizations.vue | 265 +++++++----------- .../src/modules/labrinth/oauth/internal.ts | 17 +- packages/app-lib/src/state/settings.rs | 3 + packages/assets/styles/variables.scss | 33 +++ .../layouts/shared/user-profile/layout.vue | 10 +- packages/ui/src/locales/en-US/index.json | 3 + packages/ui/src/utils/common-messages.ts | 4 + packages/utils/users.ts | 14 + 42 files changed, 383 insertions(+), 345 deletions(-) create mode 100644 apps/frontend/src/components/ui/AuthorizationCard.vue diff --git a/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue b/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue index b986d25bc9..8100571a9a 100644 --- a/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue +++ b/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue @@ -1,6 +1,6 @@ + diff --git a/packages/api-client/src/modules/labrinth/oauth/internal.ts b/packages/api-client/src/modules/labrinth/oauth/internal.ts index 0b44186320..680009e05e 100644 --- a/packages/api-client/src/modules/labrinth/oauth/internal.ts +++ b/packages/api-client/src/modules/labrinth/oauth/internal.ts @@ -45,14 +45,15 @@ export class LabrinthOAuthInternalModule extends AbstractModule { * @returns Promise resolving to an array of OAuth clients */ public async getApps(ids: string[]): Promise { - return this.client.request( - `/oauth/apps?ids=${encodeURIComponent(JSON.stringify(ids))}`, - { - api: 'labrinth', - version: 'internal', - method: 'GET', - }, - ) + if (ids.length === 0) { + return [] + } + + // bulk `/oauth/apps` is broken on backend, fetch by id instead + // TODO: Remove this once the backend is fixed + const results = await Promise.all(ids.map((id) => this.getApp(id).catch(() => null))) + + return results.filter((app): app is Labrinth.OAuth.Internal.OAuthClient => app !== null) } /** diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs index cd7d4615f5..011778996e 100644 --- a/packages/app-lib/src/state/settings.rs +++ b/packages/app-lib/src/state/settings.rs @@ -329,6 +329,7 @@ pub enum Theme { Dark, Light, Oled, + Retro, System, } @@ -338,6 +339,7 @@ impl Theme { Theme::Dark => "dark", Theme::Light => "light", Theme::Oled => "oled", + Theme::Retro => "retro", Theme::System => "system", } } @@ -347,6 +349,7 @@ impl Theme { "dark" => Theme::Dark, "light" => Theme::Light, "oled" => Theme::Oled, + "retro" => Theme::Retro, "system" => Theme::System, _ => Theme::Dark, } diff --git a/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index fdcb7bbabd..43bc28aa50 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -422,6 +422,39 @@ html { } .retro-mode { + @extend .dark-mode; + --surface-1: #191917; + --surface-2: rgb(22, 22, 21); --surface-2-5: #3a3c3e; + --surface-3: #232421; + --surface-4: #3a3b38; + --surface-5: #5a5c58; + --color-button-bg: #3a3b38; + --color-base: #c3c4b3; + --color-secondary: #9b9e98; + --color-contrast: #e6e2d1; + + --color-brand: #4d9227; + --color-brand-highlight: #25421e; + --color-accent-contrast: #ffffff; + --color-ad: var(--color-brand-highlight); + --color-ad-raised: var(--color-brand); + --color-ad-contrast: black; + --color-ad-highlight: var(--color-brand); + + --color-red: rgb(232, 32, 13); + --color-orange: rgb(232, 141, 13); + --color-green: rgb(60, 219, 54); + --color-blue: rgb(9, 159, 239); + --color-purple: rgb(139, 129, 230); + --color-gray: #718096; + + --color-red-highlight: rgba(232, 32, 13, 0.25); + --color-orange-highlight: rgba(232, 141, 13, 0.25); + --color-green-highlight: rgba(60, 219, 54, 0.25); + --color-blue-highlight: rgba(9, 159, 239, 0.25); + --color-purple-highlight: rgba(139, 129, 230, 0.25); + --color-gray-highlight: rgba(113, 128, 150, 0.25); + --brand-gradient-strong-bg: #3a3b38; } diff --git a/packages/ui/src/layouts/shared/user-profile/layout.vue b/packages/ui/src/layouts/shared/user-profile/layout.vue index 28dcdf0f8d..6ba0cdf85d 100644 --- a/packages/ui/src/layouts/shared/user-profile/layout.vue +++ b/packages/ui/src/layouts/shared/user-profile/layout.vue @@ -402,7 +402,11 @@ import { SpinnerIcon, XIcon, } from '@modrinth/assets' -import { UserBadge } from '@modrinth/utils' +import { + isModrinthUser as checkIsModrinthUser, + isOfficialAccount as checkIsOfficialAccount, + UserBadge, +} from '@modrinth/utils' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' @@ -716,8 +720,8 @@ const earliestProjectByType = computed(() => { return earliest }) -const isModrinthUser = computed(() => user.value?.id === '2REoufqX') -const isOfficialAccount = computed(() => isModrinthUser.value || user.value?.id === 'GVFjtWTf') +const isModrinthUser = computed(() => checkIsModrinthUser(user.value?.id)) +const isOfficialAccount = computed(() => checkIsOfficialAccount(user.value?.id)) const isSelf = computed(() => auth.user.value?.id === user.value?.id) const isAdminViewing = computed(() => auth.user.value?.role === 'admin') const isStaffViewing = computed( diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json index f515082ecb..f413e28eaf 100644 --- a/packages/ui/src/locales/en-US/index.json +++ b/packages/ui/src/locales/en-US/index.json @@ -2318,6 +2318,9 @@ "label.password": { "defaultMessage": "Password" }, + "label.permissions": { + "defaultMessage": "Permissions" + }, "label.plan-custom": { "defaultMessage": "Custom" }, diff --git a/packages/ui/src/utils/common-messages.ts b/packages/ui/src/utils/common-messages.ts index f9d65d0747..26eefd90ad 100644 --- a/packages/ui/src/utils/common-messages.ts +++ b/packages/ui/src/utils/common-messages.ts @@ -287,6 +287,10 @@ export const commonMessages = defineMessages({ id: 'label.scopes', defaultMessage: 'Scopes', }, + permissionsLabel: { + id: 'label.permissions', + defaultMessage: 'Permissions', + }, searchLabel: { id: 'label.search', defaultMessage: 'Search', diff --git a/packages/utils/users.ts b/packages/utils/users.ts index f13f438d81..192955dd9d 100644 --- a/packages/utils/users.ts +++ b/packages/utils/users.ts @@ -13,3 +13,17 @@ export const isAdmin = (user) => { } export const STAFF_ROLES = ['moderator', 'admin'] + +export const MODRINTH_USER_ID = '2REoufqX' +export const AUTOMOD_USER_ID = '' +export const MODRINTH_ARCHIVES_USER_ID = 'GVFjtWTf' + +export const OFFICIAL_ACCOUNT_IDS = [MODRINTH_USER_ID, AUTOMOD_USER_ID, MODRINTH_ARCHIVES_USER_ID] + +export const isModrinthUser = (userId) => { + return userId === MODRINTH_USER_ID +} + +export const isOfficialAccount = (userId) => { + return OFFICIAL_ACCOUNT_IDS.includes(userId) +} From 5d34e7902a0171c9f9c4ba994777afe2b18239d5 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Tue, 28 Jul 2026 15:09:41 +0100 Subject: [PATCH 002/145] refactor: app settings layout (#6891) * refactor: app settings layout * fix: privacy copy got reverted * fix: i18n * fix: modal title * fix: rev comments --- .../src/components/ui/JavaDetectionModal.vue | 85 ++++- .../src/components/ui/JavaSelector.vue | 57 ++- .../components/ui/modal/AppSettingsModal.vue | 115 ++++-- .../ui/settings/AppearanceSettings.vue | 340 ------------------ .../ui/settings/DefaultInstanceSettings.vue | 186 ---------- .../settings/ResourceManagementSettings.vue | 212 ----------- .../{ => account}/PrivacySettings.vue | 51 ++- .../settings/display/AppearanceSettings.vue | 108 ++++++ .../ui/settings/display/BehaviorSettings.vue | 302 ++++++++++++++++ .../{ => display}/FeatureFlagSettings.vue | 0 .../{ => display}/LanguageSettings.vue | 5 +- .../instances/DefaultInstanceSettings.vue | 333 +++++++++++++++++ .../settings/{ => instances}/JavaSettings.vue | 12 +- .../instances/ResourceManagementSettings.vue | 292 +++++++++++++++ apps/app-frontend/src/helpers/settings.ts | 2 +- .../app-frontend/src/locales/ar-SA/index.json | 3 - .../app-frontend/src/locales/cs-CZ/index.json | 3 - .../app-frontend/src/locales/da-DK/index.json | 3 - .../app-frontend/src/locales/de-CH/index.json | 3 - .../app-frontend/src/locales/de-DE/index.json | 3 - .../app-frontend/src/locales/en-US/index.json | 252 +++++++++++-- .../src/locales/es-419/index.json | 3 - .../app-frontend/src/locales/es-ES/index.json | 3 - .../app-frontend/src/locales/fi-FI/index.json | 3 - .../src/locales/fil-PH/index.json | 3 - .../app-frontend/src/locales/fr-FR/index.json | 3 - .../app-frontend/src/locales/hu-HU/index.json | 3 - .../app-frontend/src/locales/id-ID/index.json | 3 - .../app-frontend/src/locales/it-IT/index.json | 3 - .../app-frontend/src/locales/ja-JP/index.json | 3 - .../app-frontend/src/locales/ko-KR/index.json | 3 - .../app-frontend/src/locales/ms-MY/index.json | 3 - .../app-frontend/src/locales/nl-NL/index.json | 3 - .../app-frontend/src/locales/no-NO/index.json | 3 - .../app-frontend/src/locales/pl-PL/index.json | 3 - .../app-frontend/src/locales/pt-BR/index.json | 3 - .../app-frontend/src/locales/pt-PT/index.json | 3 - .../app-frontend/src/locales/ru-RU/index.json | 3 - .../app-frontend/src/locales/sr-CS/index.json | 3 - .../app-frontend/src/locales/sv-SE/index.json | 3 - .../app-frontend/src/locales/th-TH/index.json | 3 - .../app-frontend/src/locales/tr-TR/index.json | 3 - .../app-frontend/src/locales/uk-UA/index.json | 3 - .../app-frontend/src/locales/vi-VN/index.json | 3 - .../app-frontend/src/locales/zh-CN/index.json | 3 - .../app-frontend/src/locales/zh-TW/index.json | 3 - package.json | 1 + .../ui/src/components/modal/TabbedModal.vue | 50 ++- .../src/stories/modal/TabbedModal.stories.ts | 9 + pnpm-lock.yaml | 104 ++++-- 50 files changed, 1645 insertions(+), 961 deletions(-) delete mode 100644 apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue delete mode 100644 apps/app-frontend/src/components/ui/settings/DefaultInstanceSettings.vue delete mode 100644 apps/app-frontend/src/components/ui/settings/ResourceManagementSettings.vue rename apps/app-frontend/src/components/ui/settings/{ => account}/PrivacySettings.vue (61%) create mode 100644 apps/app-frontend/src/components/ui/settings/display/AppearanceSettings.vue create mode 100644 apps/app-frontend/src/components/ui/settings/display/BehaviorSettings.vue rename apps/app-frontend/src/components/ui/settings/{ => display}/FeatureFlagSettings.vue (100%) rename apps/app-frontend/src/components/ui/settings/{ => display}/LanguageSettings.vue (91%) create mode 100644 apps/app-frontend/src/components/ui/settings/instances/DefaultInstanceSettings.vue rename apps/app-frontend/src/components/ui/settings/{ => instances}/JavaSettings.vue (73%) create mode 100644 apps/app-frontend/src/components/ui/settings/instances/ResourceManagementSettings.vue diff --git a/apps/app-frontend/src/components/ui/JavaDetectionModal.vue b/apps/app-frontend/src/components/ui/JavaDetectionModal.vue index 3f864c6472..31e481431f 100644 --- a/apps/app-frontend/src/components/ui/JavaDetectionModal.vue +++ b/apps/app-frontend/src/components/ui/JavaDetectionModal.vue @@ -1,5 +1,9 @@ - - - + + + {{ item.text }} @@ -142,16 +146,28 @@ full-width />
- - - + + {{ btn.label }} +
@@ -171,6 +187,8 @@ import { } from '@modrinth/assets' import { computed, ref } from 'vue' +import { Button, IconButton } from '#ui/components/base/buttons' + import { useModalStack } from '../../composables/modal-stack' import { injectPopupNotificationManager, @@ -178,7 +196,6 @@ import { type PopupNotificationButton, type PopupNotificationProgressItem, } from '../../providers' -import ButtonStyled from '../base/ButtonStyled.vue' import ProgressBar from '../base/ProgressBar.vue' import NotificationToast from '../notifications/NotificationToast.vue' diff --git a/packages/ui/src/components/notifications/NotificationToast.vue b/packages/ui/src/components/notifications/NotificationToast.vue index 185fee06d7..82169e0044 100644 --- a/packages/ui/src/components/notifications/NotificationToast.vue +++ b/packages/ui/src/components/notifications/NotificationToast.vue @@ -49,31 +49,33 @@

- - - + + +
- - - - - - + +
@@ -96,16 +98,17 @@ {{ entityLabel }}

- - - + + +
- - - - - - + +
{{ progressLabel }} @@ -145,16 +144,28 @@ v-if="type === 'instance-download' && actions?.length" class="col-start-1 col-end-3 row-start-3 mt-2 flex min-w-0 flex-wrap items-center gap-2" > - - - + + {{ action.label }} +
@@ -182,11 +193,12 @@ import { CheckIcon, SpinnerIcon, XIcon } from '@modrinth/assets' import { computed, ref } from 'vue' +import { Button, IconButton } from '#ui/components/base/buttons' + import { useFormatBytes, useFormatNumber } from '../../composables' import type { PopupNotificationButton, PopupNotificationProgressType } from '../../providers' import { truncatedTooltip } from '../../utils/truncate' import Avatar from '../base/Avatar.vue' -import ButtonStyled from '../base/ButtonStyled.vue' type NotificationToastType = | 'friend-request' diff --git a/packages/ui/src/components/page/NormalPage.vue b/packages/ui/src/components/page/NormalPage.vue index ce3f0e1689..3acc18993e 100644 --- a/packages/ui/src/components/page/NormalPage.vue +++ b/packages/ui/src/components/page/NormalPage.vue @@ -3,6 +3,7 @@ import { injectPageContext } from '@modrinth/ui' defineProps<{ sidebar?: 'right' | 'left' + fullWidth?: boolean }>() const { hierarchicalSidebarAvailable } = injectPageContext() @@ -12,6 +13,7 @@ const { hierarchicalSidebarAvailable } = injectPageContext() :class="{ 'ui-normal-page--sidebar-left': sidebar === 'left' && !hierarchicalSidebarAvailable, 'ui-normal-page--sidebar-right': sidebar === 'right' && !hierarchicalSidebarAvailable, + 'ui-normal-page--full-width': fullWidth, }" >
@@ -46,6 +48,10 @@ const { hierarchicalSidebarAvailable } = injectPageContext() / 100%; } +.ui-normal-page--full-width { + width: calc(100% - 2rem); +} + @media (width >= 64rem) { .ui-normal-page--sidebar-left { grid-template: diff --git a/packages/ui/src/components/project/ProjectPageVersions.vue b/packages/ui/src/components/project/ProjectPageVersions.vue index 012fea3115..0dfffebdba 100644 --- a/packages/ui/src/components/project/ProjectPageVersions.vue +++ b/packages/ui/src/components/project/ProjectPageVersions.vue @@ -9,9 +9,14 @@ @update:query="updateQuery" /> - - - +
- - - + + + - - diff --git a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue index 3d943bd823..21275870a7 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/installation.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/installation.vue @@ -22,17 +22,16 @@ formatMessage(messages.resetServerTitle) }}
- - - +
{{ formatMessage(messages.resetServerDescription) }} @@ -59,17 +58,16 @@ {{ formatMessage(messages.supportOptionsTitle) }}
- - - +
@@ -79,7 +77,6 @@ import type { Archon } from '@modrinth/api-client' import { RotateCounterClockwiseIcon } from '@modrinth/assets' import { - ButtonStyled, commonMessages, ConfirmModal, defineMessages, @@ -103,6 +100,7 @@ import { import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, ref, useTemplateRef, watch } from 'vue' +import { Button } from '#ui/components/base/buttons' import { injectFilePicker } from '#ui/providers/file-picker' const debug = useDebugLogger('LoaderPage') diff --git a/packages/ui/src/layouts/shared/server-settings/pages/network.vue b/packages/ui/src/layouts/shared/server-settings/pages/network.vue index dd8091b289..9a91d08000 100644 --- a/packages/ui/src/layouts/shared/server-settings/pages/network.vue +++ b/packages/ui/src/layouts/shared/server-settings/pages/network.vue @@ -16,18 +16,16 @@ placeholder="e.g. Secondary allocation" />
- - - - - - + +
@@ -61,9 +59,14 @@ allocationsError?.message ?? 'Unknown error' }}

- - - + @@ -83,16 +86,16 @@ placeholder="e.g. Secondary allocation" /> - - - + @@ -105,30 +108,33 @@ @@ -153,16 +159,14 @@ :placeholder="exampleDomain" /> - - - +
@@ -223,8 +227,9 @@ import { import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, nextTick, ref } from 'vue' -import { ButtonStyled, ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components' +import { ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components' import type { TableColumn } from '#ui/components/base' +import { Button, IconButton } from '#ui/components/base/buttons' import { useServerPermissions } from '#ui/composables/server-permissions' import { injectModrinthClient, diff --git a/packages/ui/src/layouts/shared/user-profile/layout.vue b/packages/ui/src/layouts/shared/user-profile/layout.vue index 9c3b008a6b..608994520e 100644 --- a/packages/ui/src/layouts/shared/user-profile/layout.vue +++ b/packages/ui/src/layouts/shared/user-profile/layout.vue @@ -13,19 +13,26 @@ @@ -42,28 +49,26 @@ :placeholder="formatMessage(messages.selectRolePlaceholder)" />
- - - - - - + +
@@ -192,7 +197,7 @@ - + @@ -56,30 +54,33 @@
- - - - - - - - - - + + + +
@@ -135,9 +136,8 @@ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue' import { RouterLink } from 'vue-router' import Avatar from '#ui/components/base/Avatar.vue' -import ButtonStyled from '#ui/components/base/ButtonStyled.vue' +import { Button, FileButton } from '#ui/components/base/buttons' import EmptyState from '#ui/components/base/EmptyState.vue' -import FileInput from '#ui/components/base/FileInput.vue' import IntlFormatted from '#ui/components/base/IntlFormatted.vue' import StyledInput from '#ui/components/base/StyledInput.vue' import { defineMessages, useVIntl } from '#ui/composables' diff --git a/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue b/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue index 6d2f4f5d8c..240744fa31 100644 --- a/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue +++ b/packages/ui/src/layouts/wrapped/AccountSocialSettings.vue @@ -15,12 +15,10 @@ @@ -112,11 +110,9 @@
{{ formatMessage(messages.loadError) }} - - - +
{{ formatMessage(messages.noBlockedUsers) }} @@ -145,25 +141,24 @@
@@ -195,7 +190,7 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, ref } from 'vue' import Avatar from '#ui/components/base/Avatar.vue' -import ButtonStyled from '#ui/components/base/ButtonStyled.vue' +import { Button } from '#ui/components/base/buttons' import Chips from '#ui/components/base/Chips.vue' import EmptyState from '#ui/components/base/EmptyState.vue' import Table, { type TableColumn } from '#ui/components/base/Table.vue' diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue index 96cc63944a..b85f8d8fe3 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/access/access.vue @@ -14,23 +14,25 @@ v-model="roleFilter" :options="roleFilterOptions" :display-value="selectedRoleFilterLabel" - trigger-class="min-w-[225px] !h-10 !min-h-10 !py-0" + trigger-size="lg" + trigger-class="min-w-[225px]" > - - - + @@ -113,7 +115,7 @@ import { FilterIcon, SearchIcon, UserPlusIcon } from '@modrinth/assets' import { useQuery, useQueryClient } from '@tanstack/vue-query' import { computed, ref, watch } from 'vue' -import ButtonStyled from '#ui/components/base/ButtonStyled.vue' +import { Button } from '#ui/components/base/buttons' import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue' import DropdownFilterBar from '#ui/components/base/DropdownFilterBar.vue' import StyledInput from '#ui/components/base/StyledInput.vue' diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue index bcdebf6392..f231ba7956 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/[id]/onboarding.vue @@ -42,22 +42,22 @@
- - - - - - + +
{{ error.message }}

- - - + @@ -71,16 +71,16 @@ - - - +
@@ -95,17 +95,17 @@ :description="formatMessage(messages.emptyDescription)" >
@@ -198,30 +196,30 @@ }}
- - - +
- - - +
@@ -273,7 +271,7 @@ import type { Component } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' import { useRoute } from 'vue-router' -import ButtonStyled from '#ui/components/base/ButtonStyled.vue' +import { Button } from '#ui/components/base/buttons' import Checkbox from '#ui/components/base/Checkbox.vue' import EmptyState from '#ui/components/base/EmptyState.vue' import FilterPills, { type FilterPillOption } from '#ui/components/base/FilterPills.vue' diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index d22ac2dac4..f85d23337c 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -75,14 +75,17 @@
- - {{ - formatMessage(messages.contactSupportButton) - }} - - - - + {{ formatMessage(messages.contactSupportButton) }} + @@ -106,12 +109,10 @@ :placeholder="formatMessage(messages.searchPlaceholder, { count: filteredData.length })" wrapper-class="w-full md:w-72" /> - - - + @@ -231,8 +232,6 @@ import type { Archon, Labrinth } from '@modrinth/api-client' import { HammerIcon, LoaderCircleIcon, PlusIcon, SearchIcon } from '@modrinth/assets' import { - AutoLink, - ButtonStyled, CopyCode, defineMessages, injectAuth, @@ -255,6 +254,7 @@ import type Stripe from 'stripe' import { type ComponentPublicInstance, computed, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import { Button, ButtonLink } from '#ui/components/base/buttons' import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue' import type { ServerListingOwner } from '#ui/components/servers/access' import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue' diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue index c781570242..dbf56d1b69 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/root.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/root.vue @@ -181,31 +181,30 @@ :auto-hide="false" placement="bottom-end" > - - - + + + - - - - + + @@ -284,13 +283,11 @@ If you're stuck, please contact Modrinth Support with the information below: - - - +
An internal error occurred while installing your server. Don't fret — try @@ -310,25 +307,23 @@ v-if="errorTitle === 'Installation error'" class="mt-2 flex flex-col gap-4 sm:flex-row" > - - - - - - - - - + + +
@@ -430,7 +425,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router' import Avatar from '#ui/components/base/Avatar.vue' -import ButtonStyled from '#ui/components/base/ButtonStyled.vue' +import { Button, IconButton, TeleportOverflowMenu } from '#ui/components/base/buttons' import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue' import NavTabs from '#ui/components/base/NavTabs.vue' import PageHeader from '#ui/components/base/page-header/index.vue' @@ -438,7 +433,6 @@ import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.v import PageHeaderMetadataItem from '#ui/components/base/page-header/metadata/page-header-metadata-item.vue' import PageHeaderActions from '#ui/components/base/page-header/page-header-actions.vue' import ServerNotice from '#ui/components/base/ServerNotice.vue' -import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue' import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue' import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue' import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue' diff --git a/packages/ui/src/stories/add-stories.md b/packages/ui/src/stories/add-stories.md index 19f83c3ba9..2132d4f14c 100644 --- a/packages/ui/src/stories/add-stories.md +++ b/packages/ui/src/stories/add-stories.md @@ -153,16 +153,14 @@ For components that need user interaction to show: ```typescript export const Default: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { Button, NewModal }, setup() { const modalRef = ref | null>(null) return { modalRef } }, template: /* html */ `
- - - +

Modal content

@@ -199,10 +197,10 @@ Components should use relative imports, not the package alias: ```typescript // ❌ BAD - Causes circular dependency in Storybook -import { ButtonStyled } from '@modrinth/ui' +import { Button } from '@modrinth/ui' // ✅ GOOD - Use relative imports -import ButtonStyled from '../base/ButtonStyled.vue' +import Button from '../components/base/buttons/Button.vue' ``` ### 2. Object/Array Prop Defaults Must Be Factory Functions diff --git a/packages/ui/src/stories/base/Admonition.stories.ts b/packages/ui/src/stories/base/Admonition.stories.ts index 413e2a1b6d..bcd6894084 100644 --- a/packages/ui/src/stories/base/Admonition.stories.ts +++ b/packages/ui/src/stories/base/Admonition.stories.ts @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import { ref } from 'vue' import Admonition from '../../components/base/Admonition.vue' -import ButtonStyled from '../../components/base/ButtonStyled.vue' +import { Button } from '../../components/base/buttons' const meta = { title: 'Base/Admonition', @@ -78,7 +78,7 @@ export const HeaderWithTimestamp: Story = { export const WithTopRightActions: Story = { render: () => ({ - components: { Admonition, ButtonStyled }, + components: { Admonition, Button }, template: /*html*/ `
Uploading server files... Something went wrong while extracting the archive. @@ -115,7 +111,7 @@ export const WithTopRightActions: Story = { export const WithProgressBar: Story = { render: () => ({ - components: { Admonition, ButtonStyled }, + components: { Admonition, Button }, template: /*html*/ `
128 KB / 1.2 MB (45%) 24 MB extracted — config/settings.yml ({ - components: { Button }, - setup() { - return { args } - }, - template: /*html*/ ` - - `, - }), -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = {} - -export const Primary: Story = { - args: { - color: 'primary', - }, -} - -export const Danger: Story = { - args: { - color: 'danger', - }, -} - -export const AllColors: Story = { - render: () => ({ - components: { Button }, - template: /*html*/ ` -
- - - - - - - - -
- `, - }), -} - -export const Large: Story = { - args: { - large: true, - }, -} - -export const Outline: Story = { - args: { - outline: true, - }, -} - -export const Transparent: Story = { - args: { - transparent: true, - }, -} - -export const Disabled: Story = { - args: { - disabled: true, - }, -} - -export const AsLink: Story = { - args: { - link: 'https://modrinth.com', - external: true, - }, -} diff --git a/packages/ui/src/stories/base/ButtonStyled.stories.ts b/packages/ui/src/stories/base/ButtonStyled.stories.ts deleted file mode 100644 index 852ceab118..0000000000 --- a/packages/ui/src/stories/base/ButtonStyled.stories.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { DownloadIcon, HeartIcon, SettingsIcon } from '@modrinth/assets' -import type { Meta, StoryObj } from '@storybook/vue3-vite' - -import ButtonStyled from '../../components/base/ButtonStyled.vue' - -const colors = ['standard', 'brand', 'red', 'orange', 'green', 'blue', 'purple'] as const -const types = [ - 'standard', - 'outlined', - 'transparent', - 'highlight', - 'highlight-colored-text', - 'chip', -] as const -const sizes = ['small', 'standard', 'large'] as const - -const meta = { - title: 'Base/ButtonStyled', - component: ButtonStyled, - argTypes: { - color: { - control: 'select', - options: [...colors, 'medal-promo'], - }, - size: { - control: 'select', - options: [...sizes], - }, - type: { - control: 'select', - options: [...types], - }, - circular: { control: 'boolean' }, - colorFill: { - control: 'select', - options: ['auto', 'background', 'text', 'none'], - }, - hoverColorFill: { - control: 'select', - options: ['auto', 'background', 'text', 'none'], - }, - highlighted: { control: 'boolean' }, - highlightedStyle: { - control: 'select', - options: ['main-nav-primary', 'main-nav-secondary'], - }, - }, - args: { - color: 'standard', - size: 'standard', - type: 'standard', - circular: false, - colorFill: 'auto', - hoverColorFill: 'auto', - highlighted: false, - highlightedStyle: 'main-nav-primary', - }, - render: (args) => ({ - components: { ButtonStyled, DownloadIcon }, - setup() { - return { args } - }, - template: /*html*/ ` - - - - `, - }), -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - type: 'standard', - }, -} - -export const AllVariants: Story = { - render: () => ({ - components: { ButtonStyled }, - setup() { - return { colors, types } - }, - template: /*html*/ ` -
- - - - - - - - - - - - - -
Color / Type{{ type }}
{{ color }} - - - -
-
- `, - }), -} - -export const AllVariantsHighlighted: Story = { - render: () => ({ - components: { ButtonStyled }, - setup() { - return { colors, types } - }, - template: /*html*/ ` -
- - - - - - - - - - - - - -
Color / Type{{ type }}
{{ color }} - - - -
-
- `, - }), -} - -export const Sizes: Story = { - render: () => ({ - components: { ButtonStyled }, - setup() { - return { sizes, types } - }, - template: /*html*/ ` -
- - - - - - - - - - - - - -
Size / Type{{ type }}
{{ size }} - - - -
-
- `, - }), -} - -export const WithIcons: Story = { - render: () => ({ - components: { ButtonStyled, DownloadIcon, HeartIcon, SettingsIcon }, - setup() { - return { types } - }, - template: /*html*/ ` -
- - - - - - - - - - - - - - - - - -
Variant{{ type }}
Icon + text - - - -
Icon only - - - -
-
- `, - }), -} - -export const Disabled: Story = { - render: () => ({ - components: { ButtonStyled }, - setup() { - return { types } - }, - template: /*html*/ ` -
- - - -
- `, - }), -} diff --git a/packages/ui/src/stories/base/EmptyState.stories.ts b/packages/ui/src/stories/base/EmptyState.stories.ts index f698148b9b..8fa5be7f23 100644 --- a/packages/ui/src/stories/base/EmptyState.stories.ts +++ b/packages/ui/src/stories/base/EmptyState.stories.ts @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' -import ButtonStyled from '../../components/base/ButtonStyled.vue' +import { Button } from '../../components/base/buttons' import EmptyState from '../../components/base/EmptyState.vue' const meta = { @@ -42,7 +42,7 @@ export const Default: Story = { export const WithActions: StoryObj = { render: () => ({ - components: { EmptyState, ButtonStyled }, + components: { EmptyState, Button }, template: /*html*/ ` `, diff --git a/packages/ui/src/stories/base/JoinedButtons.stories.ts b/packages/ui/src/stories/base/JoinedButtons.stories.ts deleted file mode 100644 index c007e3631d..0000000000 --- a/packages/ui/src/stories/base/JoinedButtons.stories.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { PlayIcon, SlashIcon, StopCircleIcon, UpdatedIcon } from '@modrinth/assets' -import type { Meta, StoryObj } from '@storybook/vue3-vite' - -import JoinedButtons from '../../components/base/JoinedButtons.vue' - -const meta = { - title: 'Base/JoinedButtons', - component: JoinedButtons, - argTypes: { - color: { - control: 'select', - options: ['standard', 'brand', 'red', 'orange', 'green', 'blue', 'purple'], - }, - size: { - control: 'select', - options: ['small', 'standard', 'large'], - }, - disabled: { control: 'boolean' }, - primaryDisabled: { control: 'boolean' }, - dropdownDisabled: { control: 'boolean' }, - primaryMuted: { control: 'boolean' }, - }, -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Start: Story = { - args: { - color: 'brand', - size: 'large', - actions: [ - { - id: 'start', - label: 'Start', - icon: PlayIcon, - action: () => console.log('Start'), - }, - ], - }, -} - -export const StopWithKill: Story = { - args: { - color: 'red', - size: 'large', - actions: [ - { - id: 'stop', - label: 'Stop', - icon: StopCircleIcon, - action: () => console.log('Stop'), - }, - { - id: 'kill_server', - label: 'Kill server', - icon: SlashIcon, - action: () => console.log('Kill'), - }, - ], - }, -} - -export const Stopping: Story = { - args: { - color: 'red', - size: 'large', - primaryDisabled: true, - primaryMuted: true, - actions: [ - { - id: 'stop', - label: 'Stopping', - icon: StopCircleIcon, - action: () => console.log('Stop'), - }, - { - id: 'kill_server', - label: 'Kill server', - icon: SlashIcon, - action: () => console.log('Kill'), - }, - ], - }, -} - -export const Restart: Story = { - args: { - color: 'orange', - size: 'large', - actions: [ - { - id: 'restart', - label: 'Restart', - icon: UpdatedIcon, - action: () => console.log('Restart'), - }, - ], - }, -} - -export const Disabled: Story = { - args: { - color: 'red', - size: 'large', - disabled: true, - actions: [ - { - id: 'stop', - label: 'Stop', - icon: StopCircleIcon, - action: () => console.log('Stop'), - }, - { - id: 'kill_server', - label: 'Kill server', - icon: SlashIcon, - action: () => console.log('Kill'), - }, - ], - }, -} diff --git a/packages/ui/src/stories/base/OverflowMenu.stories.ts b/packages/ui/src/stories/base/OverflowMenu.stories.ts deleted file mode 100644 index 0adec75cf2..0000000000 --- a/packages/ui/src/stories/base/OverflowMenu.stories.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { MoreHorizontalIcon } from '@modrinth/assets' -import type { Meta, StoryObj } from '@storybook/vue3-vite' - -import ButtonStyled from '../../components/base/ButtonStyled.vue' -import OverflowMenu from '../../components/base/OverflowMenu.vue' - -const meta = { - title: 'Base/OverflowMenu', - component: OverflowMenu, - render: (args) => ({ - components: { OverflowMenu, MoreHorizontalIcon, ButtonStyled }, - setup() { - return { args } - }, - template: /*html*/ ` - - - - - - - - - `, - }), -} satisfies Meta - -export default meta -type Story = StoryObj - -export const Default: Story = { - args: { - options: [ - { id: 'edit', action: () => console.log('Edit clicked') }, - { id: 'share', action: () => console.log('Share clicked') }, - { divider: true }, - { id: 'delete', action: () => console.log('Delete clicked'), color: 'danger' }, - ], - }, -} - -export const WithDifferentPlacements: StoryObj = { - render: () => ({ - components: { OverflowMenu, MoreHorizontalIcon, ButtonStyled }, - template: /*html*/ ` -
-
- bottom-end (default) - - - - - - - -
-
- bottom-start - - - - - - - -
-
- `, - }), -} diff --git a/packages/ui/src/stories/base/PageHeader.stories.ts b/packages/ui/src/stories/base/PageHeader.stories.ts index 73ad628820..d2e569ae6b 100644 --- a/packages/ui/src/stories/base/PageHeader.stories.ts +++ b/packages/ui/src/stories/base/PageHeader.stories.ts @@ -20,9 +20,13 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import AutoLink from '../../components/base/AutoLink.vue' import Avatar from '../../components/base/Avatar.vue' -import ButtonStyled from '../../components/base/ButtonStyled.vue' +import { + Button, + IconButton, + SplitButton, + TeleportOverflowMenu, +} from '../../components/base/buttons' import FormattedTag from '../../components/base/FormattedTag.vue' -import JoinedButtons from '../../components/base/JoinedButtons.vue' import PageHeader from '../../components/base/page-header/index.vue' import PageHeaderMetadata from '../../components/base/page-header/metadata/index.vue' import PageHeaderMetadataItem from '../../components/base/page-header/metadata/page-header-metadata-item.vue' @@ -32,7 +36,6 @@ import PageHeaderMetadataTimeItem from '../../components/base/page-header/metada import PageHeaderActions from '../../components/base/page-header/page-header-actions.vue' import PageHeaderBadgeItem from '../../components/base/page-header/page-header-badge-item.vue' import TagItem from '../../components/base/TagItem.vue' -import TeleportOverflowMenu from '../../components/base/TeleportOverflowMenu.vue' import LoaderIcon from '../../components/servers/icons/LoaderIcon.vue' import ServerIcon from '../../components/servers/icons/ServerIcon.vue' @@ -87,9 +90,10 @@ const pageHeaderIcons = { const pageHeaderComponents = { AutoLink, Avatar, - ButtonStyled, + Button, FormattedTag, - JoinedButtons, + IconButton, + SplitButton, PageHeader, PageHeaderActions, PageHeaderBadgeItem, @@ -111,7 +115,7 @@ const meta = { }, decorators: [ (story) => ({ - components: { story }, + components: { story, TeleportOverflowMenu }, template: '
', }), ], @@ -157,17 +161,13 @@ export const ProjectHeader: Story = { @@ -207,12 +207,10 @@ export const CreatorHeader: Story = { @@ -222,10 +220,7 @@ export const CreatorHeader: Story = { export const AppInstanceHeader: Story = { render: () => ({ - components: { - ...pageHeaderComponents, - LoaderIcon, - }, + components: { ...pageHeaderComponents, LoaderIcon, TeleportOverflowMenu }, setup() { return { ...pageHeaderIcons, @@ -252,22 +247,16 @@ export const AppInstanceHeader: Story = { @@ -277,10 +266,7 @@ export const AppInstanceHeader: Story = { export const BrowseHeader: Story = { render: () => ({ - components: { - ...pageHeaderComponents, - LoaderIcon, - }, + components: { ...pageHeaderComponents, LoaderIcon, TeleportOverflowMenu }, setup() { return { ...pageHeaderIcons, @@ -291,11 +277,9 @@ export const BrowseHeader: Story = { template: ` @@ -317,10 +301,7 @@ export const BrowseHeader: Story = { export const ServerPanelRootHeader: Story = { render: () => ({ - components: { - ...pageHeaderComponents, - ServerIcon, - }, + components: { ...pageHeaderComponents, ServerIcon, TeleportOverflowMenu }, setup() { return { ...pageHeaderIcons, @@ -346,17 +327,13 @@ export const ServerPanelRootHeader: Story = { @@ -366,10 +343,7 @@ export const ServerPanelRootHeader: Story = { export const ServerPanelInstanceHeader: Story = { render: () => ({ - components: { - ...pageHeaderComponents, - LoaderIcon, - }, + components: { ...pageHeaderComponents, LoaderIcon, TeleportOverflowMenu }, setup() { return { ...pageHeaderIcons, @@ -381,11 +355,9 @@ export const ServerPanelInstanceHeader: Story = { template: ` diff --git a/packages/ui/src/stories/base/Table.stories.ts b/packages/ui/src/stories/base/Table.stories.ts index 157c1ffbcb..6448cbe7bd 100644 --- a/packages/ui/src/stories/base/Table.stories.ts +++ b/packages/ui/src/stories/base/Table.stories.ts @@ -3,8 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import { computed, ref } from 'vue' import Badge from '../../components/base/Badge.vue' -import ButtonStyled from '../../components/base/ButtonStyled.vue' -import OverflowMenu from '../../components/base/OverflowMenu.vue' +import { Button, TeleportOverflowMenu } from '../../components/base/buttons' import Table from '../../components/base/Table.vue' interface User { @@ -54,7 +53,7 @@ export default meta export const Default: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -74,7 +73,7 @@ export const Default: StoryObj = { export const HorizontalOverflow: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -103,7 +102,7 @@ export const HorizontalOverflow: StoryObj = { export const CustomClasses: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name', cellClass: '!overflow-visible py-3' }, @@ -129,7 +128,7 @@ export const CustomClasses: StoryObj = { export const WithSelection: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -160,7 +159,7 @@ export const WithSelection: StoryObj = { export const WithSelectionData: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -193,7 +192,7 @@ export const WithSelectionData: StoryObj = { export const WithSelectionIds: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -233,7 +232,7 @@ export const WithSorting: StoryObj = { }, }, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name', enableSorting: true }, @@ -269,7 +268,7 @@ export const WithSorting: StoryObj = { export const WithColumnAlignment: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name', align: 'left' as const }, @@ -289,7 +288,7 @@ export const WithColumnAlignment: StoryObj = { export const WithCustomCellSlots: StoryObj = { args: {}, render: () => ({ - components: { Table, Badge }, + components: { Table, Badge, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -335,7 +334,7 @@ export const WithCustomCellSlots: StoryObj = { export const WithCustomHeaderSlots: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -365,7 +364,7 @@ export const WithCustomHeaderSlots: StoryObj = { export const WithHeaderSlot: StoryObj = { args: {}, render: () => ({ - components: { Table, ButtonStyled }, + components: { Table, Button, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -383,9 +382,7 @@ export const WithHeaderSlot: StoryObj = {
Team Members
- - - +
@@ -397,7 +394,7 @@ export const WithHeaderSlot: StoryObj = { export const WithActionsColumn: StoryObj = { args: {}, render: () => ({ - components: { Table, ButtonStyled, EditIcon, TrashIcon }, + components: { Table, EditIcon, TrashIcon, Button, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -421,18 +418,14 @@ export const WithActionsColumn: StoryObj = {
@@ -443,7 +436,7 @@ export const WithActionsColumn: StoryObj = { export const WithLocalizedActionsColumn: StoryObj = { args: {}, render: () => ({ - components: { Table, ButtonStyled, EditIcon, TrashIcon }, + components: { Table, EditIcon, TrashIcon, Button, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Nombre' }, @@ -467,18 +460,14 @@ export const WithLocalizedActionsColumn: StoryObj = {
@@ -489,7 +478,7 @@ export const WithLocalizedActionsColumn: StoryObj = { export const FullFeatured: StoryObj = { args: {}, render: () => ({ - components: { Table, Badge, ButtonStyled, EditIcon, TrashIcon }, + components: { Table, Badge, EditIcon, TrashIcon, Button, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name', enableSorting: true }, @@ -565,18 +554,14 @@ export const FullFeatured: StoryObj = { @@ -592,7 +577,7 @@ export const FullFeatured: StoryObj = { export const VirtualizedLargeData: StoryObj = { args: {}, render: () => ({ - components: { Table, Badge }, + components: { Table, Badge, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name', enableSorting: true }, @@ -710,7 +695,15 @@ export const VirtualizedLargeData: StoryObj = { export const WithOverflowMenu: StoryObj = { args: {}, render: () => ({ - components: { Table, Badge, ButtonStyled, OverflowMenu, MoreVerticalIcon, EditIcon, TrashIcon }, + components: { + Table, + Badge, + MoreVerticalIcon, + EditIcon, + TrashIcon, + Button, + TeleportOverflowMenu, + }, setup() { const columns = [ { key: 'name', label: 'Name' }, @@ -737,17 +730,19 @@ export const WithOverflowMenu: StoryObj = { const getMenuOptions = (row: User) => [ { id: 'edit', + label: 'Edit', action: () => alert(`Edit user: ${row.name}`), }, { id: 'duplicate', + label: 'Duplicate', action: () => alert(`Duplicate user: ${row.name}`), }, - { divider: true }, + { type: 'divider' }, { id: 'delete', - color: 'red' as const, - hoverFilled: true, + label: 'Delete', + tone: 'red', action: () => alert(`Delete user: ${row.name}`), }, ] @@ -766,26 +761,23 @@ export const WithOverflowMenu: StoryObj = { @@ -796,7 +788,7 @@ export const WithOverflowMenu: StoryObj = { export const EmptyState: StoryObj = { args: {}, render: () => ({ - components: { Table }, + components: { Table, TeleportOverflowMenu }, setup() { const columns = [ { key: 'name', label: 'Name' }, diff --git a/packages/ui/src/stories/buttons/Button.stories.ts b/packages/ui/src/stories/buttons/Button.stories.ts new file mode 100644 index 0000000000..85bfe95332 --- /dev/null +++ b/packages/ui/src/stories/buttons/Button.stories.ts @@ -0,0 +1,213 @@ +import { DownloadIcon, ExternalIcon, HeartIcon, SettingsIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Button from '../../components/base/buttons/Button.vue' +import ButtonLink from '../../components/base/buttons/ButtonLink.vue' +import IconButton from '../../components/base/buttons/IconButton.vue' + +const types = ['base', 'colored', 'outlined', 'quiet'] as const +const sizes = ['sm', 'md', 'lg', 'xl'] as const +const colors = ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'medal_promotion'] as const +const sizeColumns = [ + { value: 'sm', label: 'Small' }, + { value: 'md', label: 'Medium' }, + { value: 'lg', label: 'Large' }, + { value: 'xl', label: 'Extra large' }, +] as const +const typeRows = [ + { label: 'Base', type: 'base' }, + { label: 'Outlined', type: 'outlined' }, + { label: 'Quiet', type: 'quiet' }, + ...colors.map((color) => ({ + label: `Colored / ${color.charAt(0).toUpperCase()}${color.slice(1)}`, + type: 'colored' as const, + color, + })), + ...colors.map((color) => ({ + label: `Outlined / ${color.charAt(0).toUpperCase()}${color.slice(1)}`, + type: 'outlined' as const, + color, + })), + ...colors.map((color) => ({ + label: `Quiet / ${color.charAt(0).toUpperCase()}${color.slice(1)}`, + type: 'quiet' as const, + color, + })), +] + +const meta = { + title: 'Buttons/Button', + component: Button, + argTypes: { + type: { + control: 'select', + options: types, + }, + size: { + control: 'select', + options: sizes, + }, + color: { + control: 'select', + options: colors, + }, + nativeType: { + control: 'select', + options: ['button', 'submit', 'reset'], + }, + disabled: { control: 'boolean' }, + loading: { control: 'boolean' }, + }, + args: { + type: 'base', + size: 'md', + color: 'brand', + nativeType: 'button', + disabled: false, + loading: false, + }, + render: (args) => ({ + components: { Button, DownloadIcon }, + setup() { + return { args } + }, + template: /*html*/ ` + + `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Playground: Story = {} + +export const AllTypes: Story = { + render: () => ({ + components: { Button, DownloadIcon }, + setup() { + return { sizeColumns, typeRows } + }, + template: /*html*/ ` +
+
+
+ {{ size.label }} +
+ + +
+ `, + }), +} + +export const Quiet: Story = { + render: () => ({ + components: { Button, DownloadIcon, IconButton, SettingsIcon }, + template: /*html*/ ` +
+ + + +
+ `, + }), +} + +export const Sizes: Story = { + render: () => ({ + components: { Button, DownloadIcon, IconButton }, + setup() { + return { sizes } + }, + template: /*html*/ ` +
+ +
+ `, + }), +} + +export const Colors: Story = { + render: () => ({ + components: { Button }, + setup() { + return { colors } + }, + template: /*html*/ ` +
+ +
+ `, + }), +} + +export const Content: Story = { + render: () => ({ + components: { Button, DownloadIcon, SettingsIcon }, + template: /*html*/ ` +
+ + + + + +
+ `, + }), +} + +export const InteractionStates: Story = { + render: () => ({ + components: { Button }, + template: /*html*/ ` +
+ + + + + + + +
+ `, + }), +} + +export const LinksAndIconButton: Story = { + render: () => ({ + components: { ButtonLink, ExternalIcon, HeartIcon, IconButton }, + template: /*html*/ ` +
+ Internal link + + Modrinth + + Disabled link + + + + +
+ `, + }), +} diff --git a/packages/ui/src/stories/buttons/ButtonGroup.stories.ts b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts new file mode 100644 index 0000000000..3f234148c4 --- /dev/null +++ b/packages/ui/src/stories/buttons/ButtonGroup.stories.ts @@ -0,0 +1,82 @@ +import { PlayIcon, SettingsIcon, StopCircleIcon, TrashIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Button from '../../components/base/buttons/Button.vue' +import ButtonGroup from '../../components/base/buttons/ButtonGroup.vue' +import SplitButton from '../../components/base/buttons/SplitButton.vue' +import type { OverflowMenuOption } from '../../components/base/buttons/types' + +const splitOptions: OverflowMenuOption[] = [ + { + id: 'settings', + label: 'Server settings', + icon: SettingsIcon, + action: () => undefined, + }, + { type: 'divider' }, + { + id: 'delete', + label: 'Delete server', + icon: TrashIcon, + tone: 'red', + action: () => undefined, + }, +] + +const meta = { + title: 'Buttons/Button Group', + component: ButtonGroup, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Joined: Story = { + render: () => ({ + components: { Button, ButtonGroup }, + template: /*html*/ ` + + + + + `, + }), +} + +export const Split: Story = { + render: () => ({ + components: { PlayIcon, SplitButton }, + setup() { + return { splitOptions } + }, + template: /*html*/ ` + + Start server + + `, + }), +} + +export const IndependentDisabledStates: Story = { + render: () => ({ + components: { SplitButton, StopCircleIcon }, + setup() { + return { splitOptions } + }, + template: /*html*/ ` +
+ + Primary disabled + + + Menu disabled + +
+ `, + }), +} diff --git a/packages/ui/src/stories/buttons/FileButton.stories.ts b/packages/ui/src/stories/buttons/FileButton.stories.ts new file mode 100644 index 0000000000..2f07b27acc --- /dev/null +++ b/packages/ui/src/stories/buttons/FileButton.stories.ts @@ -0,0 +1,61 @@ +import { UploadIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import FileButton from '../../components/base/buttons/FileButton.vue' + +const meta = { + title: 'Buttons/File Button', + component: FileButton, + argTypes: { + type: { + control: 'select', + options: ['base', 'colored', 'outlined', 'quiet'], + }, + size: { + control: 'select', + options: ['sm', 'md', 'lg', 'xl'], + }, + color: { + control: 'select', + options: ['brand', 'red', 'orange', 'green', 'blue', 'purple', 'medal_promotion'], + }, + }, + args: { + prompt: 'Select file', + type: 'base', + size: 'md', + multiple: false, + disabled: false, + }, + render: (args) => ({ + components: { FileButton, UploadIcon }, + setup() { + return { args } + }, + template: /*html*/ ` + + + + `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const MultipleImages: Story = { + args: { + prompt: 'Select images', + accept: 'image/*', + multiple: true, + type: 'colored', + }, +} + +export const Disabled: Story = { + args: { + disabled: true, + }, +} diff --git a/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts new file mode 100644 index 0000000000..5ed21392f1 --- /dev/null +++ b/packages/ui/src/stories/buttons/TeleportOverflowMenu.stories.ts @@ -0,0 +1,104 @@ +import { + DownloadIcon, + ExternalIcon, + MoreVerticalIcon, + SettingsIcon, + TrashIcon, +} from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import TeleportOverflowMenu from '../../components/base/buttons/TeleportOverflowMenu.vue' +import type { OverflowMenuOption } from '../../components/base/buttons/types' + +const options: OverflowMenuOption[] = [ + { + id: 'download', + label: 'Download', + icon: DownloadIcon, + action: () => undefined, + }, + { + id: 'settings', + label: 'Project settings', + icon: SettingsIcon, + type: 'link', + to: '/settings', + }, + { + id: 'website', + label: 'Open website', + icon: ExternalIcon, + type: 'link', + href: 'https://modrinth.com', + target: '_blank', + }, + { + id: 'unavailable', + label: 'Unavailable action', + disabled: true, + tooltip: 'This action is currently unavailable', + action: () => undefined, + }, + { type: 'divider' }, + { + id: 'delete', + label: 'Delete project', + icon: TrashIcon, + tone: 'red', + action: () => undefined, + }, +] + +const meta = { + title: 'Buttons/Teleport Overflow Menu', + component: TeleportOverflowMenu, + args: { + label: 'More actions', + options, + type: 'base', + size: 'md', + placement: 'bottom-end', + disabled: false, + hoverable: false, + }, + render: (args) => ({ + components: { MoreVerticalIcon, TeleportOverflowMenu }, + setup() { + return { args } + }, + template: /*html*/ ` + + + + `, + }), +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = {} + +export const ColoredTrigger: Story = { + args: { + type: 'colored', + }, +} + +export const OutlinedTrigger: Story = { + args: { + type: 'outlined', + }, +} + +export const QuietTrigger: Story = { + args: { + type: 'quiet', + }, +} + +export const Hoverable: Story = { + args: { + hoverable: true, + }, +} diff --git a/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts new file mode 100644 index 0000000000..d6b183197c --- /dev/null +++ b/packages/ui/src/stories/buttons/TeleportPopoutMenu.stories.ts @@ -0,0 +1,50 @@ +import { SettingsIcon } from '@modrinth/assets' +import type { Meta, StoryObj } from '@storybook/vue3-vite' + +import Button from '../../components/base/buttons/Button.vue' +import TeleportPopoutMenu from '../../components/base/buttons/TeleportPopoutMenu.vue' + +const meta = { + title: 'Buttons/Teleport Popout Menu', + component: TeleportPopoutMenu, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const ArbitraryContent: Story = { + render: () => ({ + components: { Button, SettingsIcon, TeleportPopoutMenu }, + template: /*html*/ ` + + + + + + `, + }), +} + +export const IconTrigger: Story = { + render: () => ({ + components: { SettingsIcon, TeleportPopoutMenu }, + template: /*html*/ ` + + + + + `, + }), +} diff --git a/packages/ui/src/stories/instances/ContentCardTable.stories.ts b/packages/ui/src/stories/instances/ContentCardTable.stories.ts index 486a4c909e..e7a90ebbea 100644 --- a/packages/ui/src/stories/instances/ContentCardTable.stories.ts +++ b/packages/ui/src/stories/instances/ContentCardTable.stories.ts @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite' import { fn } from 'storybook/test' import { onMounted, onUnmounted, ref } from 'vue' -import ButtonStyled from '../../components/base/ButtonStyled.vue' +import { Button, IconButton } from '../../components/base/buttons' import ContentCardTable from '../../layouts/shared/content-tab/components/ContentCardTable.vue' import type { ContentCardTableItem } from '../../layouts/shared/content-tab/types' @@ -539,7 +539,7 @@ export const InteractiveActions: Story = { export const WithCustomItemButtons: Story = { render: () => ({ - components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon }, + components: { ContentCardTable, EyeIcon, FolderOpenIcon, DownloadIcon, Button, IconButton }, setup() { return { items: sampleItems } }, @@ -551,23 +551,17 @@ export const WithCustomItemButtons: Story = { @delete="(id) => console.log('Delete', id)" > `, @@ -582,15 +576,13 @@ export const WithEmptyState: Story = { export const WithCustomEmptyState: Story = { render: () => ({ - components: { ContentCardTable, ButtonStyled }, + components: { ContentCardTable, Button, IconButton }, template: /*html*/ ` @@ -781,7 +773,7 @@ export const WithOverflowMenu: Story = { export const BulkActionsDemo: Story = { render: () => ({ - components: { ContentCardTable, ButtonStyled }, + components: { ContentCardTable, Button, IconButton }, setup() { const items = ref([ { ...sodiumItem, enabled: true }, @@ -825,15 +817,9 @@ export const BulkActionsDemo: Story = {
{{ selectedIds.length }} selected
export const InstanceDependency: Story = { render: () => ({ - components: { ButtonStyled, ContentDependencyWarningModal }, + components: { ContentDependencyWarningModal, Button }, setup() { const modalRef = ref | null>(null) const deleted = ref(false) @@ -186,9 +186,7 @@ export const InstanceDependency: Story = { }, template: /* html */ `
- - - +

Dependency deletion confirmed

({ - components: { ButtonStyled, ContentDependencyWarningModal }, + components: { ContentDependencyWarningModal, Button }, setup() { const modalRef = ref | null>(null) const deleted = ref(false) @@ -227,9 +225,7 @@ export const ServerDependency: Story = { }, template: /* html */ `
- - - +

Server dependency deletion confirmed

({ - components: { ButtonStyled, ContentDependencyWarningModal }, + components: { ContentDependencyWarningModal, Button }, setup() { const modalRef = ref | null>(null) const deleted = ref(false) @@ -267,9 +263,7 @@ export const BulkDependencies: Story = { }, template: /* html */ `
- - - +

Bulk dependency deletion confirmed

export const ModExample: Story = { render: (args) => ({ - components: { ContentUpdaterModal, ButtonStyled }, + components: { ContentUpdaterModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -277,9 +277,7 @@ export const ModExample: Story = { }, template: /*html*/ `
- - - + ({ - components: { ContentUpdaterModal, ButtonStyled }, + components: { ContentUpdaterModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -318,9 +316,7 @@ export const ModpackExample: Story = { }, template: /*html*/ `
- - - + ({ - components: { ContentUpdaterModal, ButtonStyled }, + components: { ContentUpdaterModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -355,9 +351,7 @@ export const WithIncompatibleVersions: Story = { }, template: /*html*/ `
- - - + ({ - components: { ContentUpdaterModal, ButtonStyled }, + components: { ContentUpdaterModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -389,9 +383,7 @@ export const AllVersionTypes: Story = { }, template: /*html*/ `
- - - + export const Default: Story = { render: () => ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show(mixedModpackContent) @@ -417,9 +417,7 @@ export const Default: Story = { }, template: /*html*/ `
- - - + ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show(modsOnlyContent) @@ -440,9 +438,7 @@ export const ModsOnly: Story = { }, template: /*html*/ `
- - - +
`, @@ -455,7 +451,7 @@ export const ModsOnly: Story = { export const LoadingState: Story = { render: () => ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => { @@ -469,9 +465,7 @@ export const LoadingState: Story = { }, template: /*html*/ `
- - - + ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show([]) @@ -496,9 +490,7 @@ export const EmptyContent: Story = { }, template: /*html*/ `
- - - +
`, @@ -511,7 +503,7 @@ export const EmptyContent: Story = { export const LargeModpack: Story = { render: () => ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show(largeModpackContent) @@ -519,9 +511,7 @@ export const LargeModpack: Story = { }, template: /*html*/ `
- - - + ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show(mixedModpackContent) @@ -549,9 +539,7 @@ export const SearchDemo: Story = {

Click the button and try searching for "sodium", "shader", or "faithful" to test the search functionality.

- - - +
`, @@ -564,7 +552,7 @@ export const SearchDemo: Story = { export const FilterDemo: Story = { render: () => ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show(mixedModpackContent) @@ -575,9 +563,7 @@ export const FilterDemo: Story = {

Click the button and try the filter chips (Mods, Shaders, Resource Packs) to filter content by type.

- - - +
`, @@ -590,7 +576,7 @@ export const FilterDemo: Story = { export const MixedOwnerTypes: Story = { render: () => ({ - components: { ModpackContentModal, ButtonStyled }, + components: { ModpackContentModal, Button }, setup() { const modalRef = ref | null>(null) // Mix of user and organization owners @@ -608,9 +594,7 @@ export const MixedOwnerTypes: Story = {

Shows content with different owner types: users (circular avatar) and organizations (rounded + icon).

- - - +
`, diff --git a/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts b/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts index 9509d27ac1..ac33eb8886 100644 --- a/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts +++ b/packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts @@ -1,7 +1,7 @@ import type { StoryObj } from '@storybook/vue3-vite' import { ref } from 'vue' -import ButtonStyled from '../../components/base/ButtonStyled.vue' +import { Button } from '../../components/base/buttons' import ConfirmLeaveModal from '../../components/modal/ConfirmLeaveModal.vue' const meta = { @@ -14,7 +14,7 @@ type Story = StoryObj export const Default: Story = { render: () => ({ - components: { ConfirmLeaveModal, ButtonStyled }, + components: { ConfirmLeaveModal, Button }, setup() { const modalRef = ref | null>(null) const result = ref('') @@ -27,9 +27,7 @@ export const Default: Story = { }, template: /* html */ `
- - - +

{{ result }}

@@ -39,7 +37,7 @@ export const Default: Story = { export const CustomMessages: Story = { render: () => ({ - components: { ConfirmLeaveModal, ButtonStyled }, + components: { ConfirmLeaveModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.prompt() @@ -47,9 +45,7 @@ export const CustomMessages: Story = { }, template: /* html */ `
- - - + ({ - components: { ConfirmLeaveModal, ButtonStyled }, + components: { ConfirmLeaveModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.prompt() @@ -73,9 +69,7 @@ export const WarningAdmonition: Story = { }, template: /* html */ `
- - - + export const Default: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -22,9 +22,7 @@ export const Default: Story = { }, template: `
- - - +

This is the modal content.

You can put any content here.

@@ -36,7 +34,7 @@ export const Default: Story = { export const WithActions: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -44,19 +42,13 @@ export const WithActions: Story = { }, template: `
- - - +

Are you sure you want to proceed with this action?

@@ -67,7 +59,7 @@ export const WithActions: Story = { export const DangerFade: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -75,19 +67,13 @@ export const DangerFade: Story = { }, template: `
- - - +

Are you sure you want to delete this item? This action cannot be undone.

@@ -98,7 +84,7 @@ export const DangerFade: Story = { export const WarningFade: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -106,19 +92,13 @@ export const WarningFade: Story = { }, template: `
- - - +

This action may have unintended consequences. Please review before proceeding.

@@ -129,7 +109,7 @@ export const WarningFade: Story = { export const Scrollable: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -137,9 +117,7 @@ export const Scrollable: Story = { }, template: `
- - - +

@@ -148,9 +126,7 @@ export const Scrollable: Story = {

@@ -161,7 +137,7 @@ export const Scrollable: Story = { export const MergedHeader: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -169,9 +145,7 @@ export const MergedHeader: Story = { }, template: `
- - - +

Custom Header Area

@@ -185,7 +159,7 @@ export const MergedHeader: Story = { export const NotClosable: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -193,17 +167,13 @@ export const NotClosable: Story = { }, template: `
- - - +

This modal cannot be closed by clicking outside or pressing escape.

Only the action button can close it.

@@ -214,7 +184,7 @@ export const NotClosable: Story = { export const NoPadding: Story = { render: () => ({ - components: { NewModal, ButtonStyled }, + components: { NewModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => modalRef.value?.show() @@ -222,16 +192,12 @@ export const NoPadding: Story = { }, template: `
- - - +

This modal has no default padding on the content area.

diff --git a/packages/ui/src/stories/modal/ShareModal.stories.ts b/packages/ui/src/stories/modal/ShareModal.stories.ts index 10b07701f4..28c81af2ae 100644 --- a/packages/ui/src/stories/modal/ShareModal.stories.ts +++ b/packages/ui/src/stories/modal/ShareModal.stories.ts @@ -1,7 +1,7 @@ import type { StoryObj } from '@storybook/vue3-vite' import { ref } from 'vue' -import ButtonStyled from '../../components/base/ButtonStyled.vue' +import { Button } from '../../components/base/buttons' import ShareModal from '../../components/modal/ShareModal.vue' const meta = { @@ -20,7 +20,7 @@ export const LinkShare: Story = { link: true, }, render: (args) => ({ - components: { ShareModal, ButtonStyled }, + components: { ShareModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => { @@ -30,9 +30,7 @@ export const LinkShare: Story = { }, template: `
- - - +
`, @@ -47,7 +45,7 @@ export const TextShare: Story = { link: false, }, render: (args) => ({ - components: { ShareModal, ButtonStyled }, + components: { ShareModal, Button }, setup() { const modalRef = ref | null>(null) const openModal = () => { @@ -57,9 +55,7 @@ export const TextShare: Story = { }, template: `
- - - +
`, diff --git a/packages/ui/src/stories/modal/TabbedModal.stories.ts b/packages/ui/src/stories/modal/TabbedModal.stories.ts index aee894f212..5d85e192ef 100644 --- a/packages/ui/src/stories/modal/TabbedModal.stories.ts +++ b/packages/ui/src/stories/modal/TabbedModal.stories.ts @@ -14,7 +14,7 @@ import { import type { StoryObj } from '@storybook/vue3-vite' import { defineComponent, h, ref } from 'vue' -import ButtonStyled from '../../components/base/ButtonStyled.vue' +import { Button } from '../../components/base/buttons' import UnsavedChangesPopup from '../../components/base/UnsavedChangesPopup.vue' import TabbedModal from '../../components/modal/TabbedModal.vue' @@ -42,7 +42,7 @@ export default meta export const Default: StoryObj = { render: () => ({ - components: { TabbedModal, ButtonStyled }, + components: { TabbedModal, Button }, setup() { const modalRef = ref | null>(null) const tabs = [ @@ -66,9 +66,7 @@ export const Default: StoryObj = { }, template: /* html */ `
- - - +
`, @@ -77,7 +75,7 @@ export const Default: StoryObj = { export const WithTitleSlot: StoryObj = { render: () => ({ - components: { TabbedModal, ButtonStyled, SettingsIcon }, + components: { TabbedModal, SettingsIcon, Button }, setup() { const modalRef = ref | null>(null) const tabs = [ @@ -96,9 +94,7 @@ export const WithTitleSlot: StoryObj = { }, template: /* html */ `
- - - + @@ -461,7 +471,12 @@ import { VersionChannelIndicator, VersionFilterControl, } from '@modrinth/ui' -import { formatVersionsForDisplay, type GameVersionTag, type Version } from '@modrinth/utils' +import { + type GameVersionTag, + getVersionGroupsForDisplay, + type Version, + type VersionDisplayGroup, +} from '@modrinth/utils' import { Menu } from 'floating-vue' import { computed, type Ref, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' @@ -622,8 +637,8 @@ function hasNoModLoader(loaders: string[]): boolean { ) } -function getDisplayGameVersions(version: DisplayVersion): string[] { - return formatVersionsForDisplay(version.game_versions, props.gameVersions) +function getDisplayGameVersions(version: DisplayVersion): VersionDisplayGroup[] { + return getVersionGroupsForDisplay(version.game_versions, props.gameVersions) } function getFilterTooltip(filter: string): string { diff --git a/packages/ui/src/components/version/VersionFilterControl.vue b/packages/ui/src/components/version/VersionFilterControl.vue index 5c38c04da4..3554b18795 100644 --- a/packages/ui/src/components/version/VersionFilterControl.vue +++ b/packages/ui/src/components/version/VersionFilterControl.vue @@ -243,31 +243,35 @@ if (selectedGameVersions.value.some((version) => !isReleaseGameVersion(version)) showSnapshots.value = true } -async function toggleFilters(type: FilterType, filters: Filter[]) { - for (const filter of filters) { - await toggleFilter(type, filter, true) +function selectedFiltersOfType(type: FilterType) { + if (type === 'channel') { + return selectedChannels + } else if (type === 'gameVersion') { + return selectedGameVersions + } else { + return selectedPlatforms } +} + +function toggleFilters(type: FilterType, filters: Filter[]) { + const selected = selectedFiltersOfType(type) + const allSelected = filters.every((filter) => selected.value.includes(filter)) + + selected.value = allSelected + ? selected.value.filter((x) => !filters.includes(x)) + : [...selected.value, ...filters.filter((filter) => !selected.value.includes(filter))] updateFilters() } -async function toggleFilter(type: FilterType, filter: Filter, bulk = false) { - if (type === 'channel') { - selectedChannels.value = selectedChannels.value.includes(filter) - ? selectedChannels.value.filter((x) => x !== filter) - : [...selectedChannels.value, filter] - } else if (type === 'gameVersion') { - selectedGameVersions.value = selectedGameVersions.value.includes(filter) - ? selectedGameVersions.value.filter((x) => x !== filter) - : [...selectedGameVersions.value, filter] - } else if (type === 'platform') { - selectedPlatforms.value = selectedPlatforms.value.includes(filter) - ? selectedPlatforms.value.filter((x) => x !== filter) - : [...selectedPlatforms.value, filter] - } - if (!bulk) { - updateFilters() - } +function toggleFilter(type: FilterType, filter: Filter) { + const selected = selectedFiltersOfType(type) + + selected.value = selected.value.includes(filter) + ? selected.value.filter((x) => x !== filter) + : [...selected.value, filter] + + updateFilters() } function updateSelectedGameVersions(versions: string[]) { @@ -302,7 +306,7 @@ function updateShowSnapshots(value: boolean, _event?: MouseEvent) { } } -async function clearFilters() { +function clearFilters() { selectedChannels.value = [] selectedGameVersions.value = [] selectedPlatforms.value = [] diff --git a/packages/utils/projects.ts b/packages/utils/projects.ts index 3176c940a4..36bb49e299 100644 --- a/packages/utils/projects.ts +++ b/packages/utils/projects.ts @@ -66,6 +66,11 @@ export type PlatformTag = { supported_project_types: DisplayProjectType[] } +export type VersionDisplayGroup = { + label: string + versions: string[] +} + export function getVersionsToDisplay(project, allGameVersions: GameVersionTag[]) { return formatVersionsForDisplay(project.game_versions.slice(), allGameVersions) } @@ -74,6 +79,13 @@ export function formatVersionsForDisplay( gameVersions: string[], allGameVersions: GameVersionTag[], ) { + return getVersionGroupsForDisplay(gameVersions, allGameVersions).map((group) => group.label) +} + +export function getVersionGroupsForDisplay( + gameVersions: string[], + allGameVersions: GameVersionTag[], +): VersionDisplayGroup[] { const inputVersions = gameVersions.slice() const allVersions = allGameVersions.slice() @@ -112,26 +124,33 @@ export function formatVersionsForDisplay( ) const projectVersionsGrouped = groupVersions(releaseVersions, true) - const releaseVersionsAsRanges = projectVersionsGrouped.map(({ major, minor }) => { - if (minor.length === 1) { - return formatMinecraftMinorVersion(major, minor[0]) - } + const releaseVersionsAsRanges: VersionDisplayGroup[] = projectVersionsGrouped.map( + ({ major, minor }) => { + const versions = minor.map((minorVersion) => formatMinecraftMinorVersion(major, minorVersion)) - const range = allReleasesGrouped.find((x) => x.major === major) + if (minor.length === 1) { + return { label: versions[0], versions } + } - if (range?.minor.every((value, index) => value === minor[index])) { - return `${major}.x` - } + const range = allReleasesGrouped.find((x) => x.major === major) - return `${formatMinecraftMinorVersion(major, minor[0])}–${formatMinecraftMinorVersion(major, minor[minor.length - 1])}` - }) + if (range?.minor.every((value, index) => value === minor[index])) { + return { label: `${major}.x`, versions } + } + + return { + label: `${formatMinecraftMinorVersion(major, minor[0])}–${formatMinecraftMinorVersion(major, minor[minor.length - 1])}`, + versions, + } + }, + ) const legacyVersionsAsRanges = groupConsecutiveIndices( inputVersions.filter((projVer) => allLegacy.some((gameVer) => gameVer.version === projVer)), allLegacy, ) - let output = [...legacyVersionsAsRanges] + let output: VersionDisplayGroup[] = [...legacyVersionsAsRanges] // show all snapshots if there's no release versions if (releaseVersionsAsRanges.length === 0) { @@ -141,14 +160,14 @@ export function formatVersionsForDisplay( const snapshotVersionsAsRanges = snapshotVersions.length > 3 ? groupConsecutiveIndices(snapshotVersions, allSnapshots) - : snapshotVersions + : snapshotVersions.map((version) => ({ label: version, versions: [version] })) output = [...snapshotVersionsAsRanges, ...output] } else { output = [...releaseVersionsAsRanges, ...output] } if (releaseVersionsAsRanges.length > 0 && latestSnapshot) { - output = [latestSnapshot, ...output] + output = [{ label: latestSnapshot, versions: [latestSnapshot] }, ...output] } return output } @@ -188,7 +207,10 @@ function groupVersions(versions: string[], consecutive = false) { .reverse() } -function groupConsecutiveIndices(versions: string[], referenceList: GameVersionTag[]) { +function groupConsecutiveIndices( + versions: string[], + referenceList: GameVersionTag[], +): VersionDisplayGroup[] { if (!versions || versions.length === 0) { return [] } @@ -202,21 +224,23 @@ function groupConsecutiveIndices(versions: string[], referenceList: GameVersionT .slice() .sort((a, b) => referenceMap.get(a) - referenceMap.get(b)) - const ranges: string[] = [] - let start = sortedList[0] - let previous = sortedList[0] + const ranges: VersionDisplayGroup[] = [] + let rangeStartIndex = 0 - for (let i = 1; i < sortedList.length; i++) { - const current = sortedList[i] - if (referenceMap.get(current) !== referenceMap.get(previous) + 1) { - ranges.push(validateRange(`${previous}–${start}`)) - start = current + for (let i = 1; i <= sortedList.length; i++) { + if ( + i === sortedList.length || + referenceMap.get(sortedList[i]) !== referenceMap.get(sortedList[i - 1]) + 1 + ) { + const members = sortedList.slice(rangeStartIndex, i) + ranges.push({ + label: validateRange(`${members[members.length - 1]}–${members[0]}`), + versions: members, + }) + rangeStartIndex = i } - previous = current } - ranges.push(validateRange(`${previous}–${start}`)) - return ranges } From 36857a167b6ff73a4f84fe08fc9ebf5e369b1a3e Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Tue, 4 Aug 2026 22:24:43 +0100 Subject: [PATCH 057/145] fix: content selection bar not showing in ModpackContentModal (#6950) --- .../shared/content-tab/components/ContentSelectionBar.vue | 8 +++++++- .../content-tab/components/modals/ModpackContentModal.vue | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentSelectionBar.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentSelectionBar.vue index 312ec5ec2c..bf2ec3f9c8 100644 --- a/packages/ui/src/layouts/shared/content-tab/components/ContentSelectionBar.vue +++ b/packages/ui/src/layouts/shared/content-tab/components/ContentSelectionBar.vue @@ -95,6 +95,7 @@ interface Props { ariaLabel?: string getItemId?: (item: ContentItem) => string toggleItems?: ContentItem[] + hideWhenModalOpen?: boolean } const props = withDefaults(defineProps(), { @@ -111,6 +112,7 @@ const props = withDefaults(defineProps(), { ariaLabel: undefined, getItemId: undefined, toggleItems: undefined, + hideWhenModalOpen: true, }) const emit = defineEmits<{ @@ -181,7 +183,11 @@ const bulkProgressMessage = computed(() => { diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 43b1bbefde..884e06f247 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -1625,6 +1625,27 @@ "instance.settings.tabs.hooks.title": { "message": "Game launch hooks" }, + "instance.settings.tabs.hooks.variables.description": { + "message": "Hooks run in the working directory of the instance, with the following variables:" + }, + "instance.settings.tabs.hooks.variables.inst-dir.description": { + "message": "$INST_DIR: The absolute path to the instance's folder" + }, + "instance.settings.tabs.hooks.variables.inst-id.description": { + "message": "$INST_ID: The name of the instance's folder" + }, + "instance.settings.tabs.hooks.variables.inst-java-args.description": { + "message": "$INST_JAVA_ARGS: The JVM Arguments provided to the game" + }, + "instance.settings.tabs.hooks.variables.inst-java.description": { + "message": "$INST_JAVA: The absolute path to the java binary" + }, + "instance.settings.tabs.hooks.variables.inst-mc-dir.description": { + "message": "$INST_MC_DIR: An alias for $INST_DIR" + }, + "instance.settings.tabs.hooks.variables.inst-name.description": { + "message": "$INST_NAME: The name of the instance" + }, "instance.settings.tabs.hooks.wrapper": { "message": "Wrapper" }, diff --git a/apps/app-frontend/src/pages/instance/components/settings-modal/hooks-settings.vue b/apps/app-frontend/src/pages/instance/components/settings-modal/hooks-settings.vue index 6af96227b2..a40a173b8e 100644 --- a/apps/app-frontend/src/pages/instance/components/settings-modal/hooks-settings.vue +++ b/apps/app-frontend/src/pages/instance/components/settings-modal/hooks-settings.vue @@ -56,6 +56,35 @@ const messages = defineMessages({ defaultMessage: 'Hooks allow advanced users to run certain system commands before and after launching the game.', }, + hookVariablesDescription: { + id: 'instance.settings.tabs.hooks.variables.description', + defaultMessage: + 'Hooks run in the working directory of the instance, with the following variables:', + }, + instanceNameDescription: { + id: 'instance.settings.tabs.hooks.variables.inst-name.description', + defaultMessage: '$INST_NAME: The name of the instance', + }, + instanceIdDescription: { + id: 'instance.settings.tabs.hooks.variables.inst-id.description', + defaultMessage: "$INST_ID: The name of the instance's folder", + }, + instanceDirDescription: { + id: 'instance.settings.tabs.hooks.variables.inst-dir.description', + defaultMessage: "$INST_DIR: The absolute path to the instance's folder", + }, + instanceMcDirDescription: { + id: 'instance.settings.tabs.hooks.variables.inst-mc-dir.description', + defaultMessage: '$INST_MC_DIR: An alias for $INST_DIR', + }, + instanceJavaDescription: { + id: 'instance.settings.tabs.hooks.variables.inst-java.description', + defaultMessage: '$INST_JAVA: The absolute path to the java binary', + }, + instanceJavaArgsDescription: { + id: 'instance.settings.tabs.hooks.variables.inst-java-args.description', + defaultMessage: '$INST_JAVA_ARGS: The JVM Arguments provided to the game', + }, customHooks: { id: 'instance.settings.tabs.hooks.custom-hooks', defaultMessage: 'Custom launch hooks', @@ -153,5 +182,17 @@ const messages = defineMessages({

{{ formatMessage(messages.postExitDescription) }}

+ +
+ {{ formatMessage(messages.hookVariablesDescription) }} +
+
    +
  • {{ formatMessage(messages.instanceNameDescription) }}
  • +
  • {{ formatMessage(messages.instanceIdDescription) }}
  • +
  • {{ formatMessage(messages.instanceDirDescription) }}
  • +
  • {{ formatMessage(messages.instanceMcDirDescription) }}
  • +
  • {{ formatMessage(messages.instanceJavaDescription) }}
  • +
  • {{ formatMessage(messages.instanceJavaArgsDescription) }}
  • +
diff --git a/packages/app-lib/src/api/instance/run.rs b/packages/app-lib/src/api/instance/run.rs index e5615ddedc..17de3a88f3 100644 --- a/packages/app-lib/src/api/instance/run.rs +++ b/packages/app-lib/src/api/instance/run.rs @@ -79,15 +79,95 @@ async fn run_credentials( .into()); } - let pre_launch_hooks = context + let pre_launch_hook = context .launch_overrides .hooks .pre_launch .as_ref() .or(settings.hooks.pre_launch.as_ref()) .filter(|hook_command| !hook_command.is_empty()); - if let Some(hook) = pre_launch_hooks { - let mut cmd = shlex::split(hook) + + let java_args = context + .launch_overrides + .extra_launch_args + .clone() + .unwrap_or(settings.extra_launch_args); + + let wrapper = context + .launch_overrides + .hooks + .wrapper + .clone() + .or(settings.hooks.wrapper) + .filter(|hook_command| !hook_command.is_empty()); + + let env_args = context + .launch_overrides + .custom_env_vars + .clone() + .unwrap_or(settings.custom_env_vars); + + let post_exit_hook = context + .launch_overrides + .hooks + .post_exit + .clone() + .or(settings.hooks.post_exit) + .filter(|hook_command| !hook_command.is_empty()); + + let memory = context.launch_overrides.memory.unwrap_or(settings.memory); + let resolution = context + .launch_overrides + .game_resolution + .unwrap_or(settings.game_resolution); + let has_hook_commands = pre_launch_hook.is_some() + || wrapper.is_some() + || post_exit_hook.is_some(); + let full_path = if has_hook_commands { + Some(crate::util::io::canonicalize( + state + .directories + .instances_dir() + .join(&context.instance.path), + )?) + } else { + None + }; + let hook_environment = if has_hook_commands { + let full_path = full_path + .as_ref() + .expect("hooked launches always resolve their instance path"); + let java_version = + crate::launcher::resolve_java_for_launch(&context).await?; + + Some(crate::launcher::hooks::HookEnvironment::from_current_env( + &env_args, + crate::launcher::hooks::HookVariables { + instance_name: context.instance.name.clone(), + instance_id: context.instance.path.clone(), + instance_dir: full_path.to_string_lossy().to_string(), + java_path: java_version.path.clone(), + java_args: crate::launcher::hooks::build_hook_java_args( + &java_args, + memory, + &java_version, + ), + }, + )) + } else { + None + }; + let launch_env_args = hook_environment + .as_ref() + .map_or_else(|| env_args.clone(), |env| env.injected_envs()); + + if let (Some(hook), Some(hook_environment), Some(full_path)) = ( + pre_launch_hook, + hook_environment.as_ref(), + full_path.as_ref(), + ) { + let expanded_hook = hook_environment.expand(hook); + let mut cmd = shlex::split(&expanded_hook) .ok_or_else(|| { crate::ErrorKind::LauncherError(format!( "Invalid pre-launch command: {hook}", @@ -96,17 +176,12 @@ async fn run_credentials( .into_iter(); if let Some(command) = cmd.next() { - let full_path = crate::util::io::canonicalize( - state - .directories - .instances_dir() - .join(&context.instance.path), - )?; let result = Command::new(command) .args(cmd) - .current_dir(&full_path) + .envs(launch_env_args.iter().cloned()) + .current_dir(full_path) .spawn() - .map_err(|e| IOError::with_path(e, &full_path))? + .map_err(|e| IOError::with_path(e, full_path))? .wait() .await .map_err(IOError::from)?; @@ -121,34 +196,19 @@ async fn run_credentials( } } - let java_args = context - .launch_overrides - .extra_launch_args - .clone() - .unwrap_or(settings.extra_launch_args); - let wrapper = context - .launch_overrides - .hooks - .wrapper - .clone() - .or(settings.hooks.wrapper) + let wrapper = wrapper + .map(|hook| { + hook_environment + .as_ref() + .map_or(hook.clone(), |env| env.expand(&hook)) + }) .filter(|hook_command| !hook_command.is_empty()); - let memory = context.launch_overrides.memory.unwrap_or(settings.memory); - let resolution = context - .launch_overrides - .game_resolution - .unwrap_or(settings.game_resolution); - let env_args = context - .launch_overrides - .custom_env_vars - .clone() - .unwrap_or(settings.custom_env_vars); - let post_exit_hook = context - .launch_overrides - .hooks - .post_exit - .clone() - .or(settings.hooks.post_exit) + let post_exit_hook = post_exit_hook + .map(|hook| { + hook_environment + .as_ref() + .map_or(hook.clone(), |env| env.expand(&hook)) + }) .filter(|hook_command| !hook_command.is_empty()); let mut mc_set_options: Vec<(String, String)> = vec![]; @@ -210,7 +270,7 @@ async fn run_credentials( crate::minecraft_skins::flush_pending_skin_change().await?; crate::launcher::launch_minecraft( &java_args, - &env_args, + &launch_env_args, &mc_set_options, &wrapper, &memory, diff --git a/packages/app-lib/src/launcher/hooks.rs b/packages/app-lib/src/launcher/hooks.rs new file mode 100644 index 0000000000..942e2d0420 --- /dev/null +++ b/packages/app-lib/src/launcher/hooks.rs @@ -0,0 +1,165 @@ +use crate::state::{JavaVersion, MemorySettings}; +use regex::{Captures, Regex}; +use std::collections::BTreeMap; +use std::sync::LazyLock; + +static ENV_VAR_PATTERN: LazyLock = + LazyLock::new(|| Regex::new(r"\$(\w+)").expect("valid env var regex")); + +#[derive(Debug, Clone)] +pub(crate) struct HookVariables { + pub instance_name: String, + pub instance_id: String, + pub instance_dir: String, + pub java_path: String, + pub java_args: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct HookEnvironment { + lookup_env: BTreeMap, + injected_env: BTreeMap, +} + +impl HookEnvironment { + pub(crate) fn from_current_env( + custom_env_vars: &[(String, String)], + variables: HookVariables, + ) -> Self { + Self::new( + std::env::vars_os().map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.to_string_lossy().into_owned(), + ) + }), + custom_env_vars, + variables, + ) + } + + fn new( + process_env: impl IntoIterator, + custom_env_vars: &[(String, String)], + variables: HookVariables, + ) -> Self { + let mut lookup_env = + process_env.into_iter().collect::>(); + let mut injected_env = BTreeMap::new(); + + for (key, value) in custom_env_vars { + lookup_env.insert(key.clone(), value.clone()); + injected_env.insert(key.clone(), value.clone()); + } + + let hook_vars = [ + ("INST_NAME", variables.instance_name), + ("INST_ID", variables.instance_id), + ("INST_DIR", variables.instance_dir.clone()), + ("INST_MC_DIR", variables.instance_dir), + ("INST_JAVA", variables.java_path), + ("INST_JAVA_ARGS", variables.java_args), + ]; + + for (key, value) in hook_vars { + let key = key.to_string(); + lookup_env.insert(key.clone(), value.clone()); + injected_env.insert(key, value); + } + + Self { + lookup_env, + injected_env, + } + } + + pub(crate) fn expand(&self, input: &str) -> String { + ENV_VAR_PATTERN + .replace_all(input, |captures: &Captures| { + self.lookup_env + .get(&captures[1]) + .cloned() + .unwrap_or_else(|| captures[0].to_string()) + }) + .into_owned() + } + + pub(crate) fn injected_envs(&self) -> Vec<(String, String)> { + self.injected_env + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + } +} + +pub(crate) fn build_hook_java_args( + java_args: &[String], + memory: MemorySettings, + java_version: &JavaVersion, +) -> String { + let mut args = vec![format!("-Xmx{}M", memory.maximum)]; + + args.extend(java_args.iter().filter(|arg| !arg.is_empty()).cloned()); + + if java_version.parsed_version >= 9 { + args.push( + "--add-opens=java.base/java.lang.reflect=ALL-UNNAMED".to_string(), + ); + } + + if java_version.parsed_version >= 25 { + args.push( + "--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED" + .to_string(), + ); + } + + args.join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_variables() -> HookVariables { + HookVariables { + instance_name: "Test Instance".to_string(), + instance_id: "test-instance".to_string(), + instance_dir: "/profiles/test-instance".to_string(), + java_path: "/java/bin/java".to_string(), + java_args: "-Xmx4096M".to_string(), + } + } + + #[test] + fn expands_builtin_and_custom_variables() { + let env = HookEnvironment::new( + [("HOME".to_string(), "/home/alex".to_string())], + &[("CUSTOM_VAR".to_string(), "custom".to_string())], + sample_variables(), + ); + + assert_eq!( + env.expand("$HOME/$INST_ID/$CUSTOM_VAR"), + "/home/alex/test-instance/custom" + ); + } + + #[test] + fn leaves_unknown_variables_untouched() { + let env = HookEnvironment::new([], &[], sample_variables()); + + assert_eq!(env.expand("$UNKNOWN/$INST_NAME"), "$UNKNOWN/Test Instance"); + } + + #[test] + fn expands_empty_variables_to_empty_strings() { + let env = HookEnvironment::new( + [("EMPTY_VAR".to_string(), String::new())], + &[], + sample_variables(), + ); + + assert_eq!(env.expand("prefix$EMPTY_VAR-suffix"), "prefix-suffix"); + } +} diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index 1b24a9693f..6575e8fe5f 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -32,6 +32,7 @@ use std::path::PathBuf; use tokio::process::Command; mod args; +pub(crate) mod hooks; pub mod download; pub mod quick_play_version; @@ -214,6 +215,62 @@ fn loader_versions_for_game_version<'a>( } } +pub(crate) async fn resolve_java_for_launch( + context: &InstanceLaunchContext, +) -> crate::Result { + let state = State::get().await?; + let content_set = &context.applied_content_set; + let (minecraft, version_index) = + resolve_minecraft_manifest(&content_set.game_version, &state).await?; + let version = &minecraft.versions[version_index]; + + let mut loader_version = get_loader_version_from_profile( + &content_set.game_version, + content_set.loader, + content_set.loader_version.as_deref(), + ) + .await?; + + if content_set.loader != ModLoader::Vanilla && loader_version.is_none() { + loader_version = get_loader_version_from_profile( + &content_set.game_version, + content_set.loader, + Some("stable"), + ) + .await?; + } + + let version_info = download::download_version_info( + &state, + version, + loader_version.as_ref(), + None, + None, + None, + ) + .await?; + + let key = version_info + .java_version + .as_ref() + .map_or(8, |it| it.major_version); + let (java_path, set_java) = if let Some(java_version) = + get_java_version_from_launch_context(context, &version_info).await? + { + (PathBuf::from(java_version.path), false) + } else { + (crate::api::jre::auto_install_java(key).await?, true) + }; + + let java_version = crate::api::jre::check_jre(java_path).await?; + + if set_java { + java_version.upsert(&state.pool).await?; + } + + Ok(java_version) +} + /// Resolves the Minecraft version manifest and finds the index for the given /// game version. If the version isn't found in the cache, forces a manifest /// refresh to pick up newly-released versions. @@ -998,7 +1055,7 @@ pub async fn launch_minecraft( // Java options should be set in instance options (the existence of _JAVA_OPTIONS overwrites them) command.env_remove("_JAVA_OPTIONS"); - command.envs(env_args); + command.envs(env_args.iter().cloned()); // Overwrites the minecraft options.txt file with the settings from the profile // Uses 'a:b' syntax which is not quite yaml @@ -1084,6 +1141,7 @@ pub async fn launch_minecraft( &instance.name, command, post_exit_hook, + env_args, state.directories.instance_logs_dir(&instance.path), version_info.logging.is_some(), main_class_keep_alive, diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index 669dac581f..d8ed86e636 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -107,6 +107,7 @@ impl ProcessManager { instance_name: &str, mut mc_command: Command, post_exit_command: Option, + post_exit_env_vars: Vec<(String, String)>, logs_folder: PathBuf, xml_logging: bool, main_class_keep_alive: TempDir, @@ -217,6 +218,7 @@ impl ProcessManager { instance_id.to_string(), instance_path.to_string(), post_exit_command, + post_exit_env_vars, metadata.uuid, )); @@ -750,6 +752,7 @@ impl Process { instance_id: String, instance_path: String, post_exit_command: Option, + post_exit_env_vars: Vec<(String, String)>, uuid: Uuid, ) -> crate::Result<()> { async fn update_playtime( @@ -885,7 +888,7 @@ impl Process { if let Some(command) = cmd.next() { let mut command = Command::new(command); - command.args(cmd).current_dir( + command.args(cmd).envs(post_exit_env_vars).current_dir( state.directories.instances_dir().join(&instance_path), ); command.spawn().map_err(IOError::from)?; From 5148e8ec3585ea68c7abe5cdea05744566fc2ddb Mon Sep 17 00:00:00 2001 From: aecsocket <43144841+aecsocket@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:36:05 +0900 Subject: [PATCH 062/145] feat: use postcard for redis serde (#6956) * redo error handling in xredis * give proper types to metadata fields * add round-trip tests * inline loader enum metadata fields * postcard roundtrips * prepare * bump redis key version * serde-binhum * clippy * fix * fix frontend checking existence of component fields rather than non-null-ness * prepare --- Cargo.lock | 14 +- Cargo.toml | 2 + apps/frontend/src/pages/[type]/[project].vue | 6 +- ...1d32cab210cfaea2205c95d9ca3bbd0ec1cd.json} | 16 +- ...358c2b9eac5323ae0795581358ed29e5ba65.json} | 16 +- ...19f81d17197673d831d210e351c149e41fc8.json} | 16 +- ...34d36eff6902ae55b7ed4d89a10c4d69506a9.json | 50 --- ...01ba8dca63912e9d7054115052ad89ab4718.json} | 16 +- ...654bd8650dcdd16cb9f74148bef6e932e2837.json | 50 +++ apps/labrinth/AGENTS.md | 4 +- apps/labrinth/Cargo.toml | 1 + apps/labrinth/src/auth/mod.rs | 6 - .../database/models/analytics_event_item.rs | 2 +- .../src/database/models/categories.rs | 2 +- .../src/database/models/collection_item.rs | 2 +- .../labrinth/src/database/models/flow_item.rs | 32 +- .../src/database/models/image_item.rs | 2 +- .../database/models/legacy_loader_fields.rs | 13 +- .../src/database/models/loader_fields.rs | 131 +++--- apps/labrinth/src/database/models/mod.rs | 2 - .../database/models/moderation_note_item.rs | 4 +- .../src/database/models/notification_item.rs | 2 +- .../models/notifications_template_item.rs | 24 +- .../models/notifications_type_item.rs | 2 +- .../src/database/models/organization_item.rs | 4 +- apps/labrinth/src/database/models/pat_item.rs | 6 +- .../src/database/models/product_item.rs | 4 +- .../src/database/models/project_item.rs | 25 +- .../src/database/models/session_item.rs | 6 +- .../labrinth/src/database/models/team_item.rs | 2 +- .../labrinth/src/database/models/user_item.rs | 6 +- .../src/database/models/version_item.rs | 21 +- apps/labrinth/src/database/redis.rs | 56 --- apps/labrinth/src/database/redis/mod.rs | 411 ++++++++++++++++++ apps/labrinth/src/env.rs | 2 +- apps/labrinth/src/models/exp/minecraft.rs | 7 +- apps/labrinth/src/models/exp/project.rs | 3 +- apps/labrinth/src/models/v3/billing.rs | 5 +- apps/labrinth/src/models/v3/notifications.rs | 4 +- apps/labrinth/src/queue/analytics/cache.rs | 2 +- apps/labrinth/src/queue/analytics/mod.rs | 49 ++- apps/labrinth/src/queue/server_ping.rs | 4 +- apps/labrinth/src/routes/internal/campaign.rs | 2 +- apps/labrinth/src/routes/internal/flows.rs | 2 +- apps/labrinth/src/routes/mod.rs | 6 - apps/labrinth/src/routes/v2/tags.rs | 13 +- apps/labrinth/src/routes/v3/content/mod.rs | 4 +- apps/labrinth/src/routes/v3/tags.rs | 3 +- apps/labrinth/src/search/indexing.rs | 11 +- apps/labrinth/src/sync/friends.rs | 2 +- apps/labrinth/src/sync/status.rs | 2 +- apps/labrinth/src/util/gotenberg.rs | 2 +- apps/labrinth/src/util/ratelimit.rs | 2 +- apps/labrinth/tests/loader_fields.rs | 14 +- apps/labrinth/tests/redis.rs | 40 +- apps/labrinth/tests/tags.rs | 7 +- .../api-client/src/modules/labrinth/types.ts | 7 +- packages/serde-binhum/Cargo.toml | 18 + packages/serde-binhum/README.md | 154 +++++++ packages/serde-binhum/src/lib.rs | 390 +++++++++++++++++ packages/xredis/Cargo.toml | 3 +- packages/xredis/src/blocking.rs | 58 ++- packages/xredis/src/cache.rs | 162 ++++--- packages/xredis/src/cache/locking/local.rs | 17 +- packages/xredis/src/commands.rs | 105 +++-- packages/xredis/src/config.rs | 52 ++- packages/xredis/src/connection.rs | 88 ++-- packages/xredis/src/lib.rs | 92 ++-- packages/xredis/src/metrics.rs | 40 +- packages/xredis/src/pubsub.rs | 31 +- 70 files changed, 1713 insertions(+), 646 deletions(-) rename apps/labrinth/.sqlx/{query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json => query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json} (68%) rename apps/labrinth/.sqlx/{query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json => query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json} (58%) rename apps/labrinth/.sqlx/{query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json => query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json} (63%) delete mode 100644 apps/labrinth/.sqlx/query-ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9.json rename apps/labrinth/.sqlx/{query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json => query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json} (58%) create mode 100644 apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json delete mode 100644 apps/labrinth/src/database/redis.rs create mode 100644 apps/labrinth/src/database/redis/mod.rs create mode 100644 packages/serde-binhum/Cargo.toml create mode 100644 packages/serde-binhum/README.md create mode 100644 packages/serde-binhum/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index b3eefef689..db6f7b0ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5515,6 +5515,7 @@ dependencies = [ "scalar_api_reference", "sentry", "serde", + "serde-binhum", "serde_json", "serde_with", "sha1 0.10.6", @@ -9379,6 +9380,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-binhum" +version = "0.1.0" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "serde-untagged" version = "0.1.9" @@ -13443,12 +13454,13 @@ dependencies = [ "chrono", "dashmap", "deadpool-redis", + "eyre", "futures", "lz4_flex", + "postcard", "prometheus", "redis", "serde", - "serde_json", "thiserror 2.0.17", "tokio", "tracing", diff --git a/Cargo.toml b/Cargo.toml index 1529ce8910..01c9a1af38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "packages/modrinth-util", "packages/neverbounce", "packages/path-util", + "packages/serde-binhum", "packages/xredis", ] @@ -183,6 +184,7 @@ sentry = { version = "0.45.0", default-features = false, features = [ "rustls", ] } serde = "1.0.228" +serde-binhum = { path = "packages/serde-binhum" } serde_bytes = "0.11.19" serde_cbor = "0.11.2" serde_ini = "0.2.0" diff --git a/apps/frontend/src/pages/[type]/[project].vue b/apps/frontend/src/pages/[type]/[project].vue index f0a67eec5e..b60e21f004 100644 --- a/apps/frontend/src/pages/[type]/[project].vue +++ b/apps/frontend/src/pages/[type]/[project].vue @@ -2014,9 +2014,7 @@ const navLinks = computed(() => { label: formatMessage(messages.changelogTab), href: withInstallContextQuery(`${projectUrl}/changelog`), shown: - hasVersions.value && - projectV3Loaded.value && - projectV3.value?.minecraft_server === undefined, + hasVersions.value && projectV3Loaded.value && projectV3.value?.minecraft_server == null, onHover: loadVersions, }, { @@ -2025,7 +2023,7 @@ const navLinks = computed(() => { shown: (hasVersions.value || !!currentMember.value) && projectV3Loaded.value && - projectV3.value?.minecraft_server === undefined, + projectV3.value?.minecraft_server == null, subpages: [`${projectUrl}/version/`], onHover: loadVersions, }, diff --git a/apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json b/apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json similarity index 68% rename from apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json rename to apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json index 6c62c2b6b6..f7c409af75 100644 --- a/apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json +++ b/apps/labrinth/.sqlx/query-214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", + "query": "\n SELECT id, enum_id, value, ordering,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\",\n created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", "describe": { "columns": [ { @@ -25,11 +25,16 @@ }, { "ordinal": 4, - "name": "metadata", - "type_info": "Jsonb" + "name": "ty?", + "type_info": "Text" }, { "ordinal": 5, + "name": "major?", + "type_info": "Bool" + }, + { + "ordinal": 6, "name": "created", "type_info": "Timestamptz" } @@ -44,9 +49,10 @@ false, false, true, - true, + null, + null, false ] }, - "hash": "04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433" + "hash": "214cc9257db904fd2cc68b8aa8d61d32cab210cfaea2205c95d9ca3bbd0ec1cd" } diff --git a/apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json b/apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json similarity index 58% rename from apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json rename to apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json index 7141f46a13..d0bc6abfc1 100644 --- a/apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json +++ b/apps/labrinth/.sqlx/query-83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ", + "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", "describe": { "columns": [ { @@ -30,8 +30,13 @@ }, { "ordinal": 5, - "name": "metadata", - "type_info": "Jsonb" + "name": "ty?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "major?", + "type_info": "Bool" } ], "parameters": { @@ -45,8 +50,9 @@ false, true, false, - true + null, + null ] }, - "hash": "d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51" + "hash": "83b7543e426ae348e589b591f249358c2b9eac5323ae0795581358ed29e5ba65" } diff --git a/apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json b/apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json similarity index 63% rename from apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json rename to apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json index 0af23b8554..2f1c614db8 100644 --- a/apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json +++ b/apps/labrinth/.sqlx/query-a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ", + "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ", "describe": { "columns": [ { @@ -30,8 +30,13 @@ }, { "ordinal": 5, - "name": "metadata", - "type_info": "Jsonb" + "name": "ty?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "major?", + "type_info": "Bool" } ], "parameters": { @@ -43,8 +48,9 @@ false, true, false, - true + null, + null ] }, - "hash": "fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c" + "hash": "a8569122a309057326be0d49630119f81d17197673d831d210e351c149e41fc8" } diff --git a/apps/labrinth/.sqlx/query-ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9.json b/apps/labrinth/.sqlx/query-ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9.json deleted file mode 100644 index 340549e517..0000000000 --- a/apps/labrinth/.sqlx/query-ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int4" - }, - { - "ordinal": 1, - "name": "loader", - "type_info": "Varchar" - }, - { - "ordinal": 2, - "name": "icon", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "metadata", - "type_info": "Jsonb" - }, - { - "ordinal": 4, - "name": "project_types", - "type_info": "VarcharArray" - }, - { - "ordinal": 5, - "name": "games", - "type_info": "VarcharArray" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - false, - false, - false, - false, - null, - null - ] - }, - "hash": "ab9b4b383ce8431214eb26abde734d36eff6902ae55b7ed4d89a10c4d69506a9" -} diff --git a/apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json b/apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json similarity index 58% rename from apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json rename to apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json index c66e860181..478c4dcd62 100644 --- a/apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json +++ b/apps/labrinth/.sqlx/query-ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", + "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created,\n metadata->>'type' AS \"ty?\",\n (metadata->>'major')::boolean AS \"major?\"\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ", "describe": { "columns": [ { @@ -30,8 +30,13 @@ }, { "ordinal": 5, - "name": "metadata", - "type_info": "Jsonb" + "name": "ty?", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "major?", + "type_info": "Bool" } ], "parameters": { @@ -45,8 +50,9 @@ false, true, false, - true + null, + null ] }, - "hash": "43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af" + "hash": "ec9abad348739217eb887e2ac84901ba8dca63912e9d7054115052ad89ab4718" } diff --git a/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json b/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json new file mode 100644 index 0000000000..9fda28ebba --- /dev/null +++ b/apps/labrinth/.sqlx/query-f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT l.id id, l.loader loader, l.icon icon,\n (l.metadata->>'platform')::boolean AS platform,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l\n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "loader", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "icon", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "platform", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 5, + "name": "games", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + null, + null, + null + ] + }, + "hash": "f21bfe5bcc157d442180bf49c97654bd8650dcdd16cb9f74148bef6e932e2837" +} diff --git a/apps/labrinth/AGENTS.md b/apps/labrinth/AGENTS.md index 0c1e193f6f..3c11a2248b 100644 --- a/apps/labrinth/AGENTS.md +++ b/apps/labrinth/AGENTS.md @@ -12,8 +12,10 @@ - no trailing punctuation - wrap code items e.g. type names in backticks - Prefer `wrap_internal_err`, `wrap_request_err` when attaching context to an existing error (like Anyhow `context` or Eyre `wrap_err`) +- Prefer importing `eyre::Result` and using `Result` instead of `eyre::Result` +- Prefer `eyre::Ok(value)` instead of `Ok::<_, eyre::Report>(value)` when an explicit Eyre result type is needed - All operations should ideally have some context attached - - Database operations can have a message like `.wrap_internal_err("failed to fetch XYZ")` + - Database operations can have a message like `.wrap_internal_err("fetching XYZ")` - You can perform real-time queries against the databases in the Docker Compose - `docker exec labrinth-postgres psql -c "select 1"` - `docker exec labrinth-redis redis-cli flushall` diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml index 1081074d9a..fccb039a12 100644 --- a/apps/labrinth/Cargo.toml +++ b/apps/labrinth/Cargo.toml @@ -111,6 +111,7 @@ rusty-money = { workspace = true } scalar_api_reference = { workspace = true, features = ["actix-web"] } sentry = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde-binhum = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } sha1 = { workspace = true } diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs index bf0a963f81..c58a032f13 100644 --- a/apps/labrinth/src/auth/mod.rs +++ b/apps/labrinth/src/auth/mod.rs @@ -59,12 +59,6 @@ pub enum AuthenticationError { Url, } -impl From for AuthenticationError { - fn from(error: xredis::Error) -> Self { - Self::Database(error.into()) - } -} - impl actix_web::ResponseError for AuthenticationError { fn status_code(&self) -> StatusCode { match self { diff --git a/apps/labrinth/src/database/models/analytics_event_item.rs b/apps/labrinth/src/database/models/analytics_event_item.rs index 268c0fc538..47493582aa 100644 --- a/apps/labrinth/src/database/models/analytics_event_item.rs +++ b/apps/labrinth/src/database/models/analytics_event_item.rs @@ -9,7 +9,7 @@ use crate::{ }; use serde::{Deserialize, Serialize}; -const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v3"; +const ANALYTICS_EVENTS_NAMESPACE: &str = "analytics_events:v4"; const ANALYTICS_EVENTS_ALL_KEY: &str = "all"; #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/apps/labrinth/src/database/models/categories.rs b/apps/labrinth/src/database/models/categories.rs index edddeb7d21..c2b60ebeb8 100644 --- a/apps/labrinth/src/database/models/categories.rs +++ b/apps/labrinth/src/database/models/categories.rs @@ -7,7 +7,7 @@ use super::ids::*; use futures::TryStreamExt; use serde::{Deserialize, Serialize}; -const TAGS_NAMESPACE: &str = "tags:v3"; +const TAGS_NAMESPACE: &str = "tags:v4"; pub struct ProjectType { pub id: ProjectTypeId, diff --git a/apps/labrinth/src/database/models/collection_item.rs b/apps/labrinth/src/database/models/collection_item.rs index b2c53e1f21..6f89b75e71 100644 --- a/apps/labrinth/src/database/models/collection_item.rs +++ b/apps/labrinth/src/database/models/collection_item.rs @@ -8,7 +8,7 @@ use futures::TryStreamExt; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const COLLECTIONS_NAMESPACE: &str = "collections:v3"; +const COLLECTIONS_NAMESPACE: &str = "collections:v4"; #[derive(Clone)] pub struct CollectionBuilder { diff --git a/apps/labrinth/src/database/models/flow_item.rs b/apps/labrinth/src/database/models/flow_item.rs index 649da111be..dd675941ae 100644 --- a/apps/labrinth/src/database/models/flow_item.rs +++ b/apps/labrinth/src/database/models/flow_item.rs @@ -8,14 +8,14 @@ use rand::Rng; use rand::distributions::Alphanumeric; use rand_chacha::ChaCha20Rng; use rand_chacha::rand_core::SeedableRng; -use serde::{Deserialize, Serialize}; +use serde_binhum::serde_binhum; use url::Url; use webauthn_rs::prelude::{DiscoverableAuthentication, PasskeyRegistration}; use xredis::RedisPool; -const FLOWS_NAMESPACE: &str = "flows:v3"; +const FLOWS_NAMESPACE: &str = "flows:v4"; -#[derive(Deserialize, Serialize)] +#[serde_binhum] pub enum DBFlow { OAuth { user_id: Option, @@ -60,13 +60,39 @@ pub enum DBFlow { }, RegisterPasskey { user_id: DBUserId, + #[serde_binhum(binary(with = "json_string"))] state: PasskeyRegistration, }, AuthenticatePasskey { + #[serde_binhum(binary(with = "json_string"))] state: DiscoverableAuthentication, }, } +mod json_string { + use serde::de::DeserializeOwned; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(value: &T, serializer: S) -> Result + where + T: Serialize, + S: Serializer, + { + let value = + serde_json::to_string(value).map_err(serde::ser::Error::custom)?; + value.serialize(serializer) + } + + pub fn deserialize<'de, T, D>(deserializer: D) -> Result + where + T: DeserializeOwned, + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + serde_json::from_str(&value).map_err(serde::de::Error::custom) + } +} + impl DBFlow { pub async fn insert_with_state( &self, diff --git a/apps/labrinth/src/database/models/image_item.rs b/apps/labrinth/src/database/models/image_item.rs index edb3d5add9..f3403ed7fc 100644 --- a/apps/labrinth/src/database/models/image_item.rs +++ b/apps/labrinth/src/database/models/image_item.rs @@ -6,7 +6,7 @@ use dashmap::DashMap; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const IMAGES_NAMESPACE: &str = "images:v3"; +const IMAGES_NAMESPACE: &str = "images:v4"; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct DBImage { diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs index a11a8a504f..f24291c25e 100644 --- a/apps/labrinth/src/database/models/legacy_loader_fields.rs +++ b/apps/labrinth/src/database/models/legacy_loader_fields.rs @@ -117,17 +117,8 @@ impl MinecraftGameVersion { id: loader_field_enum_value.id, version: loader_field_enum_value.value, created: loader_field_enum_value.created, - type_: loader_field_enum_value - .metadata - .get("type") - .and_then(|x| x.as_str()) - .map(|x| x.to_string()) - .unwrap_or_default(), - major: loader_field_enum_value - .metadata - .get("major") - .and_then(|x| x.as_bool()) - .unwrap_or_default(), + type_: loader_field_enum_value.ty.unwrap_or_default(), + major: loader_field_enum_value.major.unwrap_or_default(), } } } diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/database/models/loader_fields.rs index a794812b25..bb599a1764 100644 --- a/apps/labrinth/src/database/models/loader_fields.rs +++ b/apps/labrinth/src/database/models/loader_fields.rs @@ -12,14 +12,14 @@ use itertools::Itertools; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const GAMES_LIST_NAMESPACE: &str = "games:v3"; -const LOADER_ID: &str = "loader_id:v3"; -const LOADERS_LIST_NAMESPACE: &str = "loaders:v3"; -const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v3"; -const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v3"; -const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v3"; +const GAMES_LIST_NAMESPACE: &str = "games:v4"; +const LOADER_ID: &str = "loader_id:v4"; +const LOADERS_LIST_NAMESPACE: &str = "loaders:v4"; +const LOADER_FIELDS_NAMESPACE: &str = "loader_fields:v4"; +const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all:v4"; +const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums:v4"; pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str = - "loader_field_enum_values:v3"; + "loader_field_enum_values:v4"; #[derive(Clone, Serialize, Deserialize, Debug)] pub struct Game { @@ -87,6 +87,11 @@ impl Game { } } +#[derive(Clone, Serialize, Deserialize)] +pub struct LoaderMetadata { + pub platform: Option, +} + #[derive(Serialize, Deserialize, Clone)] pub struct Loader { pub id: LoaderId, @@ -94,7 +99,7 @@ pub struct Loader { pub icon: String, pub supported_project_types: Vec, pub supported_games: Vec, // slugs - pub metadata: serde_json::Value, + pub metadata: LoaderMetadata, } impl Loader { @@ -154,7 +159,8 @@ impl Loader { let result = sqlx::query!( " - SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata, + SELECT l.id id, l.loader loader, l.icon icon, + (l.metadata->>'platform')::boolean AS platform, ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games FROM loaders l @@ -179,7 +185,9 @@ impl Loader { supported_games: x .games .unwrap_or_default(), - metadata: x.metadata + metadata: LoaderMetadata { + platform: x.platform, + }, }) .try_collect::>() .await?; @@ -277,8 +285,9 @@ pub struct LoaderFieldEnumValue { pub value: String, pub ordering: Option, pub created: DateTime, - #[serde(flatten)] - pub metadata: serde_json::Value, + #[serde(rename = "type")] + pub ty: Option, + pub major: Option, } impl std::hash::Hash for LoaderFieldEnumValue { @@ -357,7 +366,8 @@ pub struct QueryLoaderFieldEnumValue { pub value: String, pub ordering: Option, pub created: DateTime, - pub metadata: Option, + pub ty: Option, + pub major: Option, } impl LoaderField { @@ -612,42 +622,50 @@ impl LoaderFieldEnumValue { where E: crate::database::Executor<'a, Database = sqlx::Postgres>, { - let val = redis.get_cached_keys_raw( - LOADER_FIELD_ENUM_VALUES_NAMESPACE, - &loader_field_enum_ids.iter().map(|x| x.0).collect::>(), - |loader_field_enum_ids| async move { - let values = sqlx::query!( - " - SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values + let val = redis + .get_cached_keys_raw( + LOADER_FIELD_ENUM_VALUES_NAMESPACE, + &loader_field_enum_ids + .iter() + .map(|x| x.0) + .collect::>(), + |loader_field_enum_ids| async move { + let values = sqlx::query!( + r#" + SELECT id, enum_id, value, ordering, + metadata->>'type' AS "ty?", + (metadata->>'major')::boolean AS "major?", + created FROM loader_field_enum_values WHERE enum_id = ANY($1) ORDER BY enum_id, ordering, created DESC - ", - &loader_field_enum_ids - ) + "#, + &loader_field_enum_ids + ) .fetch(exec) - .try_fold(DashMap::new(), |acc: DashMap>, c| { - let value = LoaderFieldEnumValue { - id: LoaderFieldEnumValueId(c.id), - enum_id: LoaderFieldEnumId(c.enum_id), - value: c.value, - ordering: c.ordering, - created: c.created, - metadata: c.metadata.unwrap_or_default(), - }; + .try_fold( + DashMap::new(), + |acc: DashMap>, c| { + let value = LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(c.id), + enum_id: LoaderFieldEnumId(c.enum_id), + value: c.value, + ordering: c.ordering, + created: c.created, + ty: c.ty, + major: c.major, + }; - acc.entry(c.enum_id) - .or_default() - .push(value); + acc.entry(c.enum_id).or_default().push(value); - async move { - Ok(acc) - } - }) + async move { Ok(acc) } + }, + ) .await?; - Ok::<_, DatabaseError>(values) - }, - ).await?; + Ok::<_, DatabaseError>(values) + }, + ) + .await?; Ok(val .into_iter() @@ -669,15 +687,16 @@ impl LoaderFieldEnumValue { .await? .into_iter() .filter(|x| { - let mut bool = true; - for (key, value) in &filter { - if let Some(metadata_value) = x.metadata.get(key) { - bool &= metadata_value == value; - } else { - bool = false; + filter.iter().all(|(key, value)| match key.as_str() { + "type" => { + x.ty.as_deref() + .is_some_and(|type_| value.as_str() == Some(type_)) } - } - bool + "major" => x + .major + .is_some_and(|major| value.as_bool() == Some(major)), + _ => false, + }) }) .collect(); @@ -1170,10 +1189,8 @@ impl VersionFieldValue { value: lfev.value.clone(), ordering: lfev.ordering, created: lfev.created, - metadata: lfev - .metadata - .clone() - .unwrap_or_default(), + ty: lfev.ty.clone(), + major: lfev.major, } }), )) @@ -1249,10 +1266,8 @@ impl VersionFieldValue { value: lfev.value.clone(), ordering: lfev.ordering, created: lfev.created, - metadata: lfev - .metadata - .clone() - .unwrap_or_default(), + ty: lfev.ty.clone(), + major: lfev.major, }) }) .collect::>()?, diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 09eddcaed7..badfad0bdb 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -77,8 +77,6 @@ pub enum DatabaseError { SerdeCacheError(#[from] serde_json::Error), #[error("error while encoding or decoding the cache: {0}")] PostcardCacheError(#[from] postcard::Error), - #[error(transparent)] - Redis(#[from] xredis::Error), #[error("Schema error: {0}")] SchemaError(String), } diff --git a/apps/labrinth/src/database/models/moderation_note_item.rs b/apps/labrinth/src/database/models/moderation_note_item.rs index 5f837b5da1..b0f14d175e 100644 --- a/apps/labrinth/src/database/models/moderation_note_item.rs +++ b/apps/labrinth/src/database/models/moderation_note_item.rs @@ -7,9 +7,9 @@ use xredis::RedisPool; use super::{DBOrganizationId, DBUserId, DatabaseError}; -const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v3"; +const MODERATION_NOTES_USERS_NAMESPACE: &str = "moderation_notes_users:v4"; const MODERATION_NOTES_ORGANIZATIONS_NAMESPACE: &str = - "moderation_notes_organizations:v3"; + "moderation_notes_organizations:v4"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DBModerationNote { diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs index b97972c647..6421a6d0bf 100644 --- a/apps/labrinth/src/database/models/notification_item.rs +++ b/apps/labrinth/src/database/models/notification_item.rs @@ -10,7 +10,7 @@ use futures::TryStreamExt; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v3"; +const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications:v4"; pub struct NotificationBuilder { pub body: NotificationBody, diff --git a/apps/labrinth/src/database/models/notifications_template_item.rs b/apps/labrinth/src/database/models/notifications_template_item.rs index e5b7911561..dbe908e39e 100644 --- a/apps/labrinth/src/database/models/notifications_template_item.rs +++ b/apps/labrinth/src/database/models/notifications_template_item.rs @@ -1,14 +1,15 @@ use crate::database::models::DatabaseError; use crate::models::v3::notifications::{NotificationChannel, NotificationType}; use crate::routes::ApiError; +use crate::util::error::Context; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const TEMPLATES_NAMESPACE: &str = "notifications_templates:v3"; +const TEMPLATES_NAMESPACE: &str = "notifications_templates:v4"; const TEMPLATES_HTML_DATA_NAMESPACE: &str = - "notifications_templates_html_data:v3"; + "notifications_templates_html_data:v4"; const TEMPLATES_DYNAMIC_HTML_NAMESPACE: &str = - "notifications_templates_dynamic_html:v3"; + "notifications_templates_dynamic_html:v4"; const HTML_DATA_CACHE_EXPIRY: i64 = 60 * 15; // 15 minutes const TEMPLATES_CACHE_EXPIRY: i64 = 60 * 30; // 30 minutes @@ -123,12 +124,16 @@ where html: String, } - let mut redis_conn = redis.connect().await?; + let mut redis_conn = redis.connect().await.wrap_internal_err( + "connecting to redis for dynamic notification html", + )?; let redis_key = redis_conn .key() .metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key); - if let Some(body) = - redis_conn.get_deserialized::(&redis_key).await? + if let Some(body) = redis_conn + .get_deserialized::(&redis_key) + .await + .wrap_internal_err("fetching dynamic notification html from redis")? { return Ok(body.html); } @@ -136,14 +141,17 @@ where drop(redis_conn); let cached = HtmlBody { html: get().await? }; - let mut redis_conn = redis.connect().await?; + let mut redis_conn = redis.connect().await.wrap_internal_err( + "connecting to redis for dynamic notification html", + )?; let redis_key = redis_conn .key() .metadata(TEMPLATES_DYNAMIC_HTML_NAMESPACE, key); redis_conn .set_serialized(&redis_key, &cached, Some(HTML_DATA_CACHE_EXPIRY)) - .await?; + .await + .wrap_internal_err("writing dynamic notification html to redis")?; Ok(cached.html) } diff --git a/apps/labrinth/src/database/models/notifications_type_item.rs b/apps/labrinth/src/database/models/notifications_type_item.rs index 4663d90920..c2d036a3f7 100644 --- a/apps/labrinth/src/database/models/notifications_type_item.rs +++ b/apps/labrinth/src/database/models/notifications_type_item.rs @@ -3,7 +3,7 @@ use crate::models::v3::notifications::NotificationType; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v3"; +const NOTIFICATION_TYPES_NAMESPACE: &str = "notification_types:v4"; #[derive(Serialize, Deserialize)] pub struct NotificationTypeItem { diff --git a/apps/labrinth/src/database/models/organization_item.rs b/apps/labrinth/src/database/models/organization_item.rs index 2cb83b6fc3..845ff1235d 100644 --- a/apps/labrinth/src/database/models/organization_item.rs +++ b/apps/labrinth/src/database/models/organization_item.rs @@ -9,8 +9,8 @@ use xredis::RedisPool; use super::{DBTeamMember, ids::*}; use serde::{Deserialize, Serialize}; -const ORGANIZATIONS_NAMESPACE: &str = "organizations:v3"; -const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v3"; +const ORGANIZATIONS_NAMESPACE: &str = "organizations:v4"; +const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles:v4"; #[derive(Deserialize, Serialize, Clone, Debug)] /// An organization of users who together control one or more projects and organizations. diff --git a/apps/labrinth/src/database/models/pat_item.rs b/apps/labrinth/src/database/models/pat_item.rs index 50a05c84c7..deb6f4a0b2 100644 --- a/apps/labrinth/src/database/models/pat_item.rs +++ b/apps/labrinth/src/database/models/pat_item.rs @@ -11,9 +11,9 @@ use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -const PATS_NAMESPACE: &str = "pats:v3"; -const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v3"; -const PATS_USERS_NAMESPACE: &str = "pats_users:v3"; +const PATS_NAMESPACE: &str = "pats:v4"; +const PATS_TOKENS_NAMESPACE: &str = "pats_tokens:v4"; +const PATS_USERS_NAMESPACE: &str = "pats_users:v4"; #[derive(Deserialize, Serialize, Clone, Debug)] pub struct DBPersonalAccessToken { diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs index 266eafa773..a1f4cc291e 100644 --- a/apps/labrinth/src/database/models/product_item.rs +++ b/apps/labrinth/src/database/models/product_item.rs @@ -9,7 +9,7 @@ use std::convert::TryFrom; use std::convert::TryInto; use xredis::RedisPool; -const PRODUCTS_NAMESPACE: &str = "products:v3"; +const PRODUCTS_NAMESPACE: &str = "products:v4"; pub struct DBProduct { pub id: DBProductId, @@ -136,7 +136,7 @@ pub struct QueryProductWithPrices { pub id: DBProductId, pub metadata: ProductMetadata, pub unitary: bool, - #[serde(skip_serializing_if = "Option::is_none", default)] + #[serde(default)] pub name: Option, pub prices: Vec, } diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs index e862cf76f3..f4d0d3118a 100644 --- a/apps/labrinth/src/database/models/project_item.rs +++ b/apps/labrinth/src/database/models/project_item.rs @@ -19,13 +19,14 @@ use dashmap::{DashMap, DashSet}; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; +use serde_binhum::serde_binhum; use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -pub const PROJECTS_NAMESPACE: &str = "projects:v3"; -pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v3"; -const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v3"; +pub const PROJECTS_NAMESPACE: &str = "projects:v4"; +pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs:v4"; +const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies:v4"; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LinkUrl { @@ -657,12 +658,14 @@ impl DBProject { .await?; let loader_field_enum_values: Vec = sqlx::query!( - " - SELECT DISTINCT id, enum_id, value, ordering, created, metadata + r#" + SELECT DISTINCT id, enum_id, value, ordering, created, + metadata->>'type' AS "ty?", + (metadata->>'major')::boolean AS "major?" FROM loader_field_enum_values lfev WHERE id = ANY($1) ORDER BY enum_id, ordering, created DESC - ", + "#, &loader_field_enum_value_ids .iter() .map(|x| x.0) @@ -675,7 +678,8 @@ impl DBProject { value: m.value, ordering: m.ordering, created: m.created, - metadata: m.metadata, + ty: m.ty, + major: m.major, }) .try_collect() .await?; @@ -946,7 +950,7 @@ impl DBProject { }, ) .await - .wrap_internal_err("failed to fetch cached projects")?; + .wrap_internal_err("fetching cached projects")?; Ok(val) } @@ -1041,8 +1045,10 @@ impl DBProject { } } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde_binhum] +#[derive(Clone, Debug)] pub struct ProjectQueryResult { + #[serde(flatten)] pub inner: DBProject, pub categories: Vec, pub additional_categories: Vec, @@ -1053,6 +1059,5 @@ pub struct ProjectQueryResult { pub gallery_items: Vec, pub thread_id: DBThreadId, pub aggregate_version_fields: Vec, - #[serde(flatten)] pub components: exp::ProjectQuery, } diff --git a/apps/labrinth/src/database/models/session_item.rs b/apps/labrinth/src/database/models/session_item.rs index 9d58dd4f12..9ca8ae560d 100644 --- a/apps/labrinth/src/database/models/session_item.rs +++ b/apps/labrinth/src/database/models/session_item.rs @@ -10,9 +10,9 @@ use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -const SESSIONS_NAMESPACE: &str = "sessions:v3"; -const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v3"; -const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v3"; +const SESSIONS_NAMESPACE: &str = "sessions:v4"; +const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids:v4"; +const SESSIONS_USERS_NAMESPACE: &str = "sessions_users:v4"; pub struct SessionBuilder { pub session: String, diff --git a/apps/labrinth/src/database/models/team_item.rs b/apps/labrinth/src/database/models/team_item.rs index 698c9d08a5..4246e60d7d 100644 --- a/apps/labrinth/src/database/models/team_item.rs +++ b/apps/labrinth/src/database/models/team_item.rs @@ -10,7 +10,7 @@ use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use xredis::RedisPool; -const TEAMS_NAMESPACE: &str = "teams:v3"; +const TEAMS_NAMESPACE: &str = "teams:v4"; pub struct TeamBuilder { pub members: Vec, diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index 416d1bad18..a5efc5e701 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -16,9 +16,9 @@ use std::fmt::{Debug, Display}; use std::hash::Hash; use xredis::RedisPool; -const USERS_NAMESPACE: &str = "users:v3"; -const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v3"; -const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v3"; +const USERS_NAMESPACE: &str = "users:v4"; +const USER_USERNAMES_NAMESPACE: &str = "users_usernames:v4"; +const USERS_PROJECTS_NAMESPACE: &str = "users_projects:v4"; #[derive(Deserialize, Serialize, Clone, Debug)] pub struct DBUser { diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs index 096bed6614..aff56773e5 100644 --- a/apps/labrinth/src/database/models/version_item.rs +++ b/apps/labrinth/src/database/models/version_item.rs @@ -17,12 +17,13 @@ use dashmap::{DashMap, DashSet}; use futures::TryStreamExt; use itertools::Itertools; use serde::{Deserialize, Serialize}; +use serde_binhum::serde_binhum; use std::cmp::Ordering; use std::collections::HashMap; use tracing::error; -pub const VERSIONS_NAMESPACE: &str = "versions:v3"; -const VERSION_FILES_NAMESPACE: &str = "versions_files:v3"; +pub const VERSIONS_NAMESPACE: &str = "versions:v4"; +const VERSION_FILES_NAMESPACE: &str = "versions_files:v4"; pub async fn cleanup_unused_attribution_files_and_groups( transaction: &mut PgTransaction<'_>, @@ -704,12 +705,14 @@ impl DBVersion { .await?; let loader_field_enum_values: Vec = sqlx::query!( - " - SELECT DISTINCT id, enum_id, value, ordering, created, metadata + r#" + SELECT DISTINCT id, enum_id, value, ordering, created, + metadata->>'type' AS "ty?", + (metadata->>'major')::boolean AS "major?" FROM loader_field_enum_values lfev WHERE id = ANY($1) ORDER BY enum_id, ordering, created ASC - ", + "#, &loader_field_enum_value_ids .iter() .map(|x| x.0) @@ -722,7 +725,8 @@ impl DBVersion { value: m.value, ordering: m.ordering, created: m.created, - metadata: m.metadata, + ty: m.ty, + major: m.major, }) .try_collect() .await?; @@ -1080,8 +1084,10 @@ impl DBVersion { } } -#[derive(Clone, Deserialize, Serialize)] +#[serde_binhum] +#[derive(Clone)] pub struct VersionQueryResult { + #[serde(flatten)] pub inner: DBVersion, pub files: Vec, @@ -1090,7 +1096,6 @@ pub struct VersionQueryResult { pub project_types: Vec, pub games: Vec, pub dependencies: Vec, - #[serde(flatten)] pub components: exp::VersionQuery, } diff --git a/apps/labrinth/src/database/redis.rs b/apps/labrinth/src/database/redis.rs deleted file mode 100644 index 0bc5f37231..0000000000 --- a/apps/labrinth/src/database/redis.rs +++ /dev/null @@ -1,56 +0,0 @@ -use std::sync::Arc; - -use crate::env::ENV; - -struct RedisConfig { - inner: xredis::RedisConfig, - cache_settings: xredis::CacheSettings, -} - -impl RedisConfig { - fn from_env() -> Result { - let inner = xredis::RedisConfig::new( - ENV.REDIS_TOPOLOGY, - ENV.REDIS_CONNECTION_TYPE, - &ENV.REDIS_URL, - ENV.REDIS_WAIT_TIMEOUT_MS, - ( - ENV.REDIS_MAX_CONNECTIONS as usize, - ENV.REDIS_MIN_CONNECTIONS, - ), - ( - ENV.REDIS_CLUSTER_MAX_CONNECTIONS as usize, - ENV.REDIS_CLUSTER_MIN_CONNECTIONS, - ), - (ENV.REDIS_BLOCKING_MAX_CONNECTIONS as usize, 0), - ENV.REDIS_CACHE_LOCKING_STRATEGY, - ENV.REDIS_READ_REPLICA_STRATEGY, - )?; - let cache_settings = xredis::CacheSettings { - default_expiry: ENV.REDIS_DEFAULT_EXPIRY, - actual_expiry: ENV.REDIS_ACTUAL_EXPIRY, - version_default_expiry: ENV.REDIS_VERSION_DEFAULT_EXPIRY, - version_actual_expiry: ENV.REDIS_VERSION_ACTUAL_EXPIRY, - encoding_format: ENV.REDIS_ENCODING_FORMAT, - compression_algorithm: ENV.REDIS_COMPRESSION_ALGORITHM, - compression_level: ENV.REDIS_COMPRESSION_LEVEL, - compression_threshold_bytes: ENV.REDIS_COMPRESSION_THRESHOLD_BYTES, - compression_min_savings_ratio: ENV - .REDIS_COMPRESSION_MIN_SAVINGS_RATIO, - }; - - Ok(Self { - inner, - cache_settings, - }) - } -} - -pub async fn from_env( - meta_namespace: impl Into>, -) -> xredis::RedisPool { - let config = RedisConfig::from_env().expect("invalid Redis configuration"); - xredis::RedisPool::new(meta_namespace, config.inner, config.cache_settings) - .await - .expect("failed to initialize Redis connections") -} diff --git a/apps/labrinth/src/database/redis/mod.rs b/apps/labrinth/src/database/redis/mod.rs new file mode 100644 index 0000000000..b2d4d4724f --- /dev/null +++ b/apps/labrinth/src/database/redis/mod.rs @@ -0,0 +1,411 @@ +use std::sync::Arc; + +use crate::env::ENV; +use eyre::{Result, WrapErr}; + +struct RedisConfig { + inner: xredis::RedisConfig, + cache_settings: xredis::CacheSettings, +} + +impl RedisConfig { + fn from_env() -> Result { + let inner = xredis::RedisConfig::new( + ENV.REDIS_TOPOLOGY, + ENV.REDIS_CONNECTION_TYPE, + &ENV.REDIS_URL, + ENV.REDIS_WAIT_TIMEOUT_MS, + ( + ENV.REDIS_MAX_CONNECTIONS as usize, + ENV.REDIS_MIN_CONNECTIONS, + ), + ( + ENV.REDIS_CLUSTER_MAX_CONNECTIONS as usize, + ENV.REDIS_CLUSTER_MIN_CONNECTIONS, + ), + (ENV.REDIS_BLOCKING_MAX_CONNECTIONS as usize, 0), + ENV.REDIS_CACHE_LOCKING_STRATEGY, + ENV.REDIS_READ_REPLICA_STRATEGY, + ) + .wrap_err("loading Redis configuration from environment")?; + let cache_settings = xredis::CacheSettings { + default_expiry: ENV.REDIS_DEFAULT_EXPIRY, + actual_expiry: ENV.REDIS_ACTUAL_EXPIRY, + version_default_expiry: ENV.REDIS_VERSION_DEFAULT_EXPIRY, + version_actual_expiry: ENV.REDIS_VERSION_ACTUAL_EXPIRY, + encoding_format: ENV.REDIS_ENCODING_FORMAT, + compression_algorithm: ENV.REDIS_COMPRESSION_ALGORITHM, + compression_level: ENV.REDIS_COMPRESSION_LEVEL, + compression_threshold_bytes: ENV.REDIS_COMPRESSION_THRESHOLD_BYTES, + compression_min_savings_ratio: ENV + .REDIS_COMPRESSION_MIN_SAVINGS_RATIO, + }; + + Ok(Self { + inner, + cache_settings, + }) + } +} + +pub async fn from_env( + meta_namespace: impl Into>, +) -> xredis::RedisPool { + let config = RedisConfig::from_env().expect("invalid Redis configuration"); + xredis::RedisPool::new(meta_namespace, config.inner, config.cache_settings) + .await + .expect("failed to initialize Redis connections") +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use ::serde::{Serialize, de::DeserializeOwned}; + use chrono::Utc; + use url::Url; + use uuid::Uuid; + use webauthn_rs::WebauthnBuilder; + + use crate::database::models::flow_item::DBFlow; + use crate::database::models::ids::{ + DBNotificationId, DBProjectId, DBTeamId, DBThreadId, DBUserId, + DBVersionId, LoaderFieldEnumId, LoaderFieldEnumValueId, LoaderFieldId, + LoaderId, + }; + use crate::database::models::loader_fields::{ + Loader, LoaderFieldEnumValue, LoaderMetadata, VersionField, + VersionFieldValue, + }; + use crate::database::models::notification_item::DBNotification; + use crate::database::models::project_item::{ + DBProject, ProjectQueryResult, + }; + use crate::database::models::version_item::{ + DBVersion, VersionQueryResult, + }; + use crate::models::billing::{Price, ProductMetadata}; + use crate::models::exp::{self, minecraft}; + use crate::models::notifications::NotificationBody; + use crate::models::projects::{ + MonetizationStatus, ProjectStatus, SideTypesMigrationReviewStatus, + VersionStatus, + }; + + fn postcard_round_trip(value: &T) -> T + where + T: Serialize + DeserializeOwned, + { + let serialized = + postcard::to_allocvec(value).expect("serializing with postcard"); + postcard::from_bytes(&serialized).expect("deserializing with postcard") + } + + fn loader_field_enum_value(ty: &str, major: bool) -> LoaderFieldEnumValue { + LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(1), + enum_id: LoaderFieldEnumId(2), + value: "1.21.8".to_string(), + ordering: None, + created: Utc::now(), + ty: Some(ty.to_string()), + major: Some(major), + } + } + + fn db_project() -> DBProject { + let now = Utc::now(); + + DBProject { + id: DBProjectId(1), + team_id: DBTeamId(2), + organization_id: None, + name: "project".to_string(), + summary: "summary".to_string(), + description: "description".to_string(), + published: now, + updated: now, + approved: Some(now), + queued: None, + status: ProjectStatus::Approved, + requested_status: None, + downloads: 3, + follows: 4, + icon_url: None, + raw_icon_url: None, + license_url: None, + license: "MIT".to_string(), + slug: Some("project".to_string()), + moderation_message: None, + moderation_message_body: None, + webhook_sent: false, + color: None, + monetization_status: MonetizationStatus::Monetized, + side_types_migration_review_status: + SideTypesMigrationReviewStatus::Reviewed, + loaders: vec!["fabric".to_string()], + components: exp::ProjectSerial::default(), + } + } + + fn db_version() -> DBVersion { + DBVersion { + id: DBVersionId(1), + project_id: DBProjectId(2), + author_id: DBUserId(3), + name: "version".to_string(), + version_number: "1.0.0".to_string(), + changelog: "changelog".to_string(), + date_published: Utc::now(), + downloads: 4, + version_type: "release".to_string(), + featured: true, + status: VersionStatus::Listed, + requested_status: None, + ordering: None, + components: exp::VersionSerial::default(), + } + } + + #[test] + fn loader_metadata_round_trips_with_postcard() { + for platform in [None, Some(false), Some(true)] { + let metadata = LoaderMetadata { platform }; + let round_tripped = postcard_round_trip(&metadata); + + assert_eq!(round_tripped.platform, platform); + } + } + + #[test] + fn loader_round_trips_with_postcard() { + for platform in [None, Some(false), Some(true)] { + let loader = Loader { + id: LoaderId(1), + loader: "paper".to_string(), + icon: "icon".to_string(), + supported_project_types: vec!["plugin".to_string()], + supported_games: vec!["minecraft-java".to_string()], + metadata: LoaderMetadata { platform }, + }; + let round_tripped = postcard_round_trip(&loader); + + assert_eq!(round_tripped.id, loader.id); + assert_eq!(round_tripped.loader, loader.loader); + assert_eq!(round_tripped.icon, loader.icon); + assert_eq!( + round_tripped.supported_project_types, + loader.supported_project_types + ); + assert_eq!(round_tripped.supported_games, loader.supported_games); + assert_eq!(round_tripped.metadata.platform, platform); + } + } + + #[test] + fn loader_field_enum_values_round_trip_with_postcard() { + let metadata_values = [ + (None, None), + (Some("snapshot"), Some(false)), + (Some("alpha"), Some(false)), + (Some("beta"), Some(true)), + (Some("release"), Some(true)), + (Some("beta"), Some(false)), + (Some("release"), Some(false)), + ]; + + for (ty, major) in metadata_values { + let enum_value = LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(1), + enum_id: LoaderFieldEnumId(2), + value: "1.21.8".to_string(), + ordering: None, + created: Utc::now(), + ty: ty.map(str::to_string), + major, + }; + + assert_eq!(postcard_round_trip(&enum_value), enum_value); + } + } + + #[test] + fn loader_field_enum_value_keeps_flattened_json_layout() { + let enum_value = loader_field_enum_value("release", true); + let json = serde_json::to_value(&enum_value) + .expect("serializing loader field enum value as JSON"); + + assert_eq!(json.get("type"), Some(&serde_json::json!("release"))); + assert_eq!(json.get("major"), Some(&serde_json::json!(true))); + assert!(json.get("metadata").is_none()); + assert_eq!( + serde_json::from_value::(json) + .expect("deserializing loader field enum value from JSON"), + enum_value + ); + } + + #[test] + fn non_enum_version_fields_round_trip_with_postcard() { + let values = [ + VersionFieldValue::Integer(5), + VersionFieldValue::Text("value".to_string()), + VersionFieldValue::Boolean(true), + VersionFieldValue::ArrayInteger(vec![1, 2]), + VersionFieldValue::ArrayText(vec!["one".to_string()]), + VersionFieldValue::ArrayBoolean(vec![true, false]), + ]; + + for value in values { + let field = VersionField { + version_id: DBVersionId(1), + field_id: LoaderFieldId(2), + field_name: "field".to_string(), + value, + }; + + assert_eq!(postcard_round_trip(&field), field); + } + } + + #[test] + fn flattened_cache_values_round_trip_with_postcard() { + let enum_value = loader_field_enum_value("release", true); + postcard_round_trip(&enum_value); + + let version = VersionQueryResult { + inner: db_version(), + files: Vec::new(), + version_fields: vec![VersionField { + version_id: DBVersionId(1), + field_id: LoaderFieldId(2), + field_name: "game_versions".to_string(), + value: VersionFieldValue::Enum( + LoaderFieldEnumId(2), + enum_value, + ), + }], + loaders: vec!["fabric".to_string()], + project_types: vec!["mod".to_string()], + games: vec!["minecraft-java".to_string()], + dependencies: Vec::new(), + components: exp::VersionQuery::default(), + }; + postcard_round_trip(&version); + + let project = ProjectQueryResult { + inner: db_project(), + categories: Vec::new(), + additional_categories: Vec::new(), + versions: vec![DBVersionId(1)], + project_types: vec!["mod".to_string()], + games: vec!["minecraft-java".to_string()], + urls: Vec::new(), + gallery_items: Vec::new(), + thread_id: DBThreadId(1), + aggregate_version_fields: Vec::new(), + components: exp::ProjectQuery::default(), + }; + postcard_round_trip(&project); + } + + #[test] + fn skipped_cache_fields_round_trip_with_postcard() { + postcard_round_trip(&exp::ProjectSerial::default()); + postcard_round_trip(&exp::ProjectQuery::default()); + postcard_round_trip(&db_project()); + } + + #[test] + fn internally_tagged_cache_values_round_trip_with_postcard() { + for metadata in [ + ProductMetadata::Midas, + ProductMetadata::Pyro { + cpu: 1, + ram: 2, + swap: 3, + storage: 4, + }, + ProductMetadata::Medal { + cpu: 1, + ram: 2, + swap: 3, + storage: 4, + region: "us-east".to_string(), + }, + ] { + postcard_round_trip(&metadata); + } + + for price in [ + Price::OneTime { price: 500 }, + Price::Recurring { + intervals: HashMap::new(), + }, + ] { + postcard_round_trip(&price); + } + + postcard_round_trip(&NotificationBody::TwoFactorEnabled); + postcard_round_trip(&DBNotification { + id: DBNotificationId(1), + user_id: DBUserId(2), + body: NotificationBody::TwoFactorEnabled, + read: false, + created: Utc::now(), + }); + postcard_round_trip(&minecraft::ServerContent::Vanilla { + supported_game_versions: vec!["1.21.8".to_string()], + recommended_game_version: Some("1.21.8".to_string()), + }); + postcard_round_trip(&minecraft::ServerContentQuery::Vanilla { + supported_game_versions: vec!["1.21.8".to_string()], + recommended_game_version: Some("1.21.8".to_string()), + }); + } + + #[test] + fn simple_flow_variants_round_trip_with_postcard() { + assert!(matches!( + postcard_round_trip(&DBFlow::MinecraftAuth), + DBFlow::MinecraftAuth + )); + + let flow = postcard_round_trip(&DBFlow::Login2FA { + user_id: DBUserId(1), + }); + assert!(matches!( + flow, + DBFlow::Login2FA { + user_id: DBUserId(1) + } + )); + } + + #[test] + fn passkey_flow_variants_round_trip_with_postcard() { + let origin = Url::parse("https://example.com").unwrap(); + let webauthn = WebauthnBuilder::new("example.com", &origin) + .unwrap() + .build() + .unwrap(); + let (_, registration) = webauthn + .start_passkey_registration( + Uuid::from_u128(1), + "user@example.com", + "user", + None, + ) + .unwrap(); + postcard_round_trip(&DBFlow::RegisterPasskey { + user_id: DBUserId(1), + state: registration, + }); + + let (_, authentication) = + webauthn.start_discoverable_authentication().unwrap(); + postcard_round_trip(&DBFlow::AuthenticatePasskey { + state: authentication, + }); + } +} diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index 88978d006a..9ddbdb9c24 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -165,7 +165,7 @@ vars! { REDIS_BLOCKING_MAX_CONNECTIONS: u32 = 256u32; // The encoding format used for Redis cache values. - REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Json; + REDIS_ENCODING_FORMAT: xredis::EncodingFormat = xredis::EncodingFormat::Postcard; // The level of LZ4 compression used for Redis cache values. A value of 0 disables compression (supports 1-12) REDIS_COMPRESSION_LEVEL: i32 = 0i32; // The compression algorithm used for Redis cache values. Currently only LZ4 is supported. diff --git a/apps/labrinth/src/models/exp/minecraft.rs b/apps/labrinth/src/models/exp/minecraft.rs index 3b8fb5508c..54f7e037c5 100644 --- a/apps/labrinth/src/models/exp/minecraft.rs +++ b/apps/labrinth/src/models/exp/minecraft.rs @@ -3,6 +3,7 @@ use std::time::Duration; use chrono::{DateTime, Utc}; use eyre::Result; use serde::{Deserialize, Serialize}; +use serde_binhum::serde_binhum; use tracing::warn; use validator::Validate; @@ -324,7 +325,8 @@ impl ComponentEdit for JavaServerProjectEdit { } /// What game content a [`JavaServerProject`] is using. -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Clone)] +#[serde_binhum(schema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ServerContent { /// Server runs modded content with a modpack found on the Modrinth platform. @@ -346,7 +348,8 @@ pub enum ServerContent { } /// What game content a [`JavaServerProject`] is using. -#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +#[derive(Debug, Clone)] +#[serde_binhum(schema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ServerContentQuery { /// Server runs modded content with a modpack found on the Modrinth platform. diff --git a/apps/labrinth/src/models/exp/project.rs b/apps/labrinth/src/models/exp/project.rs index 46694b2ec8..4dcc06fcab 100644 --- a/apps/labrinth/src/models/exp/project.rs +++ b/apps/labrinth/src/models/exp/project.rs @@ -54,7 +54,7 @@ macro_rules! define_project_components { pub struct ProjectSerial { $( #[validate(nested)] - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde(default)] pub $field_name: Option<$ty>, )* } @@ -114,7 +114,6 @@ macro_rules! define_project_components { #[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)] pub struct ProjectQuery { $( - #[serde(skip_serializing_if = "Option::is_none")] pub $field_name: Option>, )* } diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs index c3ef13facb..2557184e6b 100644 --- a/apps/labrinth/src/models/v3/billing.rs +++ b/apps/labrinth/src/models/v3/billing.rs @@ -4,6 +4,7 @@ use crate::models::ids::{ use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_binhum::serde_binhum; use std::collections::HashMap; #[derive(Serialize, Deserialize)] @@ -14,7 +15,7 @@ pub struct Product { pub unitary: bool, } -#[derive(Serialize, Deserialize)] +#[serde_binhum] #[serde(tag = "type", rename_all = "kebab-case")] pub enum ProductMetadata { Midas, @@ -55,7 +56,7 @@ pub struct ProductPrice { pub currency_code: String, } -#[derive(Serialize, Deserialize)] +#[serde_binhum] #[serde(tag = "type", rename_all = "kebab-case")] pub enum Price { OneTime { diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs index 6a655958f9..12598e7fed 100644 --- a/apps/labrinth/src/models/v3/notifications.rs +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -12,6 +12,7 @@ use crate::routes::ApiError; use ariadne::ids::UserId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_binhum::serde_binhum; use uuid::Uuid; #[derive(Serialize, Deserialize)] @@ -151,7 +152,8 @@ impl NotificationType { } } -#[derive(Serialize, Deserialize, Clone)] +#[derive(Clone)] +#[serde_binhum] #[serde(tag = "type", rename_all = "snake_case")] pub enum NotificationBody { ProjectUpdate { diff --git a/apps/labrinth/src/queue/analytics/cache.rs b/apps/labrinth/src/queue/analytics/cache.rs index 386cbdaad5..a82c69be29 100644 --- a/apps/labrinth/src/queue/analytics/cache.rs +++ b/apps/labrinth/src/queue/analytics/cache.rs @@ -12,7 +12,7 @@ use crate::{ routes::analytics::MINECRAFT_SERVER_PLAYS, util::error::Context, }; -pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v3"; +pub const MINECRAFT_SERVER_ANALYTICS: &str = "minecraft_server_analytics:v4"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MinecraftServerAnalytics { diff --git a/apps/labrinth/src/queue/analytics/mod.rs b/apps/labrinth/src/queue/analytics/mod.rs index 18766e48da..91c3f553d2 100644 --- a/apps/labrinth/src/queue/analytics/mod.rs +++ b/apps/labrinth/src/queue/analytics/mod.rs @@ -4,6 +4,7 @@ use crate::models::analytics::{ }; use crate::routes::ApiError; use crate::routes::analytics::MINECRAFT_SERVER_PLAYS; +use crate::util::error::Context; use dashmap::{DashMap, DashSet}; use std::collections::HashMap; use tracing::trace; @@ -11,9 +12,9 @@ use xredis::RedisPool; pub mod cache; -const DOWNLOADS_NAMESPACE: &str = "downloads:v3"; -const VIEWS_NAMESPACE: &str = "views:v3"; -const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v3"; +const DOWNLOADS_NAMESPACE: &str = "downloads:v4"; +const VIEWS_NAMESPACE: &str = "views:v4"; +const MINECRAFT_SERVER_PLAYS_NAMESPACE: &str = "minecraft_server_plays:v4"; const MINECRAFT_SERVER_PLAYS_EXPIRY: u64 = 86_400; // 24 hours const MINECRAFT_SERVER_PLAYS_LIMIT: u32 = 5; @@ -142,10 +143,15 @@ impl AnalyticsQueue { ) }) .collect::>(); - let mut redis_connection = redis.connect().await?; + let mut redis_connection = + redis.connect().await.wrap_internal_err( + "connecting to redis for server play counts", + )?; - let results = - redis_connection.get_many_typed::(&redis_keys).await?; + let results = redis_connection + .get_many_typed::(&redis_keys) + .await + .wrap_internal_err("fetching server play counts from redis")?; for (idx, count) in results.into_iter().enumerate() { let new_count = if let Some(count) = count { if count >= MINECRAFT_SERVER_PLAYS_LIMIT { @@ -164,7 +170,8 @@ impl AnalyticsQueue { new_count, Some(MINECRAFT_SERVER_PLAYS_EXPIRY as i64), ) - .await?; + .await + .wrap_internal_err("writing server play count to redis")?; } let mut plays = client @@ -198,10 +205,15 @@ impl AnalyticsQueue { ) }) .collect::>(); - let mut redis_connection = redis.connect().await?; + let mut redis_connection = redis + .connect() + .await + .wrap_internal_err("connecting to redis for view counts")?; - let results = - redis_connection.get_many_typed::(&redis_keys).await?; + let results = redis_connection + .get_many_typed::(&redis_keys) + .await + .wrap_internal_err("fetching view counts from redis")?; for (idx, count) in results.into_iter().enumerate() { let new_count = if let Some((views, monetized)) = raw_views.get_mut(idx) { @@ -226,7 +238,8 @@ impl AnalyticsQueue { let key = &redis_keys[idx]; redis_connection .set(key, new_count, Some(6 * 60 * 60)) - .await?; + .await + .wrap_internal_err("writing view count to redis")?; } let mut views = client.insert::("views").await?; @@ -267,10 +280,15 @@ impl AnalyticsQueue { ) }) .collect::>(); - let mut redis_connection = redis.connect().await?; + let mut redis_connection = redis + .connect() + .await + .wrap_internal_err("connecting to redis for download counts")?; - let results = - redis_connection.get_many_typed::(&redis_keys).await?; + let results = redis_connection + .get_many_typed::(&redis_keys) + .await + .wrap_internal_err("fetching download counts from redis")?; for (idx, count) in results.into_iter().enumerate() { let new_count = if let Some(count) = count { if count > 5 { @@ -286,7 +304,8 @@ impl AnalyticsQueue { let key = &redis_keys[idx]; redis_connection .set(key, new_count, Some(6 * 60 * 60)) - .await?; + .await + .wrap_internal_err("writing download count to redis")?; } let mut transaction = pool.begin().await?; diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs index 75e8728260..070095c0b7 100644 --- a/apps/labrinth/src/queue/server_ping.rs +++ b/apps/labrinth/src/queue/server_ping.rs @@ -26,9 +26,9 @@ pub struct ServerPingQueue { pub incremental_search_queue: IncrementalSearchQueue, } -pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v3"; +pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping:v4"; pub const REDIS_FAILURE_NAMESPACE: &str = - "minecraft_java_server_ping_failures:v3"; + "minecraft_java_server_ping_failures:v4"; pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings"; impl ServerPingQueue { diff --git a/apps/labrinth/src/routes/internal/campaign.rs b/apps/labrinth/src/routes/internal/campaign.rs index b440558cc5..5da7ebc414 100644 --- a/apps/labrinth/src/routes/internal/campaign.rs +++ b/apps/labrinth/src/routes/internal/campaign.rs @@ -68,7 +68,7 @@ pub struct CampaignInfo { cached_at: DateTime, } -const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info:v3"; +const CAMPAIGN_INFO_CACHE_NAMESPACE: &str = "campaign_info:v4"; const CAMPAIGN_INFO_CACHE_STALE_SECONDS: i64 = 15 * 60; const CAMPAIGN_INFO_CACHE_TTL_SECONDS: i64 = 24 * 60 * 60; diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index e9effeb083..fd70c60c55 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -2213,7 +2213,7 @@ async fn validate_2fa_code( ) .map_err(|_| AuthenticationError::InvalidCredentials)?; - const TOTP_NAMESPACE: &str = "used_totp:v3"; + const TOTP_NAMESPACE: &str = "used_totp:v4"; let mut conn = redis.connect().await?; let logical_key = format!("{}-{}", input, user_id.0); let key = redis diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index fc75849d22..e9e606deba 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -267,12 +267,6 @@ pub enum ApiError { }, } -impl From for ApiError { - fn from(error: xredis::Error) -> Self { - Self::Database(error.into()) - } -} - impl ApiError { pub fn delphi(err: impl Into) -> Self { Self::Delphi(err.into()) diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs index 6f3ef45283..56a86ca46d 100644 --- a/apps/labrinth/src/routes/v2/tags.rs +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -210,18 +210,9 @@ pub async fn game_version_list( .into_iter() .map(|f| GameVersionQueryData { version: f.value, - version_type: f - .metadata - .get("type") - .and_then(|m| m.as_str()) - .unwrap_or_default() - .to_string(), + version_type: f.ty.unwrap_or_default(), date: f.created, - major: f - .metadata - .get("major") - .and_then(|m| m.as_bool()) - .unwrap_or_default(), + major: f.major.unwrap_or_default(), }) .collect::>(); HttpResponse::Ok().json(fields) diff --git a/apps/labrinth/src/routes/v3/content/mod.rs b/apps/labrinth/src/routes/v3/content/mod.rs index 41807babd7..4d8cda3b91 100644 --- a/apps/labrinth/src/routes/v3/content/mod.rs +++ b/apps/labrinth/src/routes/v3/content/mod.rs @@ -23,8 +23,8 @@ use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use xredis::RedisPool; -const CONTENT_RESOLVE_CACHE_NAMESPACE: &str = "content_resolve:v3"; -const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat:v3"; +const CONTENT_RESOLVE_CACHE_NAMESPACE: &str = "content_resolve:v4"; +const CONTENT_RESOLVE_CACHE_HEAT_NAMESPACE: &str = "content_resolve_heat:v4"; const CONTENT_RESOLVE_CACHE_SCHEMA_VERSION: &str = "v3"; const CONTENT_RESOLVE_CACHE_HEAT_WINDOW_SECONDS: i64 = 60 * 60 * 24; diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs index 6a499df5df..4c1589cbbc 100644 --- a/apps/labrinth/src/routes/v3/tags.rs +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -6,6 +6,7 @@ use crate::database::models::categories::{ }; use crate::database::models::loader_fields::{ Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, + LoaderMetadata, }; use actix_web::{HttpResponse, get, web}; use xredis::RedisPool; @@ -103,7 +104,7 @@ pub struct LoaderData { pub supported_project_types: Vec, pub supported_games: Vec, pub supported_fields: Vec, // Available loader fields for this loader - pub metadata: Value, + pub metadata: LoaderMetadata, } #[utoipa::path(tag = "tags", responses((status = OK)))] diff --git a/apps/labrinth/src/search/indexing.rs b/apps/labrinth/src/search/indexing.rs index 03e6c34061..26ca2ba53b 100644 --- a/apps/labrinth/src/search/indexing.rs +++ b/apps/labrinth/src/search/indexing.rs @@ -394,11 +394,13 @@ async fn build_search_documents( let loader_field_enum_values: Vec = sqlx::query!( - " - SELECT DISTINCT id, enum_id, value, ordering, created, metadata + r#" + SELECT DISTINCT id, enum_id, value, ordering, created, + metadata->>'type' AS "ty?", + (metadata->>'major')::boolean AS "major?" FROM loader_field_enum_values lfev ORDER BY enum_id, ordering, created DESC - " + "# ) .fetch(pool) .map_ok(|m| QueryLoaderFieldEnumValue { @@ -407,7 +409,8 @@ async fn build_search_documents( value: m.value, ordering: m.ordering, created: m.created, - metadata: m.metadata, + ty: m.ty, + major: m.major, }) .try_collect() .await?; diff --git a/apps/labrinth/src/sync/friends.rs b/apps/labrinth/src/sync/friends.rs index a7327f38eb..32169677e8 100644 --- a/apps/labrinth/src/sync/friends.rs +++ b/apps/labrinth/src/sync/friends.rs @@ -14,7 +14,7 @@ use redis::{RedisWrite, ToRedisArgs, ToSingleRedisArg}; use serde::{Deserialize, Serialize}; use tokio::sync::mpsc; -pub const FRIENDS_CHANNEL_NAME: &str = "friends:v3"; +pub const FRIENDS_CHANNEL_NAME: &str = "friends:v4"; #[derive(Serialize, Deserialize)] pub enum RedisFriendsMessage { diff --git a/apps/labrinth/src/sync/status.rs b/apps/labrinth/src/sync/status.rs index 33f6bc3881..98375dccb7 100644 --- a/apps/labrinth/src/sync/status.rs +++ b/apps/labrinth/src/sync/status.rs @@ -5,7 +5,7 @@ use redis::AsyncCommands; use xredis::RedisPool; const EXPIRY_TIME_SECONDS: i64 = 60; -const USER_STATUS_NAMESPACE: &str = "user_status:v3"; +const USER_STATUS_NAMESPACE: &str = "user_status:v4"; pub async fn get_user_status( user: UserId, diff --git a/apps/labrinth/src/util/gotenberg.rs b/apps/labrinth/src/util/gotenberg.rs index 64d971b801..0c1a3248b0 100644 --- a/apps/labrinth/src/util/gotenberg.rs +++ b/apps/labrinth/src/util/gotenberg.rs @@ -14,7 +14,7 @@ pub const MODRINTH_GENERATED_PDF_TYPE: HeaderName = HeaderName::from_static("modrinth-generated-pdf-type"); pub const MODRINTH_PAYMENT_ID: HeaderName = HeaderName::from_static("modrinth-payment-id"); -pub const PAYMENT_STATEMENTS_NAMESPACE: &str = "payment_statements:v3"; +pub const PAYMENT_STATEMENTS_NAMESPACE: &str = "payment_statements:v4"; const REDIS_TIMEOUT_MARGIN_MS: u64 = 250; pub(crate) fn payment_statement_key( diff --git a/apps/labrinth/src/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs index 9088b6ad50..3bac6cabe8 100644 --- a/apps/labrinth/src/util/ratelimit.rs +++ b/apps/labrinth/src/util/ratelimit.rs @@ -12,7 +12,7 @@ use std::str::FromStr; use std::sync::Arc; use xredis::RedisPool; -const RATE_LIMIT_NAMESPACE: &str = "rate_limit:v3"; +const RATE_LIMIT_NAMESPACE: &str = "rate_limit:v4"; const RATE_LIMIT_EXPIRY: i64 = 300; // 5 minutes const MINUTE_IN_NANOS: i64 = 60_000_000_000; diff --git a/apps/labrinth/tests/loader_fields.rs b/apps/labrinth/tests/loader_fields.rs index 53a86bfac5..750fd18e91 100644 --- a/apps/labrinth/tests/loader_fields.rs +++ b/apps/labrinth/tests/loader_fields.rs @@ -574,12 +574,7 @@ async fn minecraft_game_version_update() { // A couple specific checks- in the dummy data, all game versions are marked as major=false except 1.20.5 let name_to_major = game_versions .iter() - .map(|x| { - ( - x.value.clone(), - x.metadata.get("major").unwrap().as_bool().unwrap(), - ) - }) + .map(|x| (x.value.clone(), x.major.unwrap())) .collect::>(); for (name, major) in name_to_major { if name == "1.20.5" { @@ -612,12 +607,7 @@ async fn minecraft_game_version_update() { let name_to_major = game_versions .iter() - .map(|x| { - ( - x.value.clone(), - x.metadata.get("major").unwrap().as_bool().unwrap(), - ) - }) + .map(|x| (x.value.clone(), x.major.unwrap())) .collect::>(); // Confirm that the new version is there assert!(name_to_major.contains_key("1.20.6")); diff --git a/apps/labrinth/tests/redis.rs b/apps/labrinth/tests/redis.rs index c812868fcf..bbd38fea94 100644 --- a/apps/labrinth/tests/redis.rs +++ b/apps/labrinth/tests/redis.rs @@ -22,7 +22,7 @@ use serde_json::json; use tokio::sync::{Barrier, Notify}; use tokio::time::timeout; use uuid::Uuid; -use xredis::{KeyBuilder, RedisPool, RedisTopology}; +use xredis::{KeyBuilder, RedisPool, RedisTopology, RedisValue}; pub mod common; @@ -250,7 +250,7 @@ async fn cache_lock_coalesces_concurrent_misses_for_one_key() { tasks.push(tokio::spawn(async move { barrier.wait().await; pool.get_cached_keys_raw( - "single_flight:v3", + "single_flight:v4", &["shared".to_string()], move |keys| async move { fetch_count.fetch_add(1, Ordering::SeqCst); @@ -292,7 +292,7 @@ async fn cache_lock_coalesces_only_overlapping_keys() { tasks.push(tokio::spawn(async move { barrier.wait().await; pool.get_cached_keys_raw( - "overlapping_locks:v3", + "overlapping_locks:v4", &requested, move |keys| async move { tokio::time::sleep(Duration::from_millis(75)).await; @@ -333,7 +333,7 @@ async fn cache_lock_does_not_block_independent_keys() { let slow = tokio::spawn(async move { slow_pool .get_cached_keys_raw( - "independent_locks:v3", + "independent_locks:v4", &["slow".to_string()], move |keys| async move { slow_started.notify_one(); @@ -350,7 +350,7 @@ async fn cache_lock_does_not_block_independent_keys() { let fast = timeout( Duration::from_secs(1), pool.get_cached_keys_raw( - "independent_locks:v3", + "independent_locks:v4", &["fast".to_string()], |keys| async move { let values = DashMap::new(); @@ -379,7 +379,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() { let failed = pool .get_cached_keys_raw( - "error_recovery:v3", + "error_recovery:v4", &["key".to_string()], |_| async { Err::, _>(DatabaseError::Internal( @@ -393,7 +393,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() { let recovered = timeout( Duration::from_secs(1), pool.get_cached_keys_raw( - "error_recovery:v3", + "error_recovery:v4", &["key".to_string()], |keys| async move { let values = DashMap::new(); @@ -413,7 +413,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() { let cancelled = tokio::spawn(async move { cancelled_pool .get_cached_keys_raw( - "cancellation_recovery:v3", + "cancellation_recovery:v4", &["key".to_string()], move |_| async move { cancelled_started.notify_one(); @@ -432,7 +432,7 @@ async fn cache_lock_is_released_after_error_and_cancellation() { let recovered = timeout( Duration::from_secs(1), pool.get_cached_keys_raw( - "cancellation_recovery:v3", + "cancellation_recovery:v4", &["key".to_string()], |keys| async move { let values = DashMap::new(); @@ -452,19 +452,19 @@ async fn cache_lock_is_released_after_error_and_cancellation() { #[actix_rt::test] async fn expired_cache_value_serves_waiter_while_writer_refreshes() { let pool = isolated_redis_pool("stale_while_revalidate").await; - let namespace = "stale_while_revalidate:v3"; + let namespace = "stale_while_revalidate:v4"; let logical_key = "key".to_string(); let mut connection = pool.connect().await.unwrap(); let redis_key = connection.key().entity(namespace, &logical_key); connection .set_serialized( &redis_key, - json!({ - "key": logical_key, - "alias": null, - "iat": 0, - "val": "stale", - }), + RedisValue::::new( + logical_key.clone(), + None, + 0, + "stale".to_string(), + ), None, ) .await @@ -539,8 +539,8 @@ async fn case_insensitive_slug_requests_share_one_cache_lock() { let requested = vec![requested]; barrier.wait().await; pool.get_cached_keys_raw_with_slug( - "slug_values:v3", - Some("slug_aliases:v3"), + "slug_values:v4", + Some("slug_aliases:v4"), false, &requested, move |_| async move { @@ -651,11 +651,11 @@ async fn many_get_routes_handle_cross_slot_cache_lifecycle() { redis.key().entity(VERSIONS_NAMESPACE, alpha_version_id), redis.key().entity(VERSIONS_NAMESPACE, beta_version_id), redis.key().entity( - "versions_files:v3", + "versions_files:v4", format!("sha1_{}", alpha.file_hash), ), redis.key().entity( - "versions_files:v3", + "versions_files:v4", format!("sha1_{}", beta.file_hash), ), ]; diff --git a/apps/labrinth/tests/tags.rs b/apps/labrinth/tests/tags.rs index 5ca1bb49ad..e2852d461a 100644 --- a/apps/labrinth/tests/tags.rs +++ b/apps/labrinth/tests/tags.rs @@ -47,12 +47,7 @@ async fn get_tags_v3() { let loader_metadata = loaders .into_iter() - .map(|x| { - ( - x.name, - x.metadata.get("platform").and_then(|x| x.as_bool()), - ) - }) + .map(|x| (x.name, x.metadata.platform)) .collect::>(); let loader_names = loader_metadata.keys().cloned().collect::>(); diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 0e571a6291..7d9980a041 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -1153,9 +1153,10 @@ export namespace Labrinth { side_types_migration_review_status: 'reviewed' | 'pending' environment?: Environment[] - minecraft_server?: MinecraftServer - minecraft_java_server?: MinecraftJavaServer - minecraft_bedrock_server?: MinecraftBedrockServer + minecraft_server?: MinecraftServer | null + minecraft_java_server?: MinecraftJavaServer | null + minecraft_bedrock_server?: MinecraftBedrockServer | null + minecraft_mod?: unknown | null /** * @deprecated Not recommended to use. diff --git a/packages/serde-binhum/Cargo.toml b/packages/serde-binhum/Cargo.toml new file mode 100644 index 0000000000..d4d438f1b9 --- /dev/null +++ b/packages/serde-binhum/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "serde-binhum" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +darling = { workspace = true } +proc-macro2 = { workspace = true } +quote = { workspace = true } +syn = { workspace = true, features = ["full"] } + +[lints] +workspace = true diff --git a/packages/serde-binhum/README.md b/packages/serde-binhum/README.md new file mode 100644 index 0000000000..c30ddd502c --- /dev/null +++ b/packages/serde-binhum/README.md @@ -0,0 +1,154 @@ +Implement `serde::{Serialize, Deserialize}` on a type with different behavior for human-readable and non-human-readable (binary) formats. + +# Motivation + +Serde has the concept of human-readable and non-human-readable de/serializers. Human-readable ones, like JSON, are - well - readable by humans, and are usually verbose and self-describing. Human-readable format deserializers implement `deserialize_any`, which lets Serde do more complicated things like internally tagged enums and `serde(flatten)`. However, binary formats, like Postcard, cannot implement `deserialize_any`, and attempting to deserialize a value using `serde(flatten)` using one of these will fail. + +```rust +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind")] +enum MyEnum { + Foo, + Bar { + x: i32, + #[serde(flatten)] + data: BarData, + }, +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct BarData { + y: i32, +} + +let error = postcard::to_allocvec(&MyEnum::Bar { + x: 1, + data: BarData { y: 2 }, +}) +.unwrap_err(); +``` + +To fix this, we can generate two `De/Serialize` implementations: one for human-readable, and another for binary formats. Then, the `De/Serialize` impl on the actual type will delegate to one of those two: + +```rust +enum MyEnum { + Foo, + Bar { + x: i32, + data: BarData, + }, +} + +// note: this type doesn't use any features which require `deserialize_any`, +// so it can safely derive `De/Serialize` as normal +#[derive(serde::Serialize, serde::Deserialize)] +struct BarData { + y: i32, +} + +const _: () = { + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(remote = "MyEnum", tag = "kind")] + enum MyEnumHumanProxy { + Foo, + Bar { + x: i32, + #[serde(flatten)] + data: BarData, + }, + } + + #[derive(serde::Serialize, serde::Deserialize)] + #[serde(remote = "MyEnum")] + enum MyEnumBinaryProxy { + Foo, + Bar { + x: i32, + data: BarData, + }, + } + + impl serde::Serialize for MyEnum { + fn serialize( + &self, + serializer: S, + ) -> Result + { + if serializer.is_human_readable() { + MyEnumHumanProxy::serialize(self, serializer) + } else { + MyEnumBinaryProxy::serialize(self, serializer) + } + } + } + + impl<'de> serde::Deserialize<'de> for MyEnum { + fn deserialize>( + deserializer: D, + ) -> Result + { + if deserializer.is_human_readable() { + MyEnumHumanProxy::deserialize(deserializer) + } else { + MyEnumBinaryProxy::deserialize(deserializer) + } + } + } +}; +``` + +# Usage + +Add `#[serde_binhum::serde_binhum]` on a type, and remove `#[derive(serde::Serialize, serde::Deserialize)]`. + +Ordinary `#[serde(...)]` attributes describe the human-readable representation. `serde-binhum` removes attributes such as `tag` and `flatten` from the generated binary proxy: + +```rust +#[serde_binhum::serde_binhum] +#[derive(Debug, PartialEq)] +#[serde(tag = "kind")] +enum MyEnum { + Foo, + Bar { + x: i32, + #[serde(flatten)] + data: BarData, + }, +} + +#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] +struct BarData { + y: i32, +} + +let value = MyEnum::Bar { + x: 1, + data: BarData { y: 2 }, +}; + +let json = serde_json::to_value(&value).unwrap(); +assert_eq!( + json, + serde_json::json!({ + "kind": "Bar", + "x": 1, + "y": 2, + }), +); + +let bytes = postcard::to_allocvec(&value).unwrap(); +let decoded = postcard::from_bytes::(&bytes).unwrap(); +assert_eq!(decoded, value); +``` + +Representation-specific Serde options can be added with `human(...)` and `binary(...)`: + +```rust +#[serde_binhum::serde_binhum] +struct Value { + #[serde_binhum(human(flatten), binary(with = "binary_data"))] + data: Data, +} +``` + +Use `#[serde_binhum::serde_binhum(schema)]` to forward `utoipa::PartialSchema` and `utoipa::ToSchema` to the human-readable proxy. diff --git a/packages/serde-binhum/src/lib.rs b/packages/serde-binhum/src/lib.rs new file mode 100644 index 0000000000..6fa3db8389 --- /dev/null +++ b/packages/serde-binhum/src/lib.rs @@ -0,0 +1,390 @@ +#![doc = include_str!("../README.md")] + +use darling::{FromMeta, ast::NestedMeta}; +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::punctuated::Punctuated; +use syn::{ + Attribute, Error, Fields, Ident, Item, ItemEnum, ItemStruct, Meta, Path, + Result, Token, parse_macro_input, parse_quote, +}; + +#[derive(Default, FromMeta)] +struct Args { + #[darling(default)] + schema: bool, +} + +#[derive(Default, FromMeta)] +struct AttributeArgs { + #[darling(default)] + human: Option, + #[darling(default)] + binary: Option, +} + +struct SerdeOptions(Vec); + +impl FromMeta for SerdeOptions { + fn from_list(items: &[NestedMeta]) -> darling::Result { + let options = items + .iter() + .map(|item| match item { + NestedMeta::Meta(option) => Ok(option.clone()), + NestedMeta::Lit(literal) => { + Err(darling::Error::custom("expected a Serde option") + .with_span(literal)) + } + }) + .collect::>>()?; + Ok(Self(options)) + } +} + +#[derive(Clone, Copy)] +enum Representation { + Human, + Binary, +} + +#[proc_macro_attribute] +/// Implements `Serialize` and `Deserialize` through generated human and binary +/// remote proxies. +/// +/// The annotated type must not derive either trait itself. Ordinary +/// `#[serde(...)]` attributes define the human-readable representation. +/// +/// # Attributes +/// +/// - **`#[serde_binhum]`**: Generates human-readable and binary Serde proxies +/// and delegates `Serialize` and `Deserialize` to the appropriate proxy. +/// - **`#[serde_binhum(schema)]`**: Also forwards `utoipa::PartialSchema` and +/// `utoipa::ToSchema` to the human-readable proxy. +/// - **`#[serde_binhum(human(...))]`**: Adds the enclosed Serde options only +/// to the human-readable proxy. This can be placed on the type, a variant, or +/// a field. +/// - **`#[serde_binhum(binary(...))]`**: Adds the enclosed Serde options only +/// to the binary proxy. This can be placed on the type, a variant, or a field. +pub fn serde_binhum(args: TokenStream, input: TokenStream) -> TokenStream { + let args = match NestedMeta::parse_meta_list(args.into()) + .map_err(darling::Error::from) + .and_then(|args| Args::from_list(&args)) + { + Ok(args) => args, + Err(error) => return error.write_errors().into(), + }; + let item = parse_macro_input!(input as Item); + + expand(args, item) + .unwrap_or_else(|error| error.to_compile_error()) + .into() +} + +fn expand(args: Args, item: Item) -> Result { + match item { + Item::Enum(item) => expand_enum(args, item), + Item::Struct(item) => expand_struct(args, item), + item => Err(Error::new_spanned( + item, + "`serde_binhum` only supports structs and enums", + )), + } +} + +fn expand_enum( + args: Args, + mut item: ItemEnum, +) -> Result { + validate_item(&item.ident, &item.generics.params, &item.attrs)?; + + let ident = item.ident.clone(); + let human = enum_proxy(&item, Representation::Human, args.schema)?; + let binary = enum_proxy(&item, Representation::Binary, false)?; + clean_attributes(&mut item.attrs); + for variant in &mut item.variants { + clean_attributes(&mut variant.attrs); + clean_fields(&mut variant.fields); + } + let implementations = implementations(&ident, args.schema); + + Ok(quote! { + #item + + const _: () = { + #human + #binary + #implementations + }; + }) +} + +fn expand_struct( + args: Args, + mut item: ItemStruct, +) -> Result { + validate_item(&item.ident, &item.generics.params, &item.attrs)?; + + let ident = item.ident.clone(); + let human = struct_proxy(&item, Representation::Human, args.schema)?; + let binary = struct_proxy(&item, Representation::Binary, false)?; + clean_attributes(&mut item.attrs); + clean_fields(&mut item.fields); + let implementations = implementations(&ident, args.schema); + + Ok(quote! { + #item + + const _: () = { + #human + #binary + #implementations + }; + }) +} + +fn validate_item( + ident: &Ident, + generics: &Punctuated, + attrs: &[Attribute], +) -> Result<()> { + if !generics.is_empty() { + return Err(Error::new_spanned( + generics, + "`serde_binhum` does not yet support generic types", + )); + } + + for attr in attrs.iter().filter(|attr| attr.path().is_ident("derive")) { + let derives = attr + .parse_args_with(Punctuated::::parse_terminated)?; + for derive in derives { + let Some(name) = derive.segments.last() else { + continue; + }; + if name.ident == "Serialize" || name.ident == "Deserialize" { + return Err(Error::new_spanned( + derive, + format!( + "`{ident}` must not derive `Serialize` or `Deserialize`; `#[serde_binhum]` implements both" + ), + )); + } + } + } + + Ok(()) +} + +fn enum_proxy( + item: &ItemEnum, + representation: Representation, + schema: bool, +) -> Result { + let mut proxy = item.clone(); + proxy.ident = match representation { + Representation::Human => format_ident!("HumanProxy"), + Representation::Binary => format_ident!("BinaryProxy"), + }; + proxy.vis = syn::Visibility::Inherited; + proxy.attrs = proxy_item_attributes( + &item.attrs, + &item.ident, + representation, + schema, + )?; + + for variant in &mut proxy.variants { + variant.attrs = proxy_attributes(&variant.attrs, representation)?; + proxy_fields(&mut variant.fields, representation)?; + } + + Ok(proxy) +} + +fn struct_proxy( + item: &ItemStruct, + representation: Representation, + schema: bool, +) -> Result { + let mut proxy = item.clone(); + proxy.ident = match representation { + Representation::Human => format_ident!("HumanProxy"), + Representation::Binary => format_ident!("BinaryProxy"), + }; + proxy.vis = syn::Visibility::Inherited; + proxy.attrs = proxy_item_attributes( + &item.attrs, + &item.ident, + representation, + schema, + )?; + proxy_fields(&mut proxy.fields, representation)?; + + Ok(proxy) +} + +fn proxy_item_attributes( + attrs: &[Attribute], + remote: &Ident, + representation: Representation, + schema: bool, +) -> Result> { + let mut attrs = proxy_attributes(attrs, representation)?; + let derive = if schema { + parse_quote!(#[derive(serde::Deserialize, serde::Serialize, utoipa::ToSchema)]) + } else { + parse_quote!(#[derive(serde::Deserialize, serde::Serialize)]) + }; + let remote = remote.to_string(); + let remote: Attribute = parse_quote!(#[serde(remote = #remote)]); + attrs.insert(0, remote); + attrs.insert(0, derive); + Ok(attrs) +} + +fn proxy_fields( + fields: &mut Fields, + representation: Representation, +) -> Result<()> { + for field in fields { + field.attrs = proxy_attributes(&field.attrs, representation)?; + } + Ok(()) +} + +fn proxy_attributes( + attrs: &[Attribute], + representation: Representation, +) -> Result> { + let mut output = Vec::new(); + let mut representation_options = Vec::new(); + + for attr in attrs { + if attr.path().is_ident("serde") { + let mut options = attr + .parse_args_with( + Punctuated::::parse_terminated, + )? + .into_iter() + .collect::>(); + if matches!(representation, Representation::Binary) { + options.retain(|option| !binary_incompatible(option)); + } + if !options.is_empty() { + output.push(parse_quote!(#[serde(#(#options),*)])); + } + } else if attr.path().is_ident("serde_binhum") { + representation_options + .extend(parse_representation_options(attr, representation)?); + } else if attr.path().is_ident("doc") || attr.path().is_ident("schema") + { + output.push(attr.clone()); + } + } + + if !representation_options.is_empty() { + output.push(parse_quote!(#[serde(#(#representation_options),*)])); + } + + Ok(output) +} + +fn parse_representation_options( + attr: &Attribute, + representation: Representation, +) -> Result> { + let args = AttributeArgs::from_meta(&attr.meta) + .map_err(|error| Error::new(error.span(), error.to_string()))?; + if args.human.is_none() && args.binary.is_none() { + return Err(Error::new_spanned( + attr, + "expected `human(...)` or `binary(...)`", + )); + } + + Ok(match representation { + Representation::Human => args.human, + Representation::Binary => args.binary, + } + .map_or_else(Vec::new, |options| options.0)) +} + +fn binary_incompatible(option: &Meta) -> bool { + let path = option.path(); + path.is_ident("flatten") + || path.is_ident("tag") + || path.is_ident("content") + || path.is_ident("untagged") + || path.is_ident("skip_serializing_if") +} + +fn clean_fields(fields: &mut Fields) { + for field in fields { + clean_attributes(&mut field.attrs); + } +} + +fn clean_attributes(attrs: &mut Vec) { + attrs.retain(|attr| { + !attr.path().is_ident("serde") && !attr.path().is_ident("serde_binhum") + }); +} + +fn implementations(ident: &Ident, schema: bool) -> proc_macro2::TokenStream { + let schema = schema.then(|| { + quote! { + impl ::utoipa::PartialSchema for #ident { + fn schema() + -> ::utoipa::openapi::RefOr<::utoipa::openapi::schema::Schema> { + ::schema() + } + } + + impl ::utoipa::ToSchema for #ident { + fn schemas( + schemas: &mut ::std::vec::Vec<( + ::std::string::String, + ::utoipa::openapi::RefOr<::utoipa::openapi::schema::Schema>, + )>, + ) { + ::schemas(schemas); + } + } + } + }); + + quote! { + impl ::serde::Serialize for #ident { + fn serialize( + &self, + serializer: S, + ) -> ::std::result::Result + where + S: ::serde::Serializer, + { + if serializer.is_human_readable() { + HumanProxy::serialize(self, serializer) + } else { + BinaryProxy::serialize(self, serializer) + } + } + } + + impl<'de> ::serde::Deserialize<'de> for #ident { + fn deserialize( + deserializer: D, + ) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + if deserializer.is_human_readable() { + HumanProxy::deserialize(deserializer) + } else { + BinaryProxy::deserialize(deserializer) + } + } + } + + #schema + } +} diff --git a/packages/xredis/Cargo.toml b/packages/xredis/Cargo.toml index 7f9fd2035a..cc87779098 100644 --- a/packages/xredis/Cargo.toml +++ b/packages/xredis/Cargo.toml @@ -10,8 +10,10 @@ ariadne = { workspace = true } chrono = { workspace = true } dashmap = { workspace = true } deadpool-redis = { workspace = true, features = ["cluster-async"] } +eyre = { workspace = true } futures = { workspace = true } lz4_flex = { workspace = true } +postcard = { workspace = true } prometheus = { workspace = true } redis = { workspace = true, features = [ "ahash", @@ -20,7 +22,6 @@ redis = { workspace = true, features = [ "tokio-comp" ] } serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "sync", "time"] } tracing = { workspace = true } diff --git a/packages/xredis/src/blocking.rs b/packages/xredis/src/blocking.rs index 7a7b7f4e0f..5aaf62d772 100644 --- a/packages/xredis/src/blocking.rs +++ b/packages/xredis/src/blocking.rs @@ -1,14 +1,14 @@ use std::time::Duration; +use eyre::{Result, WrapErr, bail}; use prometheus::Registry; +use super::RedisPool; use super::config::{RedisConfig, RedisTopology}; -use super::connection::RedisBackendBuildError; use super::metrics::{ LogicalPoolStatus, LogicalPoolStatusProvider, register_blocking_pool_metrics, }; -use super::{Error, RedisPool}; const POOL_RETAIN_INTERVAL: Duration = Duration::from_secs(30); const MAX_IDLE_CONNECTION_AGE: Duration = Duration::from_secs(5 * 60); @@ -27,9 +27,7 @@ enum RedisBlockingPoolInner { } impl RedisBlockingPool { - pub(super) async fn new( - config: &RedisConfig, - ) -> Result { + pub(super) async fn new(config: &RedisConfig) -> Result { let pool_size = config.blocking_pool_size(); let inner = match config.topology() { RedisTopology::Standalone => { @@ -39,14 +37,16 @@ impl RedisBlockingPool { let manager = deadpool_redis::Manager::new_with_config( config.seed_urls()[0].clone(), connection_config, - )?; + ) + .wrap_err("configuring standalone blocking Redis client")?; let pool = deadpool_redis::Pool::builder(manager) .max_size(pool_size.max()) .wait_timeout(Some(Duration::from_millis( config.wait_timeout_ms(), ))) .runtime(deadpool_redis::Runtime::Tokio1) - .build()?; + .build() + .wrap_err("building standalone blocking Redis pool")?; retain_standalone_pool(pool.clone()); RedisBlockingPoolInner::Standalone(pool) } @@ -54,14 +54,16 @@ impl RedisBlockingPool { let manager = deadpool_redis::cluster::Manager::new( config.seed_urls().to_vec(), false, - )?; + ) + .wrap_err("configuring clustered blocking Redis client")?; let pool = deadpool_redis::cluster::Pool::builder(manager) .max_size(pool_size.max()) .wait_timeout(Some(Duration::from_millis( config.wait_timeout_ms(), ))) .runtime(deadpool_redis::Runtime::Tokio1) - .build()?; + .build() + .wrap_err("building clustered blocking Redis pool")?; retain_cluster_pool(pool.clone()); RedisBlockingPoolInner::Cluster(pool) } @@ -70,10 +72,7 @@ impl RedisBlockingPool { Ok(Self { inner }) } - pub(super) fn register_metrics( - &self, - registry: &Registry, - ) -> Result<(), prometheus::Error> { + pub(super) fn register_metrics(&self, registry: &Registry) -> Result<()> { register_blocking_pool_metrics(registry, self.clone()) } @@ -81,22 +80,33 @@ impl RedisBlockingPool { &self, key: &str, timeout: Duration, - ) -> Result; 2]>, Error> { + ) -> Result; 2]>> { if timeout.is_zero() { - return Err(Error::InvalidBlockingTimeout); + bail!("redis blocking timeout must be greater than zero"); } let mut command = redis::cmd("BRPOP"); command.arg(key).arg(timeout.as_secs_f64()); - let response: Option<(Vec, Vec)> = match &self.inner { - RedisBlockingPoolInner::Standalone(pool) => { - command.query_async(&mut pool.get().await?).await? - } - RedisBlockingPoolInner::Cluster(pool) => { - command.query_async(&mut pool.get().await?).await? - } - }; + let response: Option<(Vec, Vec)> = + match &self.inner { + RedisBlockingPoolInner::Standalone(pool) => { + let mut connection = pool.get().await.wrap_err( + "fetching standalone blocking Redis connection", + )?; + command.query_async(&mut connection).await.wrap_err( + "reading from standalone Redis blocking queue", + )? + } + RedisBlockingPoolInner::Cluster(pool) => { + let mut connection = pool.get().await.wrap_err( + "fetching clustered blocking Redis connection", + )?; + command.query_async(&mut connection).await.wrap_err( + "reading from clustered Redis blocking queue", + )? + } + }; Ok(response.map(|(key, value)| [key, value])) } @@ -120,7 +130,7 @@ impl RedisPool { &self, key: &str, timeout: Duration, - ) -> Result; 2]>, Error> { + ) -> Result; 2]>> { self.blocking.brpop(key, timeout).await } } diff --git a/packages/xredis/src/cache.rs b/packages/xredis/src/cache.rs index 5e2aba678b..99e085e873 100644 --- a/packages/xredis/src/cache.rs +++ b/packages/xredis/src/cache.rs @@ -8,6 +8,7 @@ use std::str::FromStr; use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::{TimeZone, Utc}; use dashmap::DashMap; +use eyre::{Result, WrapErr, eyre}; use futures::stream::{FuturesUnordered, StreamExt}; use redis::aio::ConnectionLike; use serde::de::DeserializeOwned; @@ -16,8 +17,6 @@ use thiserror::Error; use tokio::time::{Instant, timeout_at}; use tracing::{Instrument, info_span}; -use crate::Error; - use super::commands; use super::connection::RoutableConnection; use super::key::KeyBuilder; @@ -33,9 +32,7 @@ const FILL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); pub(super) trait ConnectionProvider { type Connection: ConnectionLike + RoutableConnection; - fn connect( - &self, - ) -> impl Future> + Send; + fn connect(&self) -> impl Future> + Send; } #[derive(Clone, Copy)] @@ -53,7 +50,7 @@ pub enum Codec { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum EncodingFormat { - Json, + Postcard, } #[derive(Debug, Error)] @@ -92,7 +89,7 @@ impl FromStr for EncodingFormat { fn from_str(value: &str) -> Result { match value { - "json" => Ok(Self::Json), + "postcard" => Ok(Self::Postcard), _ => Err(InvalidEncodingFormat), } } @@ -112,12 +109,10 @@ pub struct CacheSettings { } impl CacheSettings { - pub fn encode_value( - &self, - value: &T, - ) -> Result, Error> { + pub fn encode_value(&self, value: &T) -> Result> { let mut value = match self.encoding_format { - EncodingFormat::Json => serde_json::to_vec(value)?, + EncodingFormat::Postcard => postcard::to_allocvec(value) + .wrap_err("serializing Redis cache value with postcard")?, }; if self.compression_level > 0 @@ -149,15 +144,23 @@ impl CacheSettings { T: for<'a> Deserialize<'a>, { let (codec, value) = value.split_first()?; - let value = match Codec::try_from(*codec).ok()? { + let Ok(codec) = Codec::try_from(*codec) else { + return None; + }; + let value = match codec { Codec::Raw => Cow::Borrowed(value), - Codec::Lz4 => Cow::Owned( - lz4_flex::block::decompress_size_prepended(value).ok()?, - ), + Codec::Lz4 => { + let Ok(value) = + lz4_flex::block::decompress_size_prepended(value) + else { + return None; + }; + Cow::Owned(value) + } }; match self.encoding_format { - EncodingFormat::Json => serde_json::from_slice(&value).ok(), + EncodingFormat::Postcard => postcard::from_bytes(&value).ok(), } } @@ -202,12 +205,12 @@ impl CacheManager { namespace: &str, keys: &[K], closure: F, - ) -> Result, E> + ) -> Result> where P: ConnectionProvider, F: FnOnce(Vec) -> Fut, Fut: Future, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, K: Display + Hash @@ -220,7 +223,8 @@ impl CacheManager { { Ok(self .get_cached_keys_raw(provider, namespace, keys, closure) - .await? + .await + .wrap_err("fetching Redis cache values")? .into_values() .collect()) } @@ -232,12 +236,12 @@ impl CacheManager { namespace: &str, keys: &[K], closure: F, - ) -> Result, E> + ) -> Result> where P: ConnectionProvider, F: FnOnce(Vec) -> Fut, Fut: Future, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, K: Display + Hash @@ -255,11 +259,16 @@ impl CacheManager { false, keys, |ids| async move { - Ok(closure(ids) - .await? - .into_iter() - .map(|(key, value)| (key, (None::, value))) - .collect()) + let values = match closure(ids).await { + Ok(values) => values, + Err(error) => return Err(error), + }; + Ok::<_, E>( + values + .into_iter() + .map(|(key, value)| (key, (None::, value))) + .collect(), + ) }, ) .await @@ -274,12 +283,12 @@ impl CacheManager { case_sensitive: bool, keys: &[I], closure: F, - ) -> Result, E> + ) -> Result> where P: ConnectionProvider, F: FnOnce(Vec) -> Fut, Fut: Future, T)>, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, I: Display + Hash + Eq + PartialEq + Clone + Debug, K: Display @@ -300,7 +309,8 @@ impl CacheManager { keys, closure, ) - .await? + .await + .wrap_err("fetching Redis cache values by slug")? .into_values() .collect()) } @@ -314,12 +324,12 @@ impl CacheManager { case_sensitive: bool, keys: &[I], closure: F, - ) -> Result, E> + ) -> Result> where P: ConnectionProvider, F: FnOnce(Vec) -> Fut, Fut: Future, T)>, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, I: Display + Hash + Eq + PartialEq + Clone + Debug, K: Display @@ -360,7 +370,9 @@ impl CacheManager { }) .collect::>(); let mut connection = - provider.connect().await.map_err(E::from)?; + provider.connect().await.wrap_err( + "connecting to Redis for slug lookup", + )?; let values = match routing { CacheReadRouting::ReplicaOptional => { commands::get_many_strings( @@ -377,8 +389,8 @@ impl CacheManager { .await } } - .map_err(E::from)?; - Ok::<_, E>( + .wrap_err("fetching Redis cache slug values")?; + eyre::Ok( values .into_iter() .flatten() @@ -386,7 +398,8 @@ impl CacheManager { ) } .instrument(info_span!("get slug ids")) - .await? + .await + .wrap_err("resolving Redis cache slugs")? } else { Vec::new() }; @@ -403,8 +416,10 @@ impl CacheManager { .map(|key| self.key_builder.entity(namespace, key)) .collect::>(); - let mut connection = - provider.connect().await.map_err(E::from)?; + let mut connection = provider + .connect() + .await + .wrap_err("connecting to Redis for cache lookup")?; let mut cached_values = HashMap::new(); let values = match routing { CacheReadRouting::ReplicaOptional => { @@ -415,7 +430,7 @@ impl CacheManager { .await } } - .map_err(E::from)?; + .wrap_err("fetching Redis cache values")?; for value in values { if let Some(value) = value.and_then(|value| { self.settings @@ -425,7 +440,7 @@ impl CacheManager { } } - Ok::<_, E>((cached_values, ids)) + eyre::Ok((cached_values, ids)) } .instrument(info_span!("get_cached_values_closure")) }; @@ -437,7 +452,9 @@ impl CacheManager { let deadline = Instant::now() + WAIT_TIMEOUT; let (cached_values_raw, ids) = - get_cached_values(ids, CacheReadRouting::ReplicaOptional).await?; + get_cached_values(ids, CacheReadRouting::ReplicaOptional) + .await + .wrap_err("reading Redis cache")?; let mut cached_values = cached_values_raw .into_iter() .filter_map(|(key, value)| { @@ -508,7 +525,10 @@ impl CacheManager { let values = timeout_at(fill_deadline, closure(fetch_ids)) .await - .map_err(|_| lock_timeout_error(0, waiters.len()))??; + .map_err(|_| lock_timeout_error(0, waiters.len())) + .wrap_err("waiting to fill Redis cache")?; + let values = + values.wrap_err("fetching values to fill Redis cache")?; let mut return_values = HashMap::new(); let mut encoded_values = Vec::with_capacity(values.len()); @@ -520,13 +540,17 @@ impl CacheManager { val: value, alias: slug.clone(), }; - let encoded = - self.settings.encode_value(&value).map_err(E::from)?; + let encoded = self + .settings + .encode_value(&value) + .wrap_err("encoding Redis cache value")?; encoded_values.push((key, slug, value, encoded)); } - let mut connection = - provider.connect().await.map_err(E::from)?; + let mut connection = provider + .connect() + .await + .wrap_err("connecting to Redis to fill cache")?; for (key, slug, _, encoded) in &encoded_values { let redis_key = self.key_builder.entity(namespace, key.to_string()); @@ -537,7 +561,7 @@ impl CacheManager { default_expiry, ) .await - .map_err(E::from)?; + .wrap_err("writing Redis cache value")?; if let Some(slug) = slug && let Some(slug_namespace) = slug_namespace { @@ -554,7 +578,7 @@ impl CacheManager { default_expiry, ) .await - .map_err(E::from)?; + .wrap_err("writing Redis cache slug")?; } } @@ -563,7 +587,7 @@ impl CacheManager { return_values.insert(key, value); } - Result::<_, E>::Ok(return_values) + Result::<_>::Ok(return_values) } .await } else { @@ -604,13 +628,14 @@ impl CacheManager { Err(error) => Err(error), } } - Err(error) => Err(E::from(error)), + Err(error) => Err(error), } } } Err(error) => Err(error), }; - cached_values.extend(operation_result?); + cached_values + .extend(operation_result.wrap_err("populating Redis cache")?); Ok(cached_values .into_iter() @@ -679,7 +704,7 @@ fn push_identity(identities: &mut Vec, identity: String) { async fn wait_for_locks( waiters: Vec<(I, LockWaiter)>, deadline: Instant, -) -> Result, Error> { +) -> Result> { let total = waiters.len(); let mut released = Vec::with_capacity(total); let mut futures = FuturesUnordered::new(); @@ -695,26 +720,24 @@ async fn wait_for_locks( Ok(()) => { released.push(key); } - Err(error) - if is_lock_timeout(&error) || Instant::now() >= deadline => - { + Err(_) if Instant::now() >= deadline => { return Err(lock_timeout_error(released.len(), total)); } - Err(error) => return Err(error), + Err(error) => { + return Err(error).wrap_err("waiting for Redis cache lock"); + } } } Ok(released) } -fn is_lock_timeout(error: &Error) -> bool { - matches!(error, Error::LocalCacheTimeout { .. }) -} - -fn lock_timeout_error(locks_released: usize, locks_waiting: usize) -> Error { - Error::LocalCacheTimeout { - released: locks_released, - total: locks_waiting, - } +fn lock_timeout_error( + locks_released: usize, + locks_waiting: usize, +) -> eyre::Report { + eyre!( + "timeout waiting on local Redis cache lock ({locks_released}/{locks_waiting} released)" + ) } #[derive(Serialize, Deserialize)] @@ -726,6 +749,15 @@ pub struct RedisValue { } impl RedisValue { + pub fn new(key: K, alias: Option, iat: i64, val: T) -> Self { + Self { + key, + alias, + iat, + val, + } + } + pub fn value(&self) -> &T { &self.val } diff --git a/packages/xredis/src/cache/locking/local.rs b/packages/xredis/src/cache/locking/local.rs index fab5f6f822..001a64f72b 100644 --- a/packages/xredis/src/cache/locking/local.rs +++ b/packages/xredis/src/cache/locking/local.rs @@ -3,11 +3,10 @@ use std::sync::atomic::{AtomicBool, Ordering}; use dashmap::DashMap; use dashmap::mapref::entry::Entry; +use eyre::{Result, WrapErr}; use tokio::sync::Notify; use tokio::time::{Instant, timeout_at}; -use crate::Error; - #[derive(Clone)] pub(in crate::cache) struct LockCoordinator { locks: Arc>>, @@ -76,10 +75,7 @@ pub(in crate::cache) struct LockWaiter { } impl LockWaiter { - pub(in crate::cache) async fn wait( - self, - deadline: Instant, - ) -> Result<(), Error> { + pub(in crate::cache) async fn wait(self, deadline: Instant) -> Result<()> { loop { if self.state.released.load(Ordering::Acquire) { return Ok(()); @@ -94,7 +90,7 @@ impl LockWaiter { timeout_at(deadline, notified) .await - .map_err(|_| lock_timeout())?; + .wrap_err("waiting for local Redis cache lock")?; } } } @@ -112,10 +108,3 @@ impl LockState { } } } - -fn lock_timeout() -> Error { - Error::LocalCacheTimeout { - released: 0, - total: 1, - } -} diff --git a/packages/xredis/src/commands.rs b/packages/xredis/src/commands.rs index fdf0b058c7..744e158a89 100644 --- a/packages/xredis/src/commands.rs +++ b/packages/xredis/src/commands.rs @@ -1,10 +1,9 @@ use std::fmt::Debug; +use eyre::{Result, WrapErr}; use redis::aio::ConnectionLike; use redis::{FromRedisValue, ToRedisArgs}; -use crate::Error; - use super::cache::CacheSettings; use super::connection::RoutableConnection; use super::routing::primary_mget_routing; @@ -18,7 +17,7 @@ pub async fn set( key: &str, data: D, expiry: i64, -) -> Result<(), Error> +) -> Result<()> where C: ConnectionLike, D: ToRedisArgs + Send + Sync + Debug, @@ -29,7 +28,8 @@ where .arg("EX") .arg(expiry) .query_async::<()>(connection) - .await?; + .await + .wrap_err("writing to Redis")?; Ok(()) } @@ -40,7 +40,7 @@ pub async fn set_serialized( data: D, expiry: Option, settings: &CacheSettings, -) -> Result<(), Error> +) -> Result<()> where C: ConnectionLike, D: serde::Serialize, @@ -48,21 +48,24 @@ where set( connection, key, - settings.encode_value(&data)?, + settings + .encode_value(&data) + .wrap_err("serializing Redis value")?, expiry.unwrap_or(settings.default_expiry), ) .await } #[tracing::instrument(skip_all)] -pub async fn get( - connection: &mut C, - key: &str, -) -> Result, Error> +pub async fn get(connection: &mut C, key: &str) -> Result> where C: ConnectionLike, { - Ok(cmd("GET").arg(key).query_async(connection).await?) + cmd("GET") + .arg(key) + .query_async(connection) + .await + .wrap_err("fetching from Redis") } /// Issues ordinary `MGET` commands in bounded chunks. Cluster routing and @@ -72,7 +75,7 @@ where pub async fn get_many( connection: &mut C, keys: &[String], -) -> Result>>, Error> +) -> Result>>> where C: ConnectionLike, { @@ -83,7 +86,7 @@ where pub async fn get_many_strings( connection: &mut C, keys: &[String], -) -> Result>, Error> +) -> Result>> where C: ConnectionLike, { @@ -93,7 +96,7 @@ where pub(super) async fn get_many_primary( connection: &mut C, keys: &[String], -) -> Result>>, Error> +) -> Result>>> where C: RoutableConnection, { @@ -103,7 +106,7 @@ where pub(super) async fn get_many_strings_primary( connection: &mut C, keys: &[String], -) -> Result>, Error> +) -> Result>> where C: RoutableConnection, { @@ -113,7 +116,7 @@ where pub(super) async fn get_many_as( connection: &mut C, keys: &[String], -) -> Result>, Error> +) -> Result>> where C: ConnectionLike, T: FromRedisValue, @@ -123,7 +126,8 @@ where let part = cmd("MGET") .arg(chunk) .query_async::>>(connection) - .await?; + .await + .wrap_err("fetching multiple values from Redis")?; values.extend(part); } Ok(values) @@ -132,7 +136,7 @@ where async fn get_many_primary_as( connection: &mut C, keys: &[String], -) -> Result>, Error> +) -> Result>> where C: RoutableConnection, T: FromRedisValue, @@ -143,10 +147,14 @@ where command.arg(chunk); let value = connection .route_command(command, primary_mget_routing(chunk)) - .await?; - let part = - redis::from_redis_value::>>(value.extract_error()?) - .map_err(redis::RedisError::from)?; + .await + .wrap_err("fetching multiple values from primary Redis nodes")?; + let value = value + .extract_error() + .wrap_err("extracting Redis response")?; + let part = redis::from_redis_value::>>(value) + .map_err(redis::RedisError::from) + .wrap_err("decoding Redis response")?; values.extend(part); } Ok(values) @@ -157,13 +165,16 @@ pub async fn get_deserialized( connection: &mut C, key: &str, settings: &CacheSettings, -) -> Result, Error> +) -> Result> where C: ConnectionLike, R: for<'a> serde::Deserialize<'a>, { - let value: Option> = - cmd("GET").arg(key).query_async(connection).await?; + let value: Option> = cmd("GET") + .arg(key) + .query_async(connection) + .await + .wrap_err("fetching serialized value from Redis")?; Ok(value.and_then(|value| settings.decode_value(&value))) } @@ -172,47 +183,49 @@ pub async fn get_many_deserialized( connection: &mut C, keys: &[String], settings: &CacheSettings, -) -> Result>, Error> +) -> Result>> where C: ConnectionLike, R: for<'a> serde::Deserialize<'a>, { Ok(get_many(connection, keys) - .await? + .await + .wrap_err("fetching serialized values from Redis")? .into_iter() .map(|value| value.and_then(|value| settings.decode_value(&value))) .collect()) } #[tracing::instrument(skip_all)] -pub async fn delete(connection: &mut C, key: &str) -> Result<(), Error> +pub async fn delete(connection: &mut C, key: &str) -> Result<()> where C: ConnectionLike, { - cmd("DEL").arg(key).query_async::<()>(connection).await?; + cmd("DEL") + .arg(key) + .query_async::<()>(connection) + .await + .wrap_err("deleting from Redis")?; Ok(()) } #[tracing::instrument(skip_all)] -pub async fn delete_many( - connection: &mut C, - keys: &[String], -) -> Result<(), Error> +pub async fn delete_many(connection: &mut C, keys: &[String]) -> Result<()> where C: ConnectionLike, { if !keys.is_empty() { - cmd("DEL").arg(keys).query_async::<()>(connection).await?; + cmd("DEL") + .arg(keys) + .query_async::<()>(connection) + .await + .wrap_err("deleting multiple values from Redis")?; } Ok(()) } #[tracing::instrument(skip_all)] -pub async fn lpush( - connection: &mut C, - key: &str, - value: D, -) -> Result<(), Error> +pub async fn lpush(connection: &mut C, key: &str, value: D) -> Result<()> where C: ConnectionLike, D: ToRedisArgs + Send + Sync + Debug, @@ -221,17 +234,19 @@ where .arg(key) .arg(value) .query_async::<()>(connection) - .await?; + .await + .wrap_err("pushing to Redis list")?; Ok(()) } #[tracing::instrument(skip_all)] -pub async fn incr( - connection: &mut C, - key: &str, -) -> Result, Error> +pub async fn incr(connection: &mut C, key: &str) -> Result> where C: ConnectionLike, { - Ok(cmd("INCR").arg(key).query_async(connection).await?) + cmd("INCR") + .arg(key) + .query_async(connection) + .await + .wrap_err("incrementing Redis value") } diff --git a/packages/xredis/src/config.rs b/packages/xredis/src/config.rs index 6e466b107d..d63b3b7f89 100644 --- a/packages/xredis/src/config.rs +++ b/packages/xredis/src/config.rs @@ -1,5 +1,6 @@ use std::{fmt, str::FromStr}; +use eyre::{Result, WrapErr}; use thiserror::Error; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -91,13 +92,11 @@ pub(crate) struct RedisPoolSize { } impl RedisPoolSize { - fn new( - name: &'static str, - max: usize, - min: usize, - ) -> Result { + fn new(name: &'static str, max: usize, min: usize) -> Result { if max == 0 || min > max { - return Err(RedisConfigError::InvalidPoolSize { name, max, min }); + return Err( + RedisConfigError::InvalidPoolSize { name, max, min }.into() + ); } Ok(Self { max, min }) @@ -193,11 +192,12 @@ impl RedisConfig { blocking_pool_size: (usize, usize), cache_locking_strategy: CacheLockingStrategy, read_replica_strategy: ReadReplicaStrategy, - ) -> Result { + ) -> Result { if cache_locking_strategy == CacheLockingStrategy::Distributed { return Err(RedisConfigError::UnsupportedCacheLockingStrategy { strategy: cache_locking_strategy, - }); + } + .into()); } let seed_urls = raw_urls @@ -208,26 +208,32 @@ impl RedisConfig { .collect::>(); if seed_urls.is_empty() { - return Err(RedisConfigError::MissingUrl); + return Err(RedisConfigError::MissingUrl.into()); } let backend = match (mode, connection_type) { (RedisTopology::Standalone, RedisConnectionType::Pooled) => { if seed_urls.len() != 1 { - return Err(RedisConfigError::MultipleStandaloneUrls); + return Err(RedisConfigError::MultipleStandaloneUrls.into()); } - RedisBackendConfig::StandalonePooled(RedisPoolSize::new( - "standalone", - standalone_pool_size.0, - standalone_pool_size.1, - )?) + RedisBackendConfig::StandalonePooled( + RedisPoolSize::new( + "standalone", + standalone_pool_size.0, + standalone_pool_size.1, + ) + .wrap_err("validating standalone Redis pool size")?, + ) } (RedisTopology::Cluster, RedisConnectionType::Pooled) => { - RedisBackendConfig::ClusterPooled(RedisPoolSize::new( - "cluster", - cluster_pool_size.0, - cluster_pool_size.1, - )?) + RedisBackendConfig::ClusterPooled( + RedisPoolSize::new( + "cluster", + cluster_pool_size.0, + cluster_pool_size.1, + ) + .wrap_err("validating clustered Redis pool size")?, + ) } (RedisTopology::Cluster, RedisConnectionType::Multiplexed) => { RedisBackendConfig::ClusterMultiplexed @@ -236,7 +242,8 @@ impl RedisConfig { return Err(RedisConfigError::UnsupportedConnectionType { mode, connection_type, - }); + } + .into()); } }; @@ -249,7 +256,8 @@ impl RedisConfig { "blocking", blocking_pool_size.0, blocking_pool_size.1, - )?, + ) + .wrap_err("validating blocking Redis pool size")?, cache_locking_strategy, read_replica_strategy, }) diff --git a/packages/xredis/src/connection.rs b/packages/xredis/src/connection.rs index 0d66fc818d..3632290d19 100644 --- a/packages/xredis/src/connection.rs +++ b/packages/xredis/src/connection.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use eyre::{Result, WrapErr}; use futures::future::try_join_all; use prometheus::Registry; use redis::aio::ConnectionLike; @@ -7,7 +8,6 @@ use redis::cluster_read_routing::{ RandomReplicaStrategy, RoundRobinReplicaStrategy, }; use redis::cluster_routing::RoutingInfo; -use thiserror::Error; use tracing::warn; use crate::ReadReplicaStrategy; @@ -29,16 +29,6 @@ pub(crate) enum RedisBackend { ClusterMultiplexed(redis::cluster_async::ClusterConnection), } -#[derive(Debug, Error)] -pub enum RedisBackendBuildError { - #[error("failed to configure Redis client: {0}")] - Redis(#[from] redis::RedisError), - #[error("failed to build Redis pool: {0}")] - PoolBuild(#[from] deadpool_redis::BuildError), - #[error("failed to establish initial Redis pool connections: {0}")] - Pool(#[from] deadpool_redis::PoolError), -} - pub(crate) struct RedisConnection { inner: RedisConnectionInner, } @@ -58,9 +48,7 @@ pub(crate) trait RoutableConnection: ConnectionLike { } impl RedisBackend { - pub(crate) async fn new( - config: &RedisConfig, - ) -> Result { + pub(crate) async fn new(config: &RedisConfig) -> Result { match config.backend() { RedisBackendConfig::StandalonePooled(pool_size) => { Self::standalone_pooled(config, pool_size).await @@ -77,21 +65,25 @@ impl RedisBackend { async fn standalone_pooled( config: &RedisConfig, pool_size: RedisPoolSize, - ) -> Result { + ) -> Result { let connection_config = redis::AsyncConnectionConfig::new() .set_connection_timeout(None) .set_response_timeout(None); let manager = deadpool_redis::Manager::new_with_config( config.seed_urls()[0].clone(), connection_config, - )?; + ) + .wrap_err("configuring standalone Redis client")?; let pool = deadpool_redis::Pool::builder(manager) .max_size(pool_size.max()) .wait_timeout(Some(Duration::from_millis(config.wait_timeout_ms()))) .runtime(deadpool_redis::Runtime::Tokio1) - .build()?; + .build() + .wrap_err("building standalone Redis pool")?; - warm_standalone_pool(&pool, pool_size.min()).await?; + warm_standalone_pool(&pool, pool_size.min()) + .await + .wrap_err("warming standalone Redis pool")?; retain_standalone_pool(pool.clone()); Ok(Self::StandalonePooled(pool)) @@ -100,16 +92,18 @@ impl RedisBackend { async fn cluster_pooled( config: &RedisConfig, pool_size: RedisPoolSize, - ) -> Result { + ) -> Result { let manager = deadpool_redis::cluster::Manager::new( config.seed_urls().to_vec(), false, - )?; + ) + .wrap_err("configuring clustered Redis client")?; let pool = deadpool_redis::cluster::Pool::builder(manager) .max_size(pool_size.max()) .wait_timeout(Some(Duration::from_millis(config.wait_timeout_ms()))) .runtime(deadpool_redis::Runtime::Tokio1) - .build()?; + .build() + .wrap_err("building clustered Redis pool")?; if config.read_replica_strategy() != ReadReplicaStrategy::Primary { warn!( @@ -117,15 +111,15 @@ impl RedisBackend { ); } - warm_cluster_pool(&pool, pool_size.min()).await?; + warm_cluster_pool(&pool, pool_size.min()) + .await + .wrap_err("warming clustered Redis pool")?; retain_cluster_pool(pool.clone()); Ok(Self::ClusterPooled(pool)) } - async fn cluster_multiplexed( - config: &RedisConfig, - ) -> Result { + async fn cluster_multiplexed(config: &RedisConfig) -> Result { let mut builder = redis::cluster::ClusterClientBuilder::new( config.seed_urls().iter().map(String::as_str), ); @@ -141,22 +135,31 @@ impl RedisBackend { } } - let client = builder.build()?; - let connection = client.get_async_connection().await?; + let client = builder + .build() + .wrap_err("building multiplexed Redis client")?; + let connection = client + .get_async_connection() + .await + .wrap_err("connecting multiplexed Redis client")?; Ok(Self::ClusterMultiplexed(connection)) } - pub(crate) async fn connect( - &self, - ) -> Result { + pub(crate) async fn connect(&self) -> Result { let inner = match self { Self::StandalonePooled(pool) => { - RedisConnectionInner::StandalonePooled(pool.get().await?) - } - Self::ClusterPooled(pool) => { - RedisConnectionInner::ClusterPooled(pool.get().await?) + RedisConnectionInner::StandalonePooled( + pool.get() + .await + .wrap_err("fetching standalone Redis connection")?, + ) } + Self::ClusterPooled(pool) => RedisConnectionInner::ClusterPooled( + pool.get() + .await + .wrap_err("fetching clustered Redis connection")?, + ), Self::ClusterMultiplexed(connection) => { RedisConnectionInner::ClusterMultiplexed(connection.clone()) } @@ -165,10 +168,7 @@ impl RedisBackend { Ok(RedisConnection { inner }) } - pub(crate) fn register_metrics( - &self, - registry: &Registry, - ) -> Result<(), prometheus::Error> { + pub(crate) fn register_metrics(&self, registry: &Registry) -> Result<()> { register_command_pool_metrics(registry, self.clone()) } } @@ -282,8 +282,10 @@ impl RoutableConnection for RedisConnection { async fn warm_standalone_pool( pool: &deadpool_redis::Pool, min: usize, -) -> Result<(), deadpool_redis::PoolError> { - let connections = try_join_all((0..min).map(|_| pool.get())).await?; +) -> Result<()> { + let connections = try_join_all((0..min).map(|_| pool.get())) + .await + .wrap_err("fetching initial standalone Redis connections")?; drop(connections); Ok(()) } @@ -291,8 +293,10 @@ async fn warm_standalone_pool( async fn warm_cluster_pool( pool: &deadpool_redis::cluster::Pool, min: usize, -) -> Result<(), deadpool_redis::PoolError> { - let connections = try_join_all((0..min).map(|_| pool.get())).await?; +) -> Result<()> { + let connections = try_join_all((0..min).map(|_| pool.get())) + .await + .wrap_err("fetching initial clustered Redis connections")?; drop(connections); Ok(()) } diff --git a/packages/xredis/src/lib.rs b/packages/xredis/src/lib.rs index e84fce15eb..19d4afce1e 100644 --- a/packages/xredis/src/lib.rs +++ b/packages/xredis/src/lib.rs @@ -6,6 +6,7 @@ use std::hash::Hash; use std::sync::Arc; use dashmap::DashMap; +use eyre::{Result, WrapErr}; use prometheus::Registry; use redis::aio::ConnectionLike; use redis::{FromRedisValue, ToRedisArgs}; @@ -35,25 +36,7 @@ pub use config::{ RedisConfigError, RedisConnectionType, RedisTopology, }; use connection::RedisBackend; -pub use connection::RedisBackendBuildError; pub use key::KeyBuilder; -use thiserror::Error as ThisError; - -#[derive(Debug, ThisError)] -pub enum Error { - #[error("error while interacting with Redis: {0}")] - Redis(#[from] redis::RedisError), - #[error("Redis pool error: {0}")] - Pool(#[from] deadpool_redis::PoolError), - #[error("error while serializing a Redis cache value: {0}")] - Serialization(#[from] serde_json::Error), - #[error("Redis blocking timeout must be greater than zero")] - InvalidBlockingTimeout, - #[error( - "timeout waiting on local cache lock ({released}/{total} released)" - )] - LocalCacheTimeout { released: usize, total: usize }, -} #[derive(Clone)] pub struct RedisPool { @@ -75,15 +58,19 @@ impl RedisPool { meta_namespace: impl Into>, config: RedisConfig, cache_settings: CacheSettings, - ) -> Result { + ) -> Result { tracing::info!( strategy = %config.cache_locking_strategy(), "configured Redis cache locking" ); - let backend = RedisBackend::new(&config).await?; + let backend = RedisBackend::new(&config) + .await + .wrap_err("creating Redis command backend")?; - let blocking = blocking::RedisBlockingPool::new(&config).await?; + let blocking = blocking::RedisBlockingPool::new(&config) + .await + .wrap_err("creating Redis blocking pool")?; let key_builder = KeyBuilder::new(meta_namespace, config.topology()); let cache = CacheManager::new(key_builder.clone(), cache_settings); @@ -102,9 +89,13 @@ impl RedisPool { } impl RedisPool { - pub async fn connect(&self) -> Result { + pub async fn connect(&self) -> Result { Ok(RedisConnection { - inner: self.backend.connect().await?, + inner: self + .backend + .connect() + .await + .wrap_err("connecting to Redis")?, key_builder: self.key_builder.clone(), settings: self.cache.settings().clone(), }) @@ -113,9 +104,13 @@ impl RedisPool { pub async fn register_and_set_metrics( &self, registry: &Registry, - ) -> Result<(), prometheus::Error> { - self.backend.register_metrics(registry)?; - self.blocking.register_metrics(registry) + ) -> Result<()> { + self.backend + .register_metrics(registry) + .wrap_err("registering Redis command pool metrics")?; + self.blocking + .register_metrics(registry) + .wrap_err("registering Redis blocking pool metrics") } pub async fn get_cached_keys( @@ -123,11 +118,11 @@ impl RedisPool { namespace: &str, keys: &[K], closure: F, - ) -> Result, E> + ) -> Result> where F: FnOnce(Vec) -> Fut, Fut: Future, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, K: Display + Hash @@ -148,11 +143,11 @@ impl RedisPool { namespace: &str, keys: &[K], closure: F, - ) -> Result, E> + ) -> Result> where F: FnOnce(Vec) -> Fut, Fut: Future, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, K: Display + Hash @@ -175,11 +170,11 @@ impl RedisPool { case_sensitive: bool, keys: &[I], closure: F, - ) -> Result, E> + ) -> Result> where F: FnOnce(Vec) -> Fut, Fut: Future, T)>, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, I: Display + Hash + Eq + PartialEq + Clone + Debug, K: Display @@ -210,11 +205,11 @@ impl RedisPool { case_sensitive: bool, keys: &[I], closure: F, - ) -> Result, E> + ) -> Result> where F: FnOnce(Vec) -> Fut, Fut: Future, T)>, E>>, - E: From, + E: std::error::Error + Send + Sync + 'static, T: Serialize + DeserializeOwned, I: Display + Hash + Eq + PartialEq + Clone + Debug, K: Display @@ -242,9 +237,7 @@ impl RedisPool { impl ConnectionProvider for RedisPool { type Connection = RedisConnection; - fn connect( - &self, - ) -> impl Future> + Send { + fn connect(&self) -> impl Future> + Send { RedisPool::connect(self) } } @@ -259,7 +252,7 @@ impl RedisConnection { key: &str, data: D, expiry: Option, - ) -> Result<(), Error> + ) -> Result<()> where D: ToRedisArgs + Send + Sync + Debug, { @@ -277,7 +270,7 @@ impl RedisConnection { key: &str, data: D, expiry: Option, - ) -> Result<(), Error> + ) -> Result<()> where D: Serialize, { @@ -291,31 +284,28 @@ impl RedisConnection { .await } - pub async fn get(&mut self, key: &str) -> Result, Error> { + pub async fn get(&mut self, key: &str) -> Result> { commands::get(&mut self.inner, key).await } pub async fn get_many( &mut self, keys: &[String], - ) -> Result>>, Error> { + ) -> Result>>> { commands::get_many(&mut self.inner, keys).await } pub async fn get_many_typed( &mut self, keys: &[String], - ) -> Result>, Error> + ) -> Result>> where R: FromRedisValue, { commands::get_many_as(&mut self.inner, keys).await } - pub async fn get_deserialized( - &mut self, - key: &str, - ) -> Result, Error> + pub async fn get_deserialized(&mut self, key: &str) -> Result> where R: for<'a> serde::Deserialize<'a>, { @@ -325,7 +315,7 @@ impl RedisConnection { pub async fn get_many_deserialized( &mut self, keys: &[String], - ) -> Result>, Error> + ) -> Result>> where R: for<'a> serde::Deserialize<'a>, { @@ -333,22 +323,22 @@ impl RedisConnection { .await } - pub async fn delete(&mut self, key: &str) -> Result<(), Error> { + pub async fn delete(&mut self, key: &str) -> Result<()> { commands::delete(&mut self.inner, key).await } - pub async fn delete_many(&mut self, keys: &[String]) -> Result<(), Error> { + pub async fn delete_many(&mut self, keys: &[String]) -> Result<()> { commands::delete_many(&mut self.inner, keys).await } - pub async fn lpush(&mut self, key: &str, value: D) -> Result<(), Error> + pub async fn lpush(&mut self, key: &str, value: D) -> Result<()> where D: ToRedisArgs + Send + Sync + Debug, { commands::lpush(&mut self.inner, key, value).await } - pub async fn incr(&mut self, key: &str) -> Result, Error> { + pub async fn incr(&mut self, key: &str) -> Result> { commands::incr(&mut self.inner, key).await } } diff --git a/packages/xredis/src/metrics.rs b/packages/xredis/src/metrics.rs index d50e4cce47..fafd5dccbe 100644 --- a/packages/xredis/src/metrics.rs +++ b/packages/xredis/src/metrics.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use eyre::{Result, WrapErr}; use prometheus::{IntGauge, Registry}; const METRICS_UPDATE_INTERVAL: Duration = Duration::from_secs(5); @@ -72,7 +73,7 @@ impl RedisPoolMetrics { fn register( registry: &Registry, kind: RedisPoolMetricsKind, - ) -> Result { + ) -> Result { let prefix = kind.metric_prefix(); let description = kind.description(); let max_size = IntGauge::new( @@ -80,28 +81,40 @@ impl RedisPoolMetrics { format!( "Maximum logical connection count for the {description}; clustered logical connections may own multiple physical sockets" ), - )?; + ) + .wrap_err("creating Redis pool maximum size metric")?; let size = IntGauge::new( format!("{prefix}_size"), format!( "Current logical connection count for the {description}; clustered logical connections may own multiple physical sockets" ), - )?; + ) + .wrap_err("creating Redis pool size metric")?; let available = IntGauge::new( format!("{prefix}_available"), format!("Available logical connections in the {description}"), - )?; + ) + .wrap_err("creating Redis pool availability metric")?; let waiting = IntGauge::new( format!("{prefix}_waiting"), format!( "Number of futures waiting for a logical connection from the {description}" ), - )?; + ) + .wrap_err("creating Redis pool waiters metric")?; - registry.register(Box::new(max_size.clone()))?; - registry.register(Box::new(size.clone()))?; - registry.register(Box::new(available.clone()))?; - registry.register(Box::new(waiting.clone()))?; + registry + .register(Box::new(max_size.clone())) + .wrap_err("registering Redis pool maximum size metric")?; + registry + .register(Box::new(size.clone())) + .wrap_err("registering Redis pool size metric")?; + registry + .register(Box::new(available.clone())) + .wrap_err("registering Redis pool availability metric")?; + registry + .register(Box::new(waiting.clone())) + .wrap_err("registering Redis pool waiters metric")?; Ok(Self { max_size, @@ -122,7 +135,7 @@ impl RedisPoolMetrics { pub(super) fn register_command_pool_metrics

( registry: &Registry, provider: P, -) -> Result<(), prometheus::Error> +) -> Result<()> where P: LogicalPoolStatusProvider, { @@ -132,7 +145,7 @@ where pub(super) fn register_blocking_pool_metrics

( registry: &Registry, provider: P, -) -> Result<(), prometheus::Error> +) -> Result<()> where P: LogicalPoolStatusProvider, { @@ -143,11 +156,12 @@ fn register_pool_metrics

( registry: &Registry, kind: RedisPoolMetricsKind, provider: P, -) -> Result<(), prometheus::Error> +) -> Result<()> where P: LogicalPoolStatusProvider, { - let metrics = RedisPoolMetrics::register(registry, kind)?; + let metrics = RedisPoolMetrics::register(registry, kind) + .wrap_err("registering Redis pool metrics")?; metrics.set(provider.logical_pool_status()); tokio::spawn(async move { diff --git a/packages/xredis/src/pubsub.rs b/packages/xredis/src/pubsub.rs index b6080a0f32..c4986ce043 100644 --- a/packages/xredis/src/pubsub.rs +++ b/packages/xredis/src/pubsub.rs @@ -1,11 +1,12 @@ use std::time::Duration; +use eyre::{Result, WrapErr}; use futures::StreamExt; use redis::ToRedisArgs; use tokio::sync::mpsc; use tracing::{info, warn}; -use super::{Error, RedisPool}; +use super::RedisPool; const PUBSUB_BUFFER_SIZE: usize = 1024; const INITIAL_RECONNECT_BACKOFF: Duration = Duration::from_millis(250); @@ -35,21 +36,20 @@ impl RedisPool { receiver } - pub async fn publish( - &self, - channel: &str, - message: M, - ) -> Result<(), Error> + pub async fn publish(&self, channel: &str, message: M) -> Result<()> where M: ToRedisArgs + Send + Sync, { - let mut connection = self.connect().await?; + let mut connection = self + .connect() + .await + .wrap_err("connecting to Redis for publishing")?; let _: usize = redis::cmd("PUBLISH") .arg(channel) .arg(message) .query_async(&mut connection) .await - .map_err(Error::from)?; + .wrap_err("publishing to Redis channel")?; Ok(()) } } @@ -111,10 +111,17 @@ async fn forward_from_seed( seed_url: &str, channel: &'static str, sender: &mpsc::Sender>, -) -> redis::RedisResult { - let client = redis::Client::open(seed_url)?; - let mut pubsub = client.get_async_pubsub().await?; - pubsub.subscribe(channel).await?; +) -> Result { + let client = redis::Client::open(seed_url) + .wrap_err("configuring Redis Pub/Sub client")?; + let mut pubsub = client + .get_async_pubsub() + .await + .wrap_err("connecting to Redis Pub/Sub")?; + pubsub + .subscribe(channel) + .await + .wrap_err("subscribing to Redis channel")?; info!(channel, "Established Redis Pub/Sub subscription"); let mut stream = pubsub.into_on_message(); From bca8b1a02b18b977e8c36b6e7e45196a71f6e88e Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 5 Aug 2026 11:47:06 +0100 Subject: [PATCH 063/145] fix: moderation queue endpoint regression (#7003) fix: regression caused by 2308ac1 changes --- ...584d26550d01a1917d1af64fe236d63dd5005.json | 4 +- ...b206d70238d0132dad1f8974ae1ac346d2bd8.json | 28 ++++ ...fd08431fc3f984263faed7aaeb311fa6ac2a9.json | 28 ---- ...8103c009077768b33c17e22dcef4774ae5814.json | 138 ++++++++++++++++++ ...931c2e9776ffed87f4406bb5c2bc30087f5b.json} | 4 +- ...51d55a61c2b6466db69a81be975e150b3ef41.json | 138 ------------------ .../src/routes/internal/moderation/mod.rs | 6 +- 7 files changed, 172 insertions(+), 174 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8.json delete mode 100644 apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json create mode 100644 apps/labrinth/.sqlx/query-b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814.json rename apps/labrinth/.sqlx/{query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json => query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json} (63%) delete mode 100644 apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json diff --git a/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json b/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json index 434d224cc7..db45e607d0 100644 --- a/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json +++ b/apps/labrinth/.sqlx/query-1551b022217df05490a01e715b3584d26550d01a1917d1af64fe236d63dd5005.json @@ -76,12 +76,10 @@ "name": "delphi_severity", "kind": { "Enum": [ - "hidden", "low", "medium", "high", - "severe", - "malware" + "severe" ] } } diff --git a/apps/labrinth/.sqlx/query-17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8.json b/apps/labrinth/.sqlx/query-17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8.json new file mode 100644 index 0000000000..b3a813243e --- /dev/null +++ b/apps/labrinth/.sqlx/query-17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $7::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n )\n SELECT id\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4)\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ORDER BY\n CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Text", + "Bool", + "Text", + "Bool" + ] + }, + "nullable": [ + false + ] + }, + "hash": "17dc154d8cf4d857d2611c21352b206d70238d0132dad1f8974ae1ac346d2bd8" +} diff --git a/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json b/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json deleted file mode 100644 index 38d43c6043..0000000000 --- a/apps/labrinth/.sqlx/query-22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $7::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN mp.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n )\n SELECT id\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND project_types[1] = $4)\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ORDER BY\n CASE WHEN $6 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $6 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $6 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $6 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "TextArray", - "Text", - "Text", - "Bool", - "Text", - "Bool" - ] - }, - "nullable": [ - false - ] - }, - "hash": "22fbd4bf1c2088903842b2c0070fd08431fc3f984263faed7aaeb311fa6ac2a9" -} diff --git a/apps/labrinth/.sqlx/query-b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814.json b/apps/labrinth/.sqlx/query-b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814.json new file mode 100644 index 0000000000..e54ff20ebf --- /dev/null +++ b/apps/labrinth/.sqlx/query-b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814.json @@ -0,0 +1,138 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $9::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n ),\n filtered_projects AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND\n project_types[1] = $4\n )\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM filtered_projects\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $7\n LIMIT $6\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n m.team_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n page_ids.project_types,\n page_ids.external_dependencies_count\n FROM page_ids\n INNER JOIN mods m ON m.id = page_ids.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "total_count!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id?", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "slug?", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "name?", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "summary?", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "status?", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "requested_status?", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "queued?", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "published?", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated?", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "organization_id?", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "organization_name?", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "organization_icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "owner_id?", + "type_info": "Int8" + }, + { + "ordinal": 15, + "name": "owner_name?", + "type_info": "Varchar" + }, + { + "ordinal": 16, + "name": "owner_icon_url?", + "type_info": "Varchar" + }, + { + "ordinal": 17, + "name": "project_types?: Vec", + "type_info": "TextArray" + }, + { + "ordinal": 18, + "name": "external_dependencies_count?", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Text", + "Bool", + "Int8", + "Int8", + "Text", + "Bool" + ] + }, + "nullable": [ + null, + false, + true, + false, + false, + true, + false, + true, + true, + false, + false, + true, + false, + true, + false, + false, + true, + null, + null + ] + }, + "hash": "b3aaa0b2f38950057d845b3d4dc8103c009077768b33c17e22dcef4774ae5814" +} diff --git a/apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json b/apps/labrinth/.sqlx/query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json similarity index 63% rename from apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json rename to apps/labrinth/.sqlx/query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json index 450ec6ad79..bf61274204 100644 --- a/apps/labrinth/.sqlx/query-5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b.json +++ b/apps/labrinth/.sqlx/query-c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n WITH filtered_projects AS (\n SELECT\n m.id,\n m.queued,\n m.published\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $6::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published\n FROM filtered_projects\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $4\n LIMIT $3\n ),\n page_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n page_external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n CASE\n WHEN m.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(ppt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count\n FROM page_ids page\n INNER JOIN mods m ON m.id = page.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id\n LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ", + "query": "\n WITH filtered_projects AS (\n SELECT\n m.id,\n m.queued,\n m.published\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $6::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published\n FROM filtered_projects\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $4\n LIMIT $3\n ),\n page_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n page_external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN page_ids page ON page.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n CASE\n WHEN jsonb_typeof(m.components -> 'minecraft_server') = 'object'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(ppt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(ppt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ped.external_dependencies_count, 0) AS external_dependencies_count\n FROM page_ids page\n INNER JOIN mods m ON m.id = page.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n LEFT JOIN page_project_types ppt ON ppt.mod_id = m.id\n LEFT JOIN page_external_dependencies ped ON ped.mod_id = m.id\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $5 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $5 = 'oldest' THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ", "describe": { "columns": [ { @@ -131,5 +131,5 @@ null ] }, - "hash": "5a68c53bd00c08edf9dfb9ffdabc28f86cdd77cd06bbb10c9a8e5199509f9c4b" + "hash": "c268a2a256f11a65449bd132a14f931c2e9776ffed87f4406bb5c2bc30087f5b" } diff --git a/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json b/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json deleted file mode 100644 index 91c2728ad9..0000000000 --- a/apps/labrinth/.sqlx/query-ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41.json +++ /dev/null @@ -1,138 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH moderation_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.description,\n m.queued,\n m.published,\n m.organization_id,\n m.team_id,\n m.components\n FROM mods m\n WHERE\n m.status = $1\n AND (\n $9::boolean = false\n OR NOT EXISTS (\n SELECT 1\n FROM delphi_issue_details_with_statuses didws\n WHERE didws.project_id = m.id\n AND didws.status = 'pending'\n )\n )\n ),\n external_dependencies AS (\n SELECT\n v.mod_id,\n COUNT(*) AS external_dependencies_count\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN dependencies d ON d.dependent_id = v.id\n WHERE d.dependency_file_name IS NOT NULL\n GROUP BY v.mod_id\n ),\n version_project_types AS (\n SELECT\n v.mod_id,\n ARRAY_AGG(DISTINCT pt.name::text) FILTER (WHERE pt.name IS NOT NULL) AS project_types\n FROM versions v\n INNER JOIN moderation_projects mp ON mp.id = v.mod_id\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.status = ANY($2)\n GROUP BY v.mod_id\n ),\n queue_projects AS (\n SELECT\n mp.id,\n mp.slug,\n mp.name,\n mp.summary,\n mp.description,\n mp.queued,\n mp.published,\n search_organization.name AS organization_name,\n search_owner.username AS owner_name,\n CASE\n WHEN mp.components ? 'minecraft_server'\n THEN ARRAY_APPEND(\n ARRAY_REMOVE(\n COALESCE(vpt.project_types::text[], ARRAY[]::text[]),\n 'modpack'\n ),\n 'minecraft_java_server'\n )\n ELSE COALESCE(vpt.project_types::text[], ARRAY[]::text[])\n END AS project_types,\n COALESCE(ed.external_dependencies_count, 0) AS external_dependencies_count\n FROM moderation_projects mp\n LEFT JOIN organizations search_organization\n ON search_organization.id = mp.organization_id\n AND $3::text IS NOT NULL\n LEFT JOIN LATERAL (\n SELECT\n u.username\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = mp.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) search_owner ON $3::text IS NOT NULL\n AND mp.organization_id IS NULL\n LEFT JOIN external_dependencies ed ON ed.mod_id = mp.id\n LEFT JOIN version_project_types vpt ON vpt.mod_id = mp.id\n ),\n filtered_projects AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM queue_projects\n WHERE\n (\n $3::text IS NULL\n OR name ILIKE '%' || $3 || '%'\n OR slug ILIKE '%' || $3 || '%'\n OR summary ILIKE '%' || $3 || '%'\n OR description ILIKE '%' || $3 || '%'\n OR owner_name ILIKE '%' || $3 || '%'\n OR organization_name ILIKE '%' || $3 || '%'\n OR EXISTS (\n SELECT 1\n FROM UNNEST(project_types) AS searched_project_type(project_type)\n WHERE searched_project_type.project_type ILIKE '%' || $3 || '%'\n )\n )\n AND (\n $4::text IS NULL\n OR ($4 = 'none' AND CARDINALITY(project_types) = 0)\n OR ($4 = 'minecraft_java_server' AND project_types @> ARRAY['minecraft_java_server']::text[])\n OR ($4 <> 'none' AND $4 <> 'minecraft_java_server' AND\n project_types[1] = $4\n )\n )\n AND ($5::boolean IS NULL OR (external_dependencies_count > 0) = $5)\n ),\n total AS (\n SELECT COUNT(*) AS total_count FROM filtered_projects\n ),\n page_ids AS (\n SELECT\n id,\n queued,\n published,\n project_types,\n external_dependencies_count\n FROM filtered_projects\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(queued, published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(queued, published) END ASC NULLS LAST,\n id ASC\n OFFSET $7\n LIMIT $6\n ),\n page_projects AS (\n SELECT\n m.id,\n m.slug,\n m.name,\n m.summary,\n m.icon_url,\n m.status,\n m.requested_status,\n m.queued,\n m.published,\n m.updated,\n m.organization_id,\n m.team_id,\n o.name AS organization_name,\n o.icon_url AS organization_icon_url,\n owner.user_id AS owner_id,\n owner.username AS owner_name,\n owner.avatar_url AS owner_icon_url,\n page_ids.project_types,\n page_ids.external_dependencies_count\n FROM page_ids\n INNER JOIN mods m ON m.id = page_ids.id\n LEFT JOIN organizations o ON o.id = m.organization_id\n LEFT JOIN LATERAL (\n SELECT\n tm.user_id,\n u.username,\n u.avatar_url\n FROM team_members tm\n INNER JOIN users u ON u.id = tm.user_id\n WHERE tm.team_id = m.team_id\n AND tm.is_owner\n ORDER BY tm.ordering ASC\n LIMIT 1\n ) owner ON m.organization_id IS NULL\n )\n SELECT\n total.total_count AS \"total_count!\",\n page_projects.id AS \"id?\",\n page_projects.slug AS \"slug?\",\n page_projects.name AS \"name?\",\n page_projects.summary AS \"summary?\",\n page_projects.icon_url AS \"icon_url?\",\n page_projects.status AS \"status?\",\n page_projects.requested_status AS \"requested_status?\",\n page_projects.queued AS \"queued?\",\n page_projects.published AS \"published?\",\n page_projects.updated AS \"updated?\",\n page_projects.organization_id AS \"organization_id?\",\n page_projects.organization_name AS \"organization_name?\",\n page_projects.organization_icon_url AS \"organization_icon_url?\",\n page_projects.owner_id AS \"owner_id?\",\n page_projects.owner_name AS \"owner_name?\",\n page_projects.owner_icon_url AS \"owner_icon_url?\",\n page_projects.project_types AS \"project_types?: Vec\",\n page_projects.external_dependencies_count AS \"external_dependencies_count?\"\n FROM total\n LEFT JOIN page_projects ON true\n ORDER BY\n CASE WHEN $8 = 'most_external_deps' THEN page_projects.external_dependencies_count END DESC,\n CASE WHEN $8 = 'least_external_deps' THEN page_projects.external_dependencies_count END ASC,\n CASE WHEN $8 = 'newest' THEN COALESCE(page_projects.queued, page_projects.published) END DESC NULLS LAST,\n CASE WHEN $8 IN ('oldest', 'most_external_deps', 'least_external_deps') THEN COALESCE(page_projects.queued, page_projects.published) END ASC NULLS LAST,\n page_projects.id ASC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "total_count!", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "id?", - "type_info": "Int8" - }, - { - "ordinal": 2, - "name": "slug?", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "name?", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "summary?", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "icon_url?", - "type_info": "Varchar" - }, - { - "ordinal": 6, - "name": "status?", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "requested_status?", - "type_info": "Varchar" - }, - { - "ordinal": 8, - "name": "queued?", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "published?", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "updated?", - "type_info": "Timestamptz" - }, - { - "ordinal": 11, - "name": "organization_id?", - "type_info": "Int8" - }, - { - "ordinal": 12, - "name": "organization_name?", - "type_info": "Text" - }, - { - "ordinal": 13, - "name": "organization_icon_url?", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "owner_id?", - "type_info": "Int8" - }, - { - "ordinal": 15, - "name": "owner_name?", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "owner_icon_url?", - "type_info": "Varchar" - }, - { - "ordinal": 17, - "name": "project_types?: Vec", - "type_info": "TextArray" - }, - { - "ordinal": 18, - "name": "external_dependencies_count?", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "TextArray", - "Text", - "Text", - "Bool", - "Int8", - "Int8", - "Text", - "Bool" - ] - }, - "nullable": [ - null, - false, - true, - false, - false, - true, - false, - true, - true, - false, - false, - true, - false, - true, - false, - false, - true, - null, - null - ] - }, - "hash": "ca8493b59004511e8d26200638951d55a61c2b6466db69a81be975e150b3ef41" -} diff --git a/apps/labrinth/src/routes/internal/moderation/mod.rs b/apps/labrinth/src/routes/internal/moderation/mod.rs index e66f0e0f84..aecce43cbb 100644 --- a/apps/labrinth/src/routes/internal/moderation/mod.rs +++ b/apps/labrinth/src/routes/internal/moderation/mod.rs @@ -333,7 +333,7 @@ pub async fn get_projects_internal( search_organization.name AS organization_name, search_owner.username AS owner_name, CASE - WHEN mp.components ? 'minecraft_server' + WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object' THEN ARRAY_APPEND( ARRAY_REMOVE( COALESCE(vpt.project_types::text[], ARRAY[]::text[]), @@ -603,7 +603,7 @@ pub async fn get_projects_internal( owner.username AS owner_name, owner.avatar_url AS owner_icon_url, CASE - WHEN m.components ? 'minecraft_server' + WHEN jsonb_typeof(m.components -> 'minecraft_server') = 'object' THEN ARRAY_APPEND( ARRAY_REMOVE( COALESCE(ppt.project_types::text[], ARRAY[]::text[]), @@ -808,7 +808,7 @@ pub async fn get_project_ids( search_organization.name AS organization_name, search_owner.username AS owner_name, CASE - WHEN mp.components ? 'minecraft_server' + WHEN jsonb_typeof(mp.components -> 'minecraft_server') = 'object' THEN ARRAY_APPEND( ARRAY_REMOVE( COALESCE(vpt.project_types::text[], ARRAY[]::text[]), From 1c574a1f2d4af85bad5bebb1783082392f580c21 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 5 Aug 2026 13:22:05 +0100 Subject: [PATCH 064/145] fix: affiliates showing when not meant to (#7008) --- apps/frontend/src/layouts/default.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 4f2849bd65..6085cc5cfa 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -1306,7 +1306,7 @@ const userMenuOptions = computed(() => { label: formatMessage(commonMessages.affiliateLinksButton), type: 'link', to: '/dashboard/affiliate-links', - shown: user.badges & UserBadge.AFFILIATE, + shown: Boolean(user.badges & UserBadge.AFFILIATE), }, { id: 'revenue', From c0e6f094d993738c43342015ffa3b83e846b56c1 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 5 Aug 2026 14:17:29 +0100 Subject: [PATCH 065/145] fix: panel issues w/ backups (#7009) --- .../ui/src/components/base/ErrorInformationCard.vue | 4 ++-- .../ui/src/components/servers/backups/BackupItem.vue | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/base/ErrorInformationCard.vue b/packages/ui/src/components/base/ErrorInformationCard.vue index 71f010070e..f183cca2fe 100644 --- a/packages/ui/src/components/base/ErrorInformationCard.vue +++ b/packages/ui/src/components/base/ErrorInformationCard.vue @@ -52,7 +52,7 @@ :color="action.color === 'standard' ? undefined : (action.color ?? 'brand')" size="xl" :disabled="action.disabled" - class="!w-full" + class="flex-1" @click="action.onClick" > @@ -64,7 +64,7 @@ {{ action.label }} - - + - + - -

+
-
+
-
+
-
+
- +

This project is not managed by an organization. If you are the member of any organizations, @@ -562,6 +564,7 @@ import { Avatar, Badge, Button, + ButtonLink, Card, Checkbox, Combobox, diff --git a/apps/frontend/src/pages/[type]/[project]/settings/tags.vue b/apps/frontend/src/pages/[type]/[project]/settings/tags.vue index 77acc7d932..cbfc414335 100644 --- a/apps/frontend/src/pages/[type]/[project]/settings/tags.vue +++ b/apps/frontend/src/pages/[type]/[project]/settings/tags.vue @@ -434,11 +434,6 @@ const toggleFeaturedCategory = (category: Category) => { vertical-align: top; } } - -.button-group { - justify-content: flex-start; -} - .category-list { column-count: 4; column-gap: var(--spacing-card-lg); diff --git a/apps/frontend/src/pages/admin/billing/[user].vue b/apps/frontend/src/pages/admin/billing/[user].vue index 72ad4e591b..e8e9d75300 100644 --- a/apps/frontend/src/pages/admin/billing/[user].vue +++ b/apps/frontend/src/pages/admin/billing/[user].vue @@ -166,7 +166,7 @@ v-if=" subscription.metadata?.type === 'pyro' || subscription.metadata?.type === 'medal' " - :to="`/hosting/manage/${subscription.metadata.id}`" + :href="`/hosting/manage/${subscription.metadata.id}`" target="_blank" class="w-fit" > diff --git a/apps/frontend/src/pages/app.vue b/apps/frontend/src/pages/app.vue index 284e9bd844..4f94a7ac1a 100644 --- a/apps/frontend/src/pages/app.vue +++ b/apps/frontend/src/pages/app.vue @@ -18,6 +18,7 @@ import { Avatar, Badge, Button, + ButtonLink, Checkbox, commonMessages, defineMessages, @@ -546,7 +547,7 @@ useSeoMeta({

{{ formatMessage(messages.description) }}

-
+
@@ -1228,13 +1231,6 @@ useSeoMeta({ mask-image: none; } - .button-group { - width: fit-content; - margin: 0 auto; - justify-content: center; - mask-image: none; - } - img { width: 100%; max-width: 65rem; diff --git a/apps/frontend/src/pages/dashboard/organizations.vue b/apps/frontend/src/pages/dashboard/organizations.vue index d258f0e5af..fddcf8b664 100644 --- a/apps/frontend/src/pages/dashboard/organizations.vue +++ b/apps/frontend/src/pages/dashboard/organizations.vue @@ -13,11 +13,13 @@
@@ -51,7 +53,14 @@ diff --git a/apps/frontend/wrangler.jsonc b/apps/frontend/wrangler.jsonc index 17d5bb1360..821c5c3e9a 100644 --- a/apps/frontend/wrangler.jsonc +++ b/apps/frontend/wrangler.jsonc @@ -1,13 +1,12 @@ { "$schema": "node_modules/wrangler/config-schema.json", "name": "frontend", - "compatibility_date": "2025-12-10", + "compatibility_date": "2026-08-05", "main": "./.output/server/index.mjs", "assets": { "binding": "ASSETS", "directory": "./.output/public/" }, - "compatibility_flags": ["nodejs_compat_v2"], "routes": ["modrinth.com/*"], "preview_urls": true, "workers_dev": true, @@ -15,8 +14,14 @@ "cpu_ms": 5000 }, "observability": { - "enabled": true, - "head_sampling_rate": 0.0001 + "traces": { + "enabled": true, + "head_sampling_rate": 0.0001 + }, + "logs": { + "enabled": true, + "head_sampling_rate": 0.0001 + } }, "keep_vars": false, "secrets_store_secrets": [ @@ -49,8 +54,14 @@ "env": { "staging": { "observability": { - "enabled": true, - "head_sampling_rate": 0.1 + "traces": { + "enabled": true, + "head_sampling_rate": 0.1 + }, + "logs": { + "enabled": true, + "head_sampling_rate": 0.1 + } }, "routes": ["staging.modrinth.com/*"], "vars": { diff --git a/packages/api-client/src/modules/labrinth/state/index.ts b/packages/api-client/src/modules/labrinth/state/index.ts index f82f36c737..ab15c13b5a 100644 --- a/packages/api-client/src/modules/labrinth/state/index.ts +++ b/packages/api-client/src/modules/labrinth/state/index.ts @@ -154,11 +154,13 @@ export class LabrinthStateModule extends AbstractModule { homePageSearch, homePageNotifs, products, - muralBankDetails: muralBankDetails?.bankDetails, + // Always emit a value: `undefined` is dropped by JSON.stringify, and consumers + // import these keys by name from the generated state. + muralBankDetails: muralBankDetails?.bankDetails ?? {}, tremendousIdMap, countries: iso3166Data.countries, subdivisions: iso3166Data.subdivisions, - taxComplianceThresholds: globals?.tax_compliance_thresholds, + taxComplianceThresholds: globals?.tax_compliance_thresholds ?? {}, errors, } } diff --git a/packages/ui/wrangler.jsonc b/packages/ui/wrangler.jsonc index bdd4196e11..02c1a9e64a 100644 --- a/packages/ui/wrangler.jsonc +++ b/packages/ui/wrangler.jsonc @@ -1,6 +1,6 @@ { "name": "storybook", - "compatibility_date": "2026-02-12", + "compatibility_date": "2026-08-05", "workers_dev": true, "assets": { "directory": "./storybook-static" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72a32c7ed6..f3f5accd91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,7 +179,7 @@ importers: devDependencies: '@eslint/compat': specifier: ^1.1.1 - version: 1.4.1(eslint@9.39.2(jiti@2.6.1)) + version: 1.4.1(eslint@9.39.2(jiti@1.21.7)) '@formatjs/cli': specifier: ^6.2.12 version: 6.12.2(@vue/compiler-core@3.5.27)(vue@3.5.27(typescript@5.9.3)) @@ -188,22 +188,22 @@ importers: version: link:../../packages/tooling-config '@nuxt/eslint-config': specifier: ^0.5.6 - version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) '@taijased/vue-render-tracker': specifier: ^1.0.7 version: 1.0.7(vue@3.5.27(typescript@5.9.3)) '@vitejs/plugin-vue': specifier: ^6.0.3 - version: 6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) + version: 6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3)) autoprefixer: specifier: ^10.4.19 version: 10.4.24(postcss@8.5.6) eslint: specifier: ^9.9.1 - version: 9.39.2(jiti@2.6.1) + version: 9.39.2(jiti@1.21.7) eslint-plugin-turbo: specifier: ^2.5.4 - version: 2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2) + version: 2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2) postcss: specifier: ^8.4.39 version: 8.5.6 @@ -221,7 +221,7 @@ importers: version: 5.9.3 vite: specifier: ^8.0.0 - version: 8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + version: 8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vue-component-type-helpers: specifier: ^3.1.8 version: 3.2.4 @@ -442,8 +442,8 @@ importers: specifier: ^2.0.24 version: 2.2.12(typescript@5.9.3) wrangler: - specifier: ^4.54.0 - version: 4.62.0 + specifier: ^4.115.0 + version: 4.115.0 apps/labrinth: {} @@ -778,10 +778,10 @@ importers: version: 10.2.4(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) '@storybook/builder-vite': specifier: ^10.1.10 - version: 10.2.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)) + version: 10.2.4(esbuild@0.28.1)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)) '@storybook/vue3-vite': specifier: ^10.1.10 - version: 10.2.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3)) + version: 10.2.4(esbuild@0.28.1)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3)) '@stripe/stripe-js': specifier: ^7.3.1 version: 7.9.0 @@ -793,7 +793,7 @@ importers: version: 5.2.4(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3)) eslint-plugin-storybook: specifier: ^10.1.10 - version: 10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) + version: 10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3) storybook: specifier: ^10.1.10 version: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1118,14 +1118,9 @@ packages: resolution: {integrity: sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ==} engines: {node: '>=18.0.0'} - '@cloudflare/unenv-preset@2.12.0': - resolution: {integrity: sha512-NK4vN+2Z/GbfGS4BamtbbVk1rcu5RmqaYGiyHJQrA09AoxdZPHDF3W/EhgI0YSK8p3vRo/VNCtbSJFPON7FWMQ==} - peerDependencies: - unenv: 2.0.0-rc.24 - workerd: ^1.20260115.0 - peerDependenciesMeta: - workerd: - optional: true + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} '@cloudflare/unenv-preset@2.12.1': resolution: {integrity: sha512-tP/Wi+40aBJovonSNJSsS7aFJY0xjuckKplmzDs2Xat06BJ68B6iG7YDUWXJL8gNn0gqW7YC5WhlYhO3QbugQA==} @@ -1136,11 +1131,14 @@ packages: workerd: optional: true - '@cloudflare/workerd-darwin-64@1.20260131.0': - resolution: {integrity: sha512-+1X4qErc715NUhJZNhtlpuCxajhD5YNre7Cz50WPMmj+BMUrh9h7fntKEadtrUo5SM2YONY7CDzK7wdWbJJBVA==} - engines: {node: '>=16'} - cpu: [x64] - os: [darwin] + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true '@cloudflare/workerd-darwin-64@1.20260212.0': resolution: {integrity: sha512-kLxuYutk88Wlo7edp8mlkN68TgZZ9237SUnuX9kNaD5jcOdblUqiBctMRZeRcPsuoX/3g2t0vS4ga02NBEVRNg==} @@ -1148,10 +1146,10 @@ packages: cpu: [x64] os: [darwin] - '@cloudflare/workerd-darwin-arm64@1.20260131.0': - resolution: {integrity: sha512-M84mXR8WEMEBuX4/dL2IQ4wHV/ALwYjx9if5ePZR8rdbD7if/fkEEoMBq0bGS/1gMLRqqCZLstabxHV+g92NNg==} + '@cloudflare/workerd-darwin-64@1.20260722.1': + resolution: {integrity: sha512-vZOP8vIS3NwnuaO+gz0FZ7kIGeiO3bZmxV35Ph9zOXKSREhDFlH7wQ7mkCdhW3O4jnXsew+XT7b+DNEI2CcJGQ==} engines: {node: '>=16'} - cpu: [arm64] + cpu: [x64] os: [darwin] '@cloudflare/workerd-darwin-arm64@1.20260212.0': @@ -1160,11 +1158,11 @@ packages: cpu: [arm64] os: [darwin] - '@cloudflare/workerd-linux-64@1.20260131.0': - resolution: {integrity: sha512-SWzr48bCL9y5wjkj23tXS6t/6us99EAH9T5TAscMV0hfJFZQt97RY/gaHKyRRjFv6jfJZvk7d4g+OmGeYBnwcg==} + '@cloudflare/workerd-darwin-arm64@1.20260722.1': + resolution: {integrity: sha512-EmIQymihDq6WNdER4+LF8Qn80yqayBUpJ+tkOO7wmY8pmgfyXjIUFNXotl21AHovTeu2seR7HdVUgeN/BilCWw==} engines: {node: '>=16'} - cpu: [x64] - os: [linux] + cpu: [arm64] + os: [darwin] '@cloudflare/workerd-linux-64@1.20260212.0': resolution: {integrity: sha512-bCSQoZzDzV5MSh4ueWo1DgmOn4Hf3QBu4Yo3eQFXA2llYFIu/sZgRtkEehw1X2/SY5Sn6O0EMCqxJYRf82Wdeg==} @@ -1172,10 +1170,10 @@ packages: cpu: [x64] os: [linux] - '@cloudflare/workerd-linux-arm64@1.20260131.0': - resolution: {integrity: sha512-mL0kLPGIBJRPeHS3+erJ2t5dJT3ODhsKvR9aA4BcsY7M30/QhlgJIF6wsgwNisTJ23q8PbobZNHBUKIe8l/E9A==} + '@cloudflare/workerd-linux-64@1.20260722.1': + resolution: {integrity: sha512-jvZ3k9fxcnEn04s80CgIYxQfpOyAiz/8qC42DP8EBa9tR27qWyg9wmm31zIobVlrgBZn/+8NfdP73avRGcQOjQ==} engines: {node: '>=16'} - cpu: [arm64] + cpu: [x64] os: [linux] '@cloudflare/workerd-linux-arm64@1.20260212.0': @@ -1184,14 +1182,20 @@ packages: cpu: [arm64] os: [linux] - '@cloudflare/workerd-windows-64@1.20260131.0': - resolution: {integrity: sha512-hoQqTFBpP1zntP2OQSpt5dEWbd9vSBliK+G7LmDXjKitPkmkRFo2PB4P9aBRE1edPAIO/fpdoJv928k2HaAn4A==} + '@cloudflare/workerd-linux-arm64@1.20260722.1': + resolution: {integrity: sha512-BOSB55SMNdy+DA5uj2WirgiNanpHGis5PVvXH1wSfvjRKr4JGgWK+EZzxz0RFUo6QjjQQC/NimEzNZ7va7jmKg==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260212.0': + resolution: {integrity: sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw==} engines: {node: '>=16'} cpu: [x64] os: [win32] - '@cloudflare/workerd-windows-64@1.20260212.0': - resolution: {integrity: sha512-wHRI218Xn4ndgWJCUHH4Zx0YlU5q/o6OmcxXkcw95tJOsQn4lDrhppioPh4eScxJZALf2X+ODeZcyQTCq5exGw==} + '@cloudflare/workerd-windows-64@1.20260722.1': + resolution: {integrity: sha512-sYM8YgUpKnRz2xjvdJLX1Ojzoi4MlA4gk8WTTExhGydjYB2UTs5NIbv0ZmpKgMoK9io3ixgmiW56ZnTbcWOdiA==} engines: {node: '>=16'} cpu: [x64] os: [win32] @@ -1268,6 +1272,9 @@ packages: '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -1290,12 +1297,6 @@ packages: cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.27.0': - resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.2': resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} @@ -1308,6 +1309,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} @@ -1320,12 +1327,6 @@ packages: cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.27.0': - resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.2': resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} @@ -1338,6 +1339,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} @@ -1350,12 +1357,6 @@ packages: cpu: [arm] os: [android] - '@esbuild/android-arm@0.27.0': - resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.2': resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} @@ -1368,6 +1369,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} @@ -1380,12 +1387,6 @@ packages: cpu: [x64] os: [android] - '@esbuild/android-x64@0.27.0': - resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.2': resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} @@ -1398,6 +1399,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} @@ -1410,12 +1417,6 @@ packages: cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.27.0': - resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.2': resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} @@ -1428,6 +1429,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} @@ -1440,12 +1447,6 @@ packages: cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.27.0': - resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.2': resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} @@ -1458,6 +1459,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} @@ -1470,12 +1477,6 @@ packages: cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.27.0': - resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.2': resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} @@ -1488,6 +1489,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} @@ -1500,12 +1507,6 @@ packages: cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.0': - resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.2': resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} @@ -1518,6 +1519,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} @@ -1530,12 +1537,6 @@ packages: cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.27.0': - resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.2': resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} @@ -1548,6 +1549,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} @@ -1560,12 +1567,6 @@ packages: cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.27.0': - resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.2': resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} @@ -1578,6 +1579,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} @@ -1590,12 +1597,6 @@ packages: cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.27.0': - resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.2': resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} @@ -1608,6 +1609,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} @@ -1620,12 +1627,6 @@ packages: cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.27.0': - resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.2': resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} @@ -1638,6 +1639,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} @@ -1650,12 +1657,6 @@ packages: cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.27.0': - resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.2': resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} @@ -1668,6 +1669,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} @@ -1680,12 +1687,6 @@ packages: cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.27.0': - resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.2': resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} @@ -1698,6 +1699,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} @@ -1710,12 +1717,6 @@ packages: cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.27.0': - resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.2': resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} @@ -1728,6 +1729,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} @@ -1740,12 +1747,6 @@ packages: cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.27.0': - resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.2': resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} @@ -1758,6 +1759,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} @@ -1770,12 +1777,6 @@ packages: cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.27.0': - resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.2': resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} @@ -1788,18 +1789,18 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.27.0': - resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.2': resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} @@ -1812,6 +1813,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} @@ -1824,12 +1831,6 @@ packages: cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.0': - resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.2': resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} @@ -1842,18 +1843,18 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.27.0': - resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.2': resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} @@ -1866,6 +1867,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} @@ -1878,12 +1885,6 @@ packages: cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.0': - resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.2': resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} @@ -1896,18 +1897,18 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.27.0': - resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.2': resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} @@ -1920,6 +1921,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} @@ -1932,12 +1939,6 @@ packages: cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.27.0': - resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.2': resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} @@ -1950,6 +1951,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} @@ -1962,12 +1969,6 @@ packages: cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.27.0': - resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.2': resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} @@ -1980,6 +1981,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} @@ -1992,12 +1999,6 @@ packages: cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.27.0': - resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.2': resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} @@ -2010,6 +2011,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} @@ -2022,12 +2029,6 @@ packages: cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.27.0': - resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.2': resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} @@ -2040,6 +2041,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2207,6 +2214,10 @@ packages: resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} engines: {node: '>=18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + '@img/sharp-darwin-arm64@0.33.5': resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2219,6 +2230,12 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + '@img/sharp-darwin-x64@0.33.5': resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2231,6 +2248,17 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + '@img/sharp-libvips-darwin-arm64@1.0.4': resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} cpu: [arm64] @@ -2241,6 +2269,11 @@ packages: cpu: [arm64] os: [darwin] + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + '@img/sharp-libvips-darwin-x64@1.0.4': resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} cpu: [x64] @@ -2251,6 +2284,11 @@ packages: cpu: [x64] os: [darwin] + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + '@img/sharp-libvips-linux-arm64@1.0.4': resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} cpu: [arm64] @@ -2263,6 +2301,12 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-arm@1.0.5': resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} cpu: [arm] @@ -2275,18 +2319,36 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.0.4': resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} cpu: [s390x] @@ -2299,6 +2361,12 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linux-x64@1.0.4': resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} cpu: [x64] @@ -2311,6 +2379,12 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} cpu: [arm64] @@ -2323,6 +2397,12 @@ packages: os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.0.4': resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} cpu: [x64] @@ -2335,6 +2415,12 @@ packages: os: [linux] libc: [musl] + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-linux-arm64@0.33.5': resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2349,6 +2435,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-arm@0.33.5': resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2363,6 +2456,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2370,6 +2470,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2377,6 +2484,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + '@img/sharp-linux-s390x@0.33.5': resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2391,6 +2505,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@img/sharp-linux-x64@0.33.5': resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2405,6 +2526,13 @@ packages: os: [linux] libc: [glibc] + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@img/sharp-linuxmusl-arm64@0.33.5': resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2419,6 +2547,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@img/sharp-linuxmusl-x64@0.33.5': resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2433,6 +2568,13 @@ packages: os: [linux] libc: [musl] + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + '@img/sharp-wasm32@0.33.5': resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2443,12 +2585,27 @@ packages: engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [wasm32] + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + '@img/sharp-win32-arm64@0.34.5': resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [win32] + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + '@img/sharp-win32-ia32@0.33.5': resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2461,6 +2618,12 @@ packages: cpu: [ia32] os: [win32] + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + '@img/sharp-win32-x64@0.33.5': resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2473,6 +2636,12 @@ packages: cpu: [x64] os: [win32] + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@intercom/messenger-js-sdk@0.0.14': resolution: {integrity: sha512-2dH4BDAh9EI90K7hUkAdZ76W79LM45Sd1OBX7t6Vzy8twpNiQ5X+7sH9G5hlJlkSGnf+vFWlFcy9TOYAyEs1hA==} @@ -6018,11 +6187,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.27.0: - resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.2: resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} @@ -6033,6 +6197,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -7586,16 +7755,16 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} - miniflare@4.20260131.0: - resolution: {integrity: sha512-CtObRzlAzOUpCFH+MgImykxmDNKthrgIYtC+oLC3UGpve6bGLomKUW4u4EorTvzlQFHe66/9m/+AYbBbpzG0mQ==} - engines: {node: '>=18.0.0'} - hasBin: true - miniflare@4.20260212.0: resolution: {integrity: sha512-Lgxq83EuR2q/0/DAVOSGXhXS1V7GDB04HVggoPsenQng8sqEDR3hO4FigIw5ZI2Sv2X7kIc30NCzGHJlCFIYWg==} engines: {node: '>=18.0.0'} hasBin: true + miniflare@4.20260722.1: + resolution: {integrity: sha512-FJIg4omaCb2wwSyOeRosEdmVRi3JzGAOOH3pa3twmmtvWECl6BVZMIDwJbjByLKRyU+mrfk0M/n3oSiojFZvSA==} + engines: {node: '>=22.0.0'} + hasBin: true + minimatch@10.1.2: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} @@ -8832,6 +9001,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -8864,6 +9038,10 @@ packages: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@1.2.0: resolution: {integrity: sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==} engines: {node: '>=0.10.0'} @@ -9419,6 +9597,10 @@ packages: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.24: resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} @@ -9992,8 +10174,8 @@ packages: vue-component-type-helpers@3.2.4: resolution: {integrity: sha512-05lR16HeZDcDpB23ku5b5f1fBOoHqFnMiKRr2CiEvbG5Ux4Yi0McmQBOET0dR0nxDXosxyVqv67q6CzS3AK8rw==} - vue-component-type-helpers@3.3.7: - resolution: {integrity: sha512-Skkhw9agYSgsWqv7bxSOGJZa9SaiJbZVGdXuFWnrzKaQYHnw9qbjD630rw6RyMqDbp54nfLCLw5SZA55if7JLg==} + vue-component-type-helpers@3.3.8: + resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} vue-confetti-explosion@1.0.2: resolution: {integrity: sha512-80OboM3/6BItIoZ6DpNcZFqGpF607kjIVc5af56oKgtFmt5yWehvJeoYhkzYlqxrqdBe0Ko4Ie3bWrmLau+dJw==} @@ -10160,22 +10342,22 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} - workerd@1.20260131.0: - resolution: {integrity: sha512-4zZxOdWeActbRfydQQlj7vZ2ay01AjjNC4K3stjmWC3xZHeXeN3EAROwsWE83SZHhtw4rn18srrhtXoQvQMw3Q==} - engines: {node: '>=16'} - hasBin: true - workerd@1.20260212.0: resolution: {integrity: sha512-4B9BoZUzKSRv3pVZGEPh7OX+Q817hpUqAUtz5O0TxJVqo4OsYJAUA/sY177Q5ha/twjT9KaJt2DtQzE+oyCOzw==} engines: {node: '>=16'} hasBin: true - wrangler@4.62.0: - resolution: {integrity: sha512-DogP9jifqw85g33BqwF6m21YBW5J7+Ep9IJLgr6oqHU0RkA79JMN5baeWXdmnIWZl+VZh6bmtNtR+5/Djd32tg==} - engines: {node: '>=20.0.0'} + workerd@1.20260722.1: + resolution: {integrity: sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.115.0: + resolution: {integrity: sha512-+upG2VW66M1sjb43yzgUZ6Ss8iYpJ6+7F3U4GF8TY5EYd+08sYdS54d24AEwCnhuef3J9KlSPusqiqR9WYy1UA==} + engines: {node: '>=22.0.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20260131.0 + '@cloudflare/workers-types': ^5.20260722.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -10229,6 +10411,18 @@ packages: utf-8-validate: optional: true + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -10711,11 +10905,7 @@ snapshots: '@cloudflare/kv-asset-handler@0.4.2': {} - '@cloudflare/unenv-preset@2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260131.0)': - dependencies: - unenv: 2.0.0-rc.24 - optionalDependencies: - workerd: 1.20260131.0 + '@cloudflare/kv-asset-handler@0.5.0': {} '@cloudflare/unenv-preset@2.12.1(unenv@2.0.0-rc.24)(workerd@1.20260212.0)': dependencies: @@ -10723,36 +10913,42 @@ snapshots: optionalDependencies: workerd: 1.20260212.0 - '@cloudflare/workerd-darwin-64@1.20260131.0': - optional: true + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260722.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260722.1 '@cloudflare/workerd-darwin-64@1.20260212.0': optional: true - '@cloudflare/workerd-darwin-arm64@1.20260131.0': + '@cloudflare/workerd-darwin-64@1.20260722.1': optional: true '@cloudflare/workerd-darwin-arm64@1.20260212.0': optional: true - '@cloudflare/workerd-linux-64@1.20260131.0': + '@cloudflare/workerd-darwin-arm64@1.20260722.1': optional: true '@cloudflare/workerd-linux-64@1.20260212.0': optional: true - '@cloudflare/workerd-linux-arm64@1.20260131.0': + '@cloudflare/workerd-linux-64@1.20260722.1': optional: true '@cloudflare/workerd-linux-arm64@1.20260212.0': optional: true - '@cloudflare/workerd-windows-64@1.20260131.0': + '@cloudflare/workerd-linux-arm64@1.20260722.1': optional: true '@cloudflare/workerd-windows-64@1.20260212.0': optional: true + '@cloudflare/workerd-windows-64@1.20260722.1': + optional: true + '@codemirror/autocomplete@6.20.0': dependencies: '@codemirror/language': 6.12.1 @@ -10887,6 +11083,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -10911,259 +11112,256 @@ snapshots: '@esbuild/aix-ppc64@0.25.12': optional: true - '@esbuild/aix-ppc64@0.27.0': - optional: true - '@esbuild/aix-ppc64@0.27.2': optional: true '@esbuild/aix-ppc64@0.27.3': optional: true + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.27.0': - optional: true - '@esbuild/android-arm64@0.27.2': optional: true '@esbuild/android-arm64@0.27.3': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm@0.21.5': optional: true '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.27.0': - optional: true - '@esbuild/android-arm@0.27.2': optional: true '@esbuild/android-arm@0.27.3': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-x64@0.21.5': optional: true '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.27.0': - optional: true - '@esbuild/android-x64@0.27.2': optional: true '@esbuild/android-x64@0.27.3': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.27.0': - optional: true - '@esbuild/darwin-arm64@0.27.2': optional: true '@esbuild/darwin-arm64@0.27.3': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.27.0': - optional: true - '@esbuild/darwin-x64@0.27.2': optional: true '@esbuild/darwin-x64@0.27.3': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.27.0': - optional: true - '@esbuild/freebsd-arm64@0.27.2': optional: true '@esbuild/freebsd-arm64@0.27.3': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.27.0': - optional: true - '@esbuild/freebsd-x64@0.27.2': optional: true '@esbuild/freebsd-x64@0.27.3': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.27.0': - optional: true - '@esbuild/linux-arm64@0.27.2': optional: true '@esbuild/linux-arm64@0.27.3': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.27.0': - optional: true - '@esbuild/linux-arm@0.27.2': optional: true '@esbuild/linux-arm@0.27.3': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.27.0': - optional: true - '@esbuild/linux-ia32@0.27.2': optional: true '@esbuild/linux-ia32@0.27.3': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.27.0': - optional: true - '@esbuild/linux-loong64@0.27.2': optional: true '@esbuild/linux-loong64@0.27.3': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.27.0': - optional: true - '@esbuild/linux-mips64el@0.27.2': optional: true '@esbuild/linux-mips64el@0.27.3': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.27.0': - optional: true - '@esbuild/linux-ppc64@0.27.2': optional: true '@esbuild/linux-ppc64@0.27.3': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.27.0': - optional: true - '@esbuild/linux-riscv64@0.27.2': optional: true '@esbuild/linux-riscv64@0.27.3': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.27.0': - optional: true - '@esbuild/linux-s390x@0.27.2': optional: true '@esbuild/linux-s390x@0.27.3': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.27.0': - optional: true - '@esbuild/linux-x64@0.27.2': optional: true '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/netbsd-arm64@0.25.12': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.0': + '@esbuild/netbsd-arm64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.27.2': @@ -11172,25 +11370,25 @@ snapshots: '@esbuild/netbsd-arm64@0.27.3': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.27.0': - optional: true - '@esbuild/netbsd-x64@0.27.2': optional: true '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.25.12': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.0': + '@esbuild/openbsd-arm64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.27.2': @@ -11199,25 +11397,25 @@ snapshots: '@esbuild/openbsd-arm64@0.27.3': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.27.0': - optional: true - '@esbuild/openbsd-x64@0.27.2': optional: true '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openharmony-arm64@0.25.12': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.0': + '@esbuild/openharmony-arm64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.27.2': @@ -11226,66 +11424,69 @@ snapshots: '@esbuild/openharmony-arm64@0.27.3': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.27.0': - optional: true - '@esbuild/sunos-x64@0.27.2': optional: true '@esbuild/sunos-x64@0.27.3': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.27.0': - optional: true - '@esbuild/win32-arm64@0.27.2': optional: true '@esbuild/win32-arm64@0.27.3': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.27.0': - optional: true - '@esbuild/win32-ia32@0.27.2': optional: true '@esbuild/win32-ia32@0.27.3': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.27.0': - optional: true - '@esbuild/win32-x64@0.27.2': optional: true '@esbuild/win32-x64@0.27.3': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint: 9.39.2(jiti@1.21.7) @@ -11298,6 +11499,12 @@ snapshots: '@eslint-community/regexpp@4.12.2': {} + '@eslint/compat@1.4.1(eslint@9.39.2(jiti@1.21.7))': + dependencies: + '@eslint/core': 0.17.0 + optionalDependencies: + eslint: 9.39.2(jiti@1.21.7) + '@eslint/compat@1.4.1(eslint@9.39.2(jiti@2.6.1))': dependencies: '@eslint/core': 0.17.0 @@ -11477,6 +11684,8 @@ snapshots: '@img/colour@1.0.0': {} + '@img/colour@1.1.0': {} + '@img/sharp-darwin-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-arm64': 1.0.4 @@ -11487,6 +11696,11 @@ snapshots: '@img/sharp-libvips-darwin-arm64': 1.2.4 optional: true + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + '@img/sharp-darwin-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-darwin-x64': 1.0.4 @@ -11497,60 +11711,100 @@ snapshots: '@img/sharp-libvips-darwin-x64': 1.2.4 optional: true + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-libvips-darwin-arm64@1.0.4': optional: true '@img/sharp-libvips-darwin-arm64@1.2.4': optional: true + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + '@img/sharp-libvips-darwin-x64@1.0.4': optional: true '@img/sharp-libvips-darwin-x64@1.2.4': optional: true + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm64@1.0.4': optional: true '@img/sharp-libvips-linux-arm64@1.2.4': optional: true + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linux-arm@1.0.5': optional: true '@img/sharp-libvips-linux-arm@1.2.4': optional: true + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + '@img/sharp-libvips-linux-ppc64@1.2.4': optional: true + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + '@img/sharp-libvips-linux-riscv64@1.2.4': optional: true + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + '@img/sharp-libvips-linux-s390x@1.0.4': optional: true '@img/sharp-libvips-linux-s390x@1.2.4': optional: true + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + '@img/sharp-libvips-linux-x64@1.0.4': optional: true '@img/sharp-libvips-linux-x64@1.2.4': optional: true + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': optional: true '@img/sharp-libvips-linuxmusl-arm64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + '@img/sharp-libvips-linuxmusl-x64@1.0.4': optional: true '@img/sharp-libvips-linuxmusl-x64@1.2.4': optional: true + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + '@img/sharp-linux-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm64': 1.0.4 @@ -11561,6 +11815,11 @@ snapshots: '@img/sharp-libvips-linux-arm64': 1.2.4 optional: true + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + '@img/sharp-linux-arm@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-arm': 1.0.5 @@ -11571,16 +11830,31 @@ snapshots: '@img/sharp-libvips-linux-arm': 1.2.4 optional: true + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + '@img/sharp-linux-ppc64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-ppc64': 1.2.4 optional: true + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + '@img/sharp-linux-riscv64@0.34.5': optionalDependencies: '@img/sharp-libvips-linux-riscv64': 1.2.4 optional: true + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + '@img/sharp-linux-s390x@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-s390x': 1.0.4 @@ -11591,6 +11865,11 @@ snapshots: '@img/sharp-libvips-linux-s390x': 1.2.4 optional: true + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + '@img/sharp-linux-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linux-x64': 1.0.4 @@ -11601,6 +11880,11 @@ snapshots: '@img/sharp-libvips-linux-x64': 1.2.4 optional: true + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-arm64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 @@ -11611,6 +11895,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 optional: true + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + '@img/sharp-linuxmusl-x64@0.33.5': optionalDependencies: '@img/sharp-libvips-linuxmusl-x64': 1.0.4 @@ -11621,6 +11910,11 @@ snapshots: '@img/sharp-libvips-linuxmusl-x64': 1.2.4 optional: true + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + '@img/sharp-wasm32@0.33.5': dependencies: '@emnapi/runtime': 1.8.1 @@ -11631,21 +11925,40 @@ snapshots: '@emnapi/runtime': 1.8.1 optional: true + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + '@img/sharp-win32-arm64@0.34.5': optional: true + '@img/sharp-win32-arm64@0.35.2': + optional: true + '@img/sharp-win32-ia32@0.33.5': optional: true '@img/sharp-win32-ia32@0.34.5': optional: true + '@img/sharp-win32-ia32@0.35.2': + optional: true + '@img/sharp-win32-x64@0.33.5': optional: true '@img/sharp-win32-x64@0.34.5': optional: true + '@img/sharp-win32-x64@0.35.2': + optional: true + '@intercom/messenger-js-sdk@0.0.14': {} '@intlify/core-base@10.0.8': @@ -11960,6 +12273,31 @@ snapshots: - utf-8-validate - vue + '@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint/js': 9.39.2 + '@nuxt/eslint-plugin': 0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + eslint-config-flat-gitignore: 0.3.0(eslint@9.39.2(jiti@1.21.7)) + eslint-flat-config-utils: 0.4.0 + eslint-plugin-import-x: 4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-jsdoc: 50.8.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-regexp: 2.10.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-unicorn: 55.0.0(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-vue: 9.33.0(eslint@9.39.2(jiti@1.21.7)) + globals: 15.15.0 + local-pkg: 0.5.1 + pathe: 1.1.2 + vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7)) + transitivePeerDependencies: + - '@typescript-eslint/utils' + - eslint-import-resolver-node + - supports-color + - typescript + '@nuxt/eslint-config@0.5.7(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint/js': 9.39.2 @@ -11985,6 +12323,15 @@ snapshots: - supports-color - typescript + '@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + - typescript + '@nuxt/eslint-plugin@0.5.7(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 @@ -13426,9 +13773,9 @@ snapshots: storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 - '@storybook/builder-vite@10.2.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))': + '@storybook/builder-vite@10.2.4(esbuild@0.28.1)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))': dependencies: - '@storybook/csf-plugin': 10.2.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)) + '@storybook/csf-plugin': 10.2.4(esbuild@0.28.1)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)) storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) ts-dedent: 2.2.0 vite: 5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0) @@ -13437,12 +13784,12 @@ snapshots: - rollup - webpack - '@storybook/csf-plugin@10.2.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))': + '@storybook/csf-plugin@10.2.4(esbuild@0.28.1)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))': dependencies: storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) unplugin: 2.3.11 optionalDependencies: - esbuild: 0.27.3 + esbuild: 0.28.1 rollup: 4.57.1 vite: 5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0) @@ -13453,9 +13800,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@storybook/vue3-vite@10.2.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3))': + '@storybook/vue3-vite@10.2.4(esbuild@0.28.1)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0))(vue@3.5.27(typescript@5.9.3))': dependencies: - '@storybook/builder-vite': 10.2.4(esbuild@0.27.3)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)) + '@storybook/builder-vite': 10.2.4(esbuild@0.28.1)(rollup@4.57.1)(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@5.4.21(@types/node@24.12.2)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)) '@storybook/vue3': 10.2.4(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vue@3.5.27(typescript@5.9.3)) magic-string: 0.30.21 storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -13475,10 +13822,22 @@ snapshots: storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) type-fest: 2.19.0 vue: 3.5.27(typescript@5.9.3) - vue-component-type-helpers: 3.3.7 + vue-component-type-helpers: 3.3.8 '@stripe/stripe-js@7.9.0': {} + '@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + eslint: 9.39.2(jiti@1.21.7) + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + estraverse: 5.3.0 + picomatch: 4.0.4 + transitivePeerDependencies: + - supports-color + - typescript + '@stylistic/eslint-plugin@2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) @@ -13893,6 +14252,22 @@ snapshots: dependencies: '@types/node': 24.12.2 + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/type-utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.54.0 + eslint: 9.39.2(jiti@1.21.7) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/eslint-plugin@8.54.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -13909,6 +14284,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.54.0 + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.54.0 + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.54.0 @@ -13952,6 +14339,18 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.54.0 + '@typescript-eslint/typescript-estree': 8.54.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/type-utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.54.0 @@ -14144,6 +14543,12 @@ snapshots: vite: 7.3.1(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) vue: 3.5.27(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.0-rc.2 + vite: 8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2) + vue: 3.5.27(typescript@5.9.3) + '@vitejs/plugin-vue@6.0.4(vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2))(vue@3.5.27(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 @@ -15637,35 +16042,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.27.0: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.0 - '@esbuild/android-arm': 0.27.0 - '@esbuild/android-arm64': 0.27.0 - '@esbuild/android-x64': 0.27.0 - '@esbuild/darwin-arm64': 0.27.0 - '@esbuild/darwin-x64': 0.27.0 - '@esbuild/freebsd-arm64': 0.27.0 - '@esbuild/freebsd-x64': 0.27.0 - '@esbuild/linux-arm': 0.27.0 - '@esbuild/linux-arm64': 0.27.0 - '@esbuild/linux-ia32': 0.27.0 - '@esbuild/linux-loong64': 0.27.0 - '@esbuild/linux-mips64el': 0.27.0 - '@esbuild/linux-ppc64': 0.27.0 - '@esbuild/linux-riscv64': 0.27.0 - '@esbuild/linux-s390x': 0.27.0 - '@esbuild/linux-x64': 0.27.0 - '@esbuild/netbsd-arm64': 0.27.0 - '@esbuild/netbsd-x64': 0.27.0 - '@esbuild/openbsd-arm64': 0.27.0 - '@esbuild/openbsd-x64': 0.27.0 - '@esbuild/openharmony-arm64': 0.27.0 - '@esbuild/sunos-x64': 0.27.0 - '@esbuild/win32-arm64': 0.27.0 - '@esbuild/win32-ia32': 0.27.0 - '@esbuild/win32-x64': 0.27.0 - esbuild@0.27.2: optionalDependencies: '@esbuild/aix-ppc64': 0.27.2 @@ -15724,6 +16100,35 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -15734,6 +16139,12 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@eslint/compat': 1.4.1(eslint@9.39.2(jiti@1.21.7)) + eslint: 9.39.2(jiti@1.21.7) + find-up-simple: 1.0.1 + eslint-config-flat-gitignore@0.3.0(eslint@9.39.2(jiti@2.6.1)): dependencies: '@eslint/compat': 1.4.1(eslint@9.39.2(jiti@2.6.1)) @@ -15755,6 +16166,23 @@ snapshots: optionalDependencies: unrs-resolver: 1.11.1 + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@typescript-eslint/types': 8.54.0 + comment-parser: 1.4.5 + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + eslint-import-context: 0.1.9(unrs-resolver@1.11.1) + is-glob: 4.0.3 + minimatch: 10.1.2 + semver: 7.7.4 + stable-hash-x: 0.2.0 + unrs-resolver: 1.11.1 + optionalDependencies: + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + transitivePeerDependencies: + - supports-color + eslint-plugin-import-x@4.16.1(@typescript-eslint/utils@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@typescript-eslint/types': 8.54.0 @@ -15772,6 +16200,22 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@es-joy/jsdoccomment': 0.50.2 + are-docs-informative: 0.0.2 + comment-parser: 1.4.1 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint: 9.39.2(jiti@1.21.7) + espree: 10.4.0 + esquery: 1.7.0 + parse-imports-exports: 0.2.4 + semver: 7.7.4 + spdx-expression-parse: 4.0.0 + transitivePeerDependencies: + - supports-color + eslint-plugin-jsdoc@50.8.0(eslint@9.39.2(jiti@2.6.1)): dependencies: '@es-joy/jsdoccomment': 0.50.2 @@ -15797,6 +16241,17 @@ snapshots: optionalDependencies: eslint-config-prettier: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/regexpp': 4.12.2 + comment-parser: 1.4.5 + eslint: 9.39.2(jiti@1.21.7) + jsdoc-type-pratt-parser: 4.8.0 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + scslre: 0.3.0 + eslint-plugin-regexp@2.10.0(eslint@9.39.2(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -15812,21 +16267,47 @@ snapshots: dependencies: eslint: 9.39.2(jiti@2.6.1) - eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@1.21.7))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): + eslint-plugin-storybook@10.2.4(eslint@9.39.2(jiti@2.6.1))(storybook@10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3): dependencies: - '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - eslint: 9.39.2(jiti@1.21.7) + '@typescript-eslint/utils': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) storybook: 10.2.4(@testing-library/dom@10.4.1)(prettier@3.8.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) transitivePeerDependencies: - supports-color - typescript + eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@1.21.7))(turbo@2.8.2): + dependencies: + dotenv: 16.0.3 + eslint: 9.39.2(jiti@1.21.7) + turbo: 2.8.2 + eslint-plugin-turbo@2.8.2(eslint@9.39.2(jiti@2.6.1))(turbo@2.8.2): dependencies: dotenv: 16.0.3 eslint: 9.39.2(jiti@2.6.1) turbo: 2.8.2 + eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + ci-info: 4.4.0 + clean-regexp: 1.0.0 + core-js-compat: 3.48.0 + eslint: 9.39.2(jiti@1.21.7) + esquery: 1.7.0 + globals: 15.15.0 + indent-string: 4.0.0 + is-builtin-module: 3.2.1 + jsesc: 3.1.0 + pluralize: 8.0.0 + read-pkg-up: 7.0.1 + regexp-tree: 0.1.27 + regjsparser: 0.10.0 + semver: 7.7.4 + strip-indent: 3.0.0 + eslint-plugin-unicorn@55.0.0(eslint@9.39.2(jiti@2.6.1)): dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -15861,6 +16342,20 @@ snapshots: '@stylistic/eslint-plugin': 2.13.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@1.21.7)): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + eslint: 9.39.2(jiti@1.21.7) + globals: 13.24.0 + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 6.1.2 + semver: 7.7.4 + vue-eslint-parser: 9.4.3(eslint@9.39.2(jiti@1.21.7)) + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - supports-color + eslint-plugin-vue@9.33.0(eslint@9.39.2(jiti@2.6.1)): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -17713,18 +18208,6 @@ snapshots: min-indent@1.0.1: {} - miniflare@4.20260131.0: - dependencies: - '@cspotcode/source-map-support': 0.8.1 - sharp: 0.34.5 - undici: 7.18.2 - workerd: 1.20260131.0 - ws: 8.18.0 - youch: 4.1.0-beta.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - miniflare@4.20260212.0: dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -17737,6 +18220,18 @@ snapshots: - bufferutil - utf-8-validate + miniflare@4.20260722.1: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.28.0 + workerd: 1.20260722.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.1.2: dependencies: '@isaacs/brace-expansion': 5.0.1 @@ -19281,6 +19776,8 @@ snapshots: semver@7.7.4: {} + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -19377,6 +19874,38 @@ snapshots: '@img/sharp-win32-ia32': 0.34.5 '@img/sharp-win32-x64': 0.34.5 + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shebang-command@1.2.0: dependencies: shebang-regex: 1.0.0 @@ -19952,6 +20481,8 @@ snapshots: undici@7.18.2: {} + undici@7.28.0: {} + unenv@2.0.0-rc.24: dependencies: pathe: 2.0.3 @@ -20357,6 +20888,22 @@ snapshots: yaml: 2.8.2 optional: true + vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@1.21.7)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.8 + rolldown: 1.0.0-rc.12 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.12.2 + esbuild: 0.27.3 + fsevents: 2.3.3 + jiti: 1.21.7 + sass: 1.97.3 + terser: 5.46.0 + yaml: 2.8.2 + vite@8.0.3(@types/node@24.12.2)(esbuild@0.27.3)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2): dependencies: lightningcss: 1.32.0 @@ -20493,7 +21040,7 @@ snapshots: vue-component-type-helpers@3.2.4: {} - vue-component-type-helpers@3.3.7: {} + vue-component-type-helpers@3.3.8: {} vue-confetti-explosion@1.0.2(vue@3.5.27(typescript@5.9.3)): dependencies: @@ -20533,6 +21080,19 @@ snapshots: transitivePeerDependencies: - supports-color + vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@1.21.7)): + dependencies: + debug: 4.4.3 + eslint: 9.39.2(jiti@1.21.7) + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.7.0 + lodash: 4.17.23 + semver: 7.7.4 + transitivePeerDependencies: + - supports-color + vue-eslint-parser@9.4.3(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 4.4.3 @@ -20667,14 +21227,6 @@ snapshots: word-wrap@1.2.5: {} - workerd@1.20260131.0: - optionalDependencies: - '@cloudflare/workerd-darwin-64': 1.20260131.0 - '@cloudflare/workerd-darwin-arm64': 1.20260131.0 - '@cloudflare/workerd-linux-64': 1.20260131.0 - '@cloudflare/workerd-linux-arm64': 1.20260131.0 - '@cloudflare/workerd-windows-64': 1.20260131.0 - workerd@1.20260212.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20260212.0 @@ -20683,16 +21235,24 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20260212.0 '@cloudflare/workerd-windows-64': 1.20260212.0 - wrangler@4.62.0: + workerd@1.20260722.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260722.1 + '@cloudflare/workerd-darwin-arm64': 1.20260722.1 + '@cloudflare/workerd-linux-64': 1.20260722.1 + '@cloudflare/workerd-linux-arm64': 1.20260722.1 + '@cloudflare/workerd-windows-64': 1.20260722.1 + + wrangler@4.115.0: dependencies: - '@cloudflare/kv-asset-handler': 0.4.2 - '@cloudflare/unenv-preset': 2.12.0(unenv@2.0.0-rc.24)(workerd@1.20260131.0) + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260722.1) blake3-wasm: 2.1.5 - esbuild: 0.27.0 - miniflare: 4.20260131.0 + esbuild: 0.28.1 + miniflare: 4.20260722.1 path-to-regexp: 6.3.0 unenv: 2.0.0-rc.24 - workerd: 1.20260131.0 + workerd: 1.20260722.1 optionalDependencies: fsevents: 2.3.3 transitivePeerDependencies: @@ -20739,6 +21299,8 @@ snapshots: ws@8.19.0: {} + ws@8.21.0: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 From c671ca52f63a02856b6f3d3d89ed143f38c5f350 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Thu, 6 Aug 2026 16:10:46 +0100 Subject: [PATCH 068/145] fix: dropdown panel at 0,0 (#7024) --- .../ui/src/components/base/DropdownFilterBar.vue | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/base/DropdownFilterBar.vue b/packages/ui/src/components/base/DropdownFilterBar.vue index ceaea500db..90163795c1 100644 --- a/packages/ui/src/components/base/DropdownFilterBar.vue +++ b/packages/ui/src/components/base/DropdownFilterBar.vue @@ -387,7 +387,7 @@ import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars' import type { Component, ComponentPublicInstance, CSSProperties } from 'vue' import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue' -import { Button, type ButtonSize } from '#ui/components/base/buttons' +import { Button, type ButtonElementHandle, type ButtonSize } from '#ui/components/base/buttons' import { useVirtualScroll } from '../../composables/virtual-scroll' import MultiSelect, { type MultiSelectItem } from './MultiSelect.vue' @@ -554,7 +554,7 @@ const isCursorInsideSubmenu = ref(false) const hasSubmenuPosition = ref(false) const isMobileAddMenuLayout = ref(false) const submenuOpenDirection = ref('right') -const addMenuTrigger = ref(null) +const addMenuTrigger = ref(null) const menuContainer = ref(null) const submenu = ref(null) const activeCategoryOptionsScrollbar = ref(null) @@ -732,8 +732,9 @@ const addMenuTransitionEnterActiveClass = computed(() => const addMenuTransitionEnterFromClass = computed(() => isMobileAddMenuLayout.value ? 'opacity-100' : 'opacity-0', ) +const addMenuTriggerElement = computed(() => addMenuTrigger.value?.element ?? null) const addMenuOutsideClickTarget = computed(() => menuContainer.value ?? submenu.value) -const addMenuOutsideClickIgnore = computed(() => [addMenuTrigger, menuContainer, submenu]) +const addMenuOutsideClickIgnore = computed(() => [addMenuTriggerElement, menuContainer, submenu]) const appliedFilterPreviews = computed(() => Object.entries(props.modelValue) @@ -984,7 +985,7 @@ function handleAddMenuKeydown(event: KeyboardEvent) { event.preventDefault() closeAddMenu() - nextTick(() => addMenuTrigger.value?.focus()) + nextTick(() => addMenuTriggerElement.value?.focus()) } function setCategoryButtonRef( @@ -1585,11 +1586,12 @@ function triangleArea(a: Point, b: Point, c: Point): number { function updateAddMenuPosition(): boolean { const positioningElement = menuContainer.value ?? (isMobileActiveSubmenu.value ? submenu.value : null) - if (typeof window === 'undefined' || !addMenuTrigger.value || !positioningElement) { + const triggerElement = addMenuTriggerElement.value + if (typeof window === 'undefined' || !triggerElement || !positioningElement) { return false } - const triggerRect = addMenuTrigger.value.getBoundingClientRect() + const triggerRect = triggerElement.getBoundingClientRect() const dropdownWidth = Math.max(ADD_MENU_WIDTH, triggerRect.width) addMenuStyle.value = { From b8d97268cb5041c0ed021654a1d5a1b1960a1a14 Mon Sep 17 00:00:00 2001 From: "Michael H." Date: Thu, 6 Aug 2026 17:39:06 +0200 Subject: [PATCH 069/145] perf: further reduce memory (#7015) * feat: server source maps * perf: strip unused fields from search results * perf: reduce game version memory usage --- apps/frontend/src/composables/generated.ts | 35 ++++++++++++++++--- .../src/pages/discover/[type]/index.vue | 19 ++++++++-- .../plugins/update-game-versions.client.ts | 19 ++++++++++ .../src/plugins/update-game-versions.ts | 17 --------- apps/frontend/wrangler.jsonc | 1 + 5 files changed, 67 insertions(+), 24 deletions(-) create mode 100644 apps/frontend/src/plugins/update-game-versions.client.ts delete mode 100644 apps/frontend/src/plugins/update-game-versions.ts diff --git a/apps/frontend/src/composables/generated.ts b/apps/frontend/src/composables/generated.ts index e178ab048a..8a7b895e0f 100644 --- a/apps/frontend/src/composables/generated.ts +++ b/apps/frontend/src/composables/generated.ts @@ -68,11 +68,16 @@ export interface GeneratedState extends GloballyUsedState { } /** - * Composable for accessing the globally used generated state. - * This includes both fetched data and runtime-defined constants. + * Built once per module load rather than via `useState`, because every `useState` value is + * serialized into the SSR payload of every page. This is build-time constant data that the + * client already has via the static import above, so putting it in the payload would ship a + * second copy for no benefit. + * + * Consequence: on the server this object is shared by every request in the isolate, so it must + * stay immutable there. Runtime refreshes are client-only and go through `setGameVersions`. */ -export const useGeneratedState = () => - useState('generatedState', () => ({ +const generatedState = shallowRef( + Object.freeze({ // Cast JSON data to typed API responses categories: (categories ?? []) as Labrinth.Tags.v2.Category[], loaders: (loaders ?? []) as Labrinth.Tags.v2.Loader[], @@ -147,4 +152,24 @@ export const useGeneratedState = () => errors, buildYear: new Date().getFullYear(), - })) + }) as GeneratedState, +) + +/** + * Composable for accessing the globally used generated state. + * This includes both fetched data and runtime-defined constants. + */ +export const useGeneratedState = () => generatedState + +/** + * Replaces the build-time game versions with a freshly fetched list. Client-only: mutating this + * on the server would leak across every request sharing the isolate. + */ +export function setGameVersions(versions: Labrinth.Tags.v2.GameVersion[]) { + if (import.meta.server) return + + generatedState.value = Object.freeze({ + ...generatedState.value, + gameVersions: versions, + }) as GeneratedState +} diff --git a/apps/frontend/src/pages/discover/[type]/index.vue b/apps/frontend/src/pages/discover/[type]/index.vue index 8b24b6f5f2..993c13eabe 100644 --- a/apps/frontend/src/pages/discover/[type]/index.vue +++ b/apps/frontend/src/pages/discover/[type]/index.vue @@ -228,6 +228,19 @@ function parseSearchParams(requestParams: string): Labrinth.Search.SearchParams } } +// Search returns expanded dependency data that no card renders and that isn't part of +// ResultSearchProject. On modpacks it is ~90% of the response, and everything cached here +// is serialized into the SSR payload, so drop it before it reaches the query cache. +function stripUnrenderedFields( + hits: Labrinth.Search.v3.ResultSearchProject[], +): Labrinth.Search.v3.ResultSearchProject[] { + return hits.map((hit) => { + const { dependencies, dependency_project_ids, compatible_dependency_project_ids, ...rendered } = + hit as Labrinth.Search.v3.ResultSearchProject & Record + return rendered as Labrinth.Search.v3.ResultSearchProject + }) +} + async function fetchSearch(requestParams: string) { debug('search() called', { requestParams: requestParams.substring(0, 100), @@ -241,17 +254,19 @@ async function fetchSearch(requestParams: string) { debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length }) + const hits = stripUnrenderedFields(raw.hits ?? []) + if (isServerType.value) { return { projectHits: [], - serverHits: raw.hits, + serverHits: hits, total_hits: raw.total_hits, per_page: raw.hits_per_page, } } return { - projectHits: raw.hits, + projectHits: hits, serverHits: [], total_hits: raw.total_hits, per_page: raw.hits_per_page, diff --git a/apps/frontend/src/plugins/update-game-versions.client.ts b/apps/frontend/src/plugins/update-game-versions.client.ts new file mode 100644 index 0000000000..b73976b254 --- /dev/null +++ b/apps/frontend/src/plugins/update-game-versions.client.ts @@ -0,0 +1,19 @@ +import type { Labrinth } from '@modrinth/api-client' + +import { setGameVersions } from '~/composables/generated' + +export default defineNuxtPlugin((nuxtApp) => { + // Deferred until after hydration: the server renders with the build-time game versions, so + // swapping them in during setup would make the first client render disagree with the markup. + nuxtApp.hook('app:suspense:resolve', async () => { + try { + const gameVersions = await $fetch('/api/tags/game-versions') + + if (gameVersions && gameVersions.length > 0) { + setGameVersions(gameVersions) + } + } catch (error) { + console.error('[Game Version Updater] Failed to fetch:', error) + } + }) +}) diff --git a/apps/frontend/src/plugins/update-game-versions.ts b/apps/frontend/src/plugins/update-game-versions.ts deleted file mode 100644 index 5ded43620b..0000000000 --- a/apps/frontend/src/plugins/update-game-versions.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Labrinth } from '@modrinth/api-client' - -export default defineNuxtPlugin(async () => { - try { - const gameVersions = await $fetch('/api/tags/game-versions') - - if (gameVersions && gameVersions.length > 0) { - const state = useState<{ gameVersions: Labrinth.Tags.v2.GameVersion[] }>('generatedState') - - if (state.value) { - state.value.gameVersions = gameVersions - } - } - } catch (error) { - console.error('[Game Version Updater] Failed to fetch:', error) - } -}) diff --git a/apps/frontend/wrangler.jsonc b/apps/frontend/wrangler.jsonc index 821c5c3e9a..22da20148b 100644 --- a/apps/frontend/wrangler.jsonc +++ b/apps/frontend/wrangler.jsonc @@ -10,6 +10,7 @@ "routes": ["modrinth.com/*"], "preview_urls": true, "workers_dev": true, + "upload_source_maps": true, "limits": { "cpu_ms": 5000 }, From 8aa22b9bed38151e7eda1c5b66fade475b236ed5 Mon Sep 17 00:00:00 2001 From: Mr_chank <180248271+chank-op@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:39:05 +1000 Subject: [PATCH 070/145] refactor(i18n): use common messages for generic button labels (#7020) A few components defined their own "Cancel", "Close", "Clear" and "Success" descriptors when commonMessages already has them. Swapped them over and removed the old ones. Locale files come from intl:prune-local dropping the now-orphaned strings across every language. --- .../ui/modal/ModrinthAccountRequiredModal.vue | 15 +++++++++------ apps/app-frontend/src/locales/da-DK/index.json | 3 --- apps/app-frontend/src/locales/de-CH/index.json | 3 --- apps/app-frontend/src/locales/de-DE/index.json | 3 --- apps/app-frontend/src/locales/en-US/index.json | 3 --- apps/app-frontend/src/locales/es-419/index.json | 3 --- apps/app-frontend/src/locales/fr-FR/index.json | 3 --- apps/app-frontend/src/locales/hu-HU/index.json | 3 --- apps/app-frontend/src/locales/it-IT/index.json | 3 --- apps/app-frontend/src/locales/ja-JP/index.json | 3 --- apps/app-frontend/src/locales/ko-KR/index.json | 3 --- apps/app-frontend/src/locales/nl-NL/index.json | 3 --- apps/app-frontend/src/locales/pl-PL/index.json | 3 --- apps/app-frontend/src/locales/pt-BR/index.json | 3 --- apps/app-frontend/src/locales/ru-RU/index.json | 3 --- apps/app-frontend/src/locales/sv-SE/index.json | 3 --- apps/app-frontend/src/locales/tr-TR/index.json | 3 --- apps/app-frontend/src/locales/uk-UA/index.json | 3 --- apps/app-frontend/src/locales/zh-CN/index.json | 3 --- apps/app-frontend/src/locales/zh-TW/index.json | 3 --- .../ui/dashboard/CreatorTaxFormModal.vue | 6 +----- .../components/ui/moderation/ModpackScanModal.vue | 13 +++---------- apps/frontend/src/locales/de-CH/index.json | 9 --------- apps/frontend/src/locales/de-DE/index.json | 9 --------- apps/frontend/src/locales/en-US/index.json | 9 --------- apps/frontend/src/locales/es-419/index.json | 9 --------- apps/frontend/src/locales/fr-FR/index.json | 9 --------- apps/frontend/src/locales/hu-HU/index.json | 3 --- apps/frontend/src/locales/it-IT/index.json | 9 --------- apps/frontend/src/locales/ja-JP/index.json | 3 --- apps/frontend/src/locales/ko-KR/index.json | 9 --------- apps/frontend/src/locales/ms-MY/index.json | 6 ------ apps/frontend/src/locales/nl-NL/index.json | 9 --------- apps/frontend/src/locales/pl-PL/index.json | 9 --------- apps/frontend/src/locales/pt-BR/index.json | 9 --------- apps/frontend/src/locales/ru-RU/index.json | 9 --------- apps/frontend/src/locales/sv-SE/index.json | 6 ------ apps/frontend/src/locales/tr-TR/index.json | 6 ------ apps/frontend/src/locales/uk-UA/index.json | 9 --------- apps/frontend/src/locales/zh-CN/index.json | 9 --------- apps/frontend/src/locales/zh-TW/index.json | 9 --------- .../sharing/invite-players-modal/index.vue | 9 ++++----- .../invite-players-modal-invite-link-editor.vue | 9 +++------ .../components/editor/EditorFindReplace.vue | 9 +++------ packages/ui/src/locales/cs-CZ/index.json | 4 ---- packages/ui/src/locales/da-DK/index.json | 4 ---- packages/ui/src/locales/de-CH/index.json | 10 ---------- packages/ui/src/locales/de-DE/index.json | 10 ---------- packages/ui/src/locales/en-US/index.json | 9 --------- packages/ui/src/locales/es-419/index.json | 10 ---------- packages/ui/src/locales/es-ES/index.json | 3 --- packages/ui/src/locales/fil-PH/index.json | 4 ---- packages/ui/src/locales/fr-FR/index.json | 9 --------- packages/ui/src/locales/hu-HU/index.json | 9 --------- packages/ui/src/locales/id-ID/index.json | 4 ---- packages/ui/src/locales/it-IT/index.json | 9 --------- packages/ui/src/locales/ja-JP/index.json | 3 --- packages/ui/src/locales/ko-KR/index.json | 9 --------- packages/ui/src/locales/ms-MY/index.json | 4 ---- packages/ui/src/locales/nl-NL/index.json | 9 --------- packages/ui/src/locales/pl-PL/index.json | 9 --------- packages/ui/src/locales/pt-BR/index.json | 10 ---------- packages/ui/src/locales/pt-PT/index.json | 4 ---- packages/ui/src/locales/ro-RO/index.json | 3 --- packages/ui/src/locales/ru-RU/index.json | 9 --------- packages/ui/src/locales/sr-CS/index.json | 4 ---- packages/ui/src/locales/sv-SE/index.json | 10 ---------- packages/ui/src/locales/tr-TR/index.json | 3 --- packages/ui/src/locales/uk-UA/index.json | 9 --------- packages/ui/src/locales/vi-VN/index.json | 4 ---- packages/ui/src/locales/zh-CN/index.json | 10 ---------- packages/ui/src/locales/zh-TW/index.json | 10 ---------- 72 files changed, 23 insertions(+), 440 deletions(-) diff --git a/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue index 18b832278d..ad413f1ab8 100644 --- a/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue +++ b/apps/app-frontend/src/components/ui/modal/ModrinthAccountRequiredModal.vue @@ -73,7 +73,7 @@
- + + +
+ + diff --git a/packages/ui/src/layouts/shared/user-profile/layout.vue b/packages/ui/src/layouts/shared/user-profile/layout.vue index 608994520e..bd16975459 100644 --- a/packages/ui/src/layouts/shared/user-profile/layout.vue +++ b/packages/ui/src/layouts/shared/user-profile/layout.vue @@ -496,7 +496,7 @@ import { injectPageContext, injectTags, } from '#ui/providers' -import { commonMessages, getProjectTypeTitleMessage } from '#ui/utils' +import { commonMessages, getProjectTypeTitleMessage, sortProjectTypes } from '#ui/utils' import { blockedUsersQueryKey, injectUserProfile } from './providers' import { @@ -840,7 +840,7 @@ const projectTypes = computed(() => { const types = new Set(projects.value.map((project) => project.resolvedProjectType)) if (collections.value.length > 0) types.add('collection') types.delete('project') - return [...types] + return sortProjectTypes(types) }) const navLinks = computed(() => { @@ -851,15 +851,13 @@ const navLinks = computed(() => { label: formatMessage(commonMessages.allProjectType), href: profilePath, }, - ...projectTypes.value - .map((projectType) => ({ - label: - projectType === 'collection' - ? formatMessage(messages.collectionsLabel) - : formatMessage(getProjectTypeTitleMessage(projectType), { count: 2 }), - href: `${profilePath}/${projectType}s`, - })) - .sort((first, second) => first.label.localeCompare(second.label)), + ...projectTypes.value.map((projectType) => ({ + label: + projectType === 'collection' + ? formatMessage(messages.collectionsLabel) + : formatMessage(getProjectTypeTitleMessage(projectType), { count: 2 }), + href: `${profilePath}/${projectType}s`, + })), ] }) diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts index 6aeb20c1a9..217dd867e4 100644 --- a/packages/ui/src/utils/index.ts +++ b/packages/ui/src/utils/index.ts @@ -6,6 +6,7 @@ export * from './file-extensions' export * from './game-modes' export * from './loaders' export * from './notices' +export * from './project-types' export * from './savable' export * from './search' export * from './server-content-installing' diff --git a/packages/ui/src/utils/project-types.ts b/packages/ui/src/utils/project-types.ts new file mode 100644 index 0000000000..447532156a --- /dev/null +++ b/packages/ui/src/utils/project-types.ts @@ -0,0 +1,31 @@ +export const PROJECT_TYPE_ORDER = [ + 'mod', + 'resourcepack', + 'datapack', + 'shader', + 'modpack', + 'plugin', + 'server', + 'collection', +] as const + +export type OrderedProjectType = (typeof PROJECT_TYPE_ORDER)[number] + +const PROJECT_TYPE_SORT_ALIASES: Record = { + minecraft_java_server: 'server', + shaderpack: 'shader', +} + +export function getProjectTypeSortIndex(type: string): number { + const normalized = PROJECT_TYPE_SORT_ALIASES[type] ?? type + const index = (PROJECT_TYPE_ORDER as readonly string[]).indexOf(normalized) + return index === -1 ? PROJECT_TYPE_ORDER.length : index +} + +export function compareProjectTypes(a: string, b: string): number { + return getProjectTypeSortIndex(a) - getProjectTypeSortIndex(b) +} + +export function sortProjectTypes(types: Iterable): T[] { + return [...types].sort(compareProjectTypes) +} diff --git a/packages/ui/src/utils/search.ts b/packages/ui/src/utils/search.ts index 5a189fa92e..8e06f062ce 100644 --- a/packages/ui/src/utils/search.ts +++ b/packages/ui/src/utils/search.ts @@ -93,10 +93,10 @@ export type ProjectType = const ALL_PROJECT_TYPES: ProjectType[] = [ 'mod', - 'modpack', 'resourcepack', - 'shader', 'datapack', + 'shader', + 'modpack', 'plugin', 'server', ] From bca5303ae34eef7080aa2bd55481fe000172aaa4 Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:19:56 -0700 Subject: [PATCH 083/145] improve popout animations (#7040) --- packages/ui/src/components/base/Combobox.vue | 8 +- .../src/components/base/DropdownFilterBar.vue | 378 +++++++++--------- .../ui/src/components/base/MultiSelect.vue | 7 +- .../base/buttons/TeleportOverflowMenu.vue | 24 +- .../base/buttons/TeleportPopoutMenu.vue | 9 +- .../files-tab/components/FileContextMenu.vue | 11 +- packages/ui/src/styles/tailwind-utilities.css | 23 ++ 7 files changed, 230 insertions(+), 230 deletions(-) diff --git a/packages/ui/src/components/base/Combobox.vue b/packages/ui/src/components/base/Combobox.vue index 4a0c6bcd0a..750238a9e1 100644 --- a/packages/ui/src/components/base/Combobox.vue +++ b/packages/ui/src/components/base/Combobox.vue @@ -97,12 +97,7 @@ - +
- +
-
+
- -
-
- - -
- -
- -
- -
- {{ activeCategorySelectionLabel }} - -
-
+
- -
+
@@ -726,12 +728,6 @@ const isMobileActiveSubmenu = computed( () => isMobileAddMenuLayout.value && activeCategory.value !== undefined && hasSubmenuPosition.value, ) -const addMenuTransitionEnterActiveClass = computed(() => - isMobileAddMenuLayout.value ? 'transition-none duration-0' : 'transition-opacity duration-150', -) -const addMenuTransitionEnterFromClass = computed(() => - isMobileAddMenuLayout.value ? 'opacity-100' : 'opacity-0', -) const addMenuTriggerElement = computed(() => addMenuTrigger.value?.element ?? null) const addMenuOutsideClickTarget = computed(() => menuContainer.value ?? submenu.value) const addMenuOutsideClickIgnore = computed(() => [addMenuTriggerElement, menuContainer, submenu]) diff --git a/packages/ui/src/components/base/MultiSelect.vue b/packages/ui/src/components/base/MultiSelect.vue index f765bd90e6..7d17408333 100644 --- a/packages/ui/src/components/base/MultiSelect.vue +++ b/packages/ui/src/components/base/MultiSelect.vue @@ -108,12 +108,7 @@ - +
{ + switch (resolvedSide.value) { + case 'top': + return 'bottom center' + case 'left': + return 'right center' + case 'right': + return 'left center' + default: + return 'top center' + } +}) + const menuItemClasses = 'overflow-menu-item flex min-h-10 w-full items-center gap-2 rounded-[10px] border-0 bg-transparent px-3 py-2 text-left text-base font-semibold leading-5 text-contrast no-underline ' + 'cursor-pointer whitespace-nowrap hover:bg-surface-4 focus-visible:bg-surface-4 focus-visible:outline-none ' + @@ -294,20 +307,13 @@ defineExpose({ open: openMenu, close: closeMenu }) - +