From 61754efca4cab8d9b5301d46b52e6b75135512ae Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:26:40 -0700 Subject: [PATCH 01/45] Fix changelog prerender issue --- .../src/pages/news/changelog/[product]/[date].vue | 2 +- packages/utils/changelog.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/pages/news/changelog/[product]/[date].vue b/apps/frontend/src/pages/news/changelog/[product]/[date].vue index 1ad2bca816..931419d681 100644 --- a/apps/frontend/src/pages/news/changelog/[product]/[date].vue +++ b/apps/frontend/src/pages/news/changelog/[product]/[date].vue @@ -23,7 +23,7 @@ const changelogEntry = computed(() => const isFirst = computed(() => changelogEntry.value?.date === getChangelog()[0].date) if (!changelogEntry.value) { - createError({ statusCode: 404, statusMessage: 'Version not found' }) + throw createError({ statusCode: 404, statusMessage: 'Version not found' }) } diff --git a/packages/utils/changelog.ts b/packages/utils/changelog.ts index 626e9bd098..b26d86ac03 100644 --- a/packages/utils/changelog.ts +++ b/packages/utils/changelog.ts @@ -11,14 +11,14 @@ export type VersionEntry = { const VERSIONS: VersionEntry[] = [ { - date: `2026-03-17T19:00:00-08:00`, - product: 'website', + date: `2026-03-17T19:30:00-08:00`, + product: 'web', body: `## Improvements - Sorted server project categories, regions, and language filter options. - Added GitHub Pages image links to the list of domains that can bypass the wsrv.nl image proxy for more real-time dynamic images.`, }, { - date: `2026-03-17T19:00:00-08:00`, + date: `2026-03-17T19:30:00-08:00`, product: 'hosting', body: `## Improvements - Fixed being unable to sort by project type and updates at the same time on the Content tab. @@ -27,7 +27,7 @@ const VERSIONS: VersionEntry[] = [ }, { date: `2026-03-17T12:40:00-08:00`, - product: 'website', + product: 'web', body: `## Improvements - Fixed personal access token settings page erroring.`, }, From cf1b5f5e2dc483b93ed6d4b7f4a333812de4c02a Mon Sep 17 00:00:00 2001 From: xinyihl <1012737146@qq.com> Date: Thu, 19 Mar 2026 00:16:04 +0800 Subject: [PATCH 02/45] Make settings page localizable (#5294) * make settings localizable * move plan names to common messages * unknown -> plan-unknown * prepr:frontend --- .../servers/marketing/ServerPlanSelector.vue | 24 +- apps/frontend/src/locales/en-US/index.json | 412 ++++++++++++- apps/frontend/src/pages/settings.vue | 23 +- apps/frontend/src/pages/settings/account.vue | 546 ++++++++++++++---- .../src/pages/settings/applications.vue | 17 +- .../src/pages/settings/authorizations.vue | 64 +- .../src/pages/settings/billing/charges.vue | 42 +- .../src/pages/settings/billing/index.vue | 477 ++++++++++++--- apps/frontend/src/pages/settings/index.vue | 43 +- apps/frontend/src/pages/settings/profile.vue | 8 +- packages/ui/src/locales/en-US/index.json | 15 + packages/ui/src/utils/common-messages.ts | 20 + 12 files changed, 1420 insertions(+), 271 deletions(-) diff --git a/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue b/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue index 542c26dde9..9a3111a0fc 100644 --- a/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue +++ b/apps/frontend/src/components/ui/servers/marketing/ServerPlanSelector.vue @@ -1,6 +1,13 @@ - - - - diff --git a/apps/app-frontend/src/components/ui/modal/ModpackAlreadyInstalledModal.vue b/apps/app-frontend/src/components/ui/modal/ModpackAlreadyInstalledModal.vue new file mode 100644 index 0000000000..6b16e80229 --- /dev/null +++ b/apps/app-frontend/src/components/ui/modal/ModpackAlreadyInstalledModal.vue @@ -0,0 +1,84 @@ + + + diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index de90ff404d..46fe5406a2 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -1,6 +1,6 @@ import type { ModrinthId } from '@modrinth/utils' -type GameInstance = { +export type GameInstance = { path: string install_stage: InstallStage @@ -46,7 +46,7 @@ type LinkedData = { locked: boolean } -type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' +export type InstanceLoader = 'vanilla' | 'forge' | 'fabric' | 'quilt' | 'neoforge' type ContentFile = { metadata?: { diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 2ed90eae50..8d9bbdcdea 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -41,6 +41,21 @@ "app.instance.confirm-delete.header": { "message": "Delete instance" }, + "app.instance.modpack-already-installed.admonition-body": { + "message": "This modpack is already installed in the \"{instanceName}\" instance." + }, + "app.instance.modpack-already-installed.admonition-header": { + "message": "Duplicate modpack" + }, + "app.instance.modpack-already-installed.create-anyway": { + "message": "Create anyway" + }, + "app.instance.modpack-already-installed.go-to-instance": { + "message": "Go to instance" + }, + "app.instance.modpack-already-installed.header": { + "message": "Modpack already installed" + }, "app.instance.mods.content-type-project": { "message": "project" }, diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index c3244a3ea5..d08ef25327 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -20,6 +20,11 @@ @@ -471,7 +476,7 @@ async function handleModpackContentToggle(item: ContentItem) { } async function handleModpackContentBulkToggle(items: ContentItem[]) { - await Promise.all(items.map((item) => toggleDisableMod(item))) + await Promise.all(items.map((item) => _toggleDisableMod(item))) } async function handleModpackContent() { @@ -814,13 +819,12 @@ provideContentManager({ isPackLocked, isBusy: isInstanceBusy, isBulkOperating, - getItemId: (item) => item.file_path ?? item.file_name, contentTypeLabel: ref(formatMessage(messages.contentTypeProject)), toggleEnabled: toggleDisableMod, bulkEnableItems: (items) => - Promise.all(items.map((item) => toggleDisableMod(item))).then(() => {}), + Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}), bulkDisableItems: (items) => - Promise.all(items.map((item) => toggleDisableMod(item))).then(() => {}), + Promise.all(items.map((item) => _toggleDisableMod(item))).then(() => {}), deleteItem: removeMod, bulkDeleteItems: (items) => Promise.all(items.map((item) => removeMod(item))).then(() => {}), refresh: () => initProjects('must_revalidate'), @@ -838,7 +842,7 @@ provideContentManager({ dismissContentHint, shareItems: handleShareItems, mapToTableItem: (item) => ({ - id: item.file_path ?? item.file_name, + id: item.id, project: item.project ?? { id: item.file_name, slug: null, diff --git a/apps/app-frontend/src/providers/content-install.ts b/apps/app-frontend/src/providers/content-install.ts index 463eafc252..3ec65e1ccf 100644 --- a/apps/app-frontend/src/providers/content-install.ts +++ b/apps/app-frontend/src/providers/content-install.ts @@ -26,6 +26,7 @@ import { remove_project, } from '@/helpers/profile.js' import { get_game_versions } from '@/helpers/tags' +import type { GameInstance, InstanceLoader } from '@/helpers/types' import { findPreferredVersion, installVersionDependencies, @@ -37,13 +38,8 @@ interface ModalRef { hide: () => void } -interface InstallConfirmModalRef { - show: ( - project: Labrinth.Projects.v2.Project, - version: string, - callback: (versionId?: string) => void, - createInstanceCallback: (profile: string) => void, - ) => void +interface ModpackAlreadyInstalledModalRef { + show: (instanceName: string, instancePath: string) => void } interface IncompatibilityWarningModalRef { @@ -92,7 +88,9 @@ export interface ContentInstallContext { handleNavigate: (instance: ContentInstallInstance) => void handleCancel: () => void setContentInstallModal: (ref: ModalRef) => void - setInstallConfirmModal: (ref: InstallConfirmModalRef) => void + setModpackAlreadyInstalledModal: (ref: ModpackAlreadyInstalledModalRef) => void + handleModpackDuplicateCreateAnyway: () => Promise + handleModpackDuplicateGoToInstance: (instancePath: string) => void setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void install: ( projectId: string, @@ -140,12 +138,13 @@ export function createContentInstall(opts: { ) { const primaryFile = version?.files?.find((f) => f.primary) ?? version?.files?.[0] const placeholder: ContentItem = { + id: `__installing_${project.id}`, file_name: `__installing_${project.id}`, project: { id: project.id, - slug: project.slug ?? null, + slug: project.slug ?? '', title: project.title, - icon_url: project.icon_url ?? null, + icon_url: project.icon_url ?? undefined, }, version: version ? { @@ -183,18 +182,26 @@ export function createContentInstall(opts: { } let modalRef: ModalRef | null = null - let installConfirmModalRef: InstallConfirmModalRef | null = null + let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null let currentProject: Labrinth.Projects.v2.Project | null = null let currentVersions: Labrinth.Versions.v2.Version[] = [] let currentCallback: (versionId?: string) => void = () => {} let profileMap: Record = {} + let pendingModpackInstall: { + project: Labrinth.Projects.v2.Project + version: string + source: string + callback: (versionId?: string) => void + createInstanceCallback: (profile: string) => void + } | null = null + async function showModInstallModal( project: Labrinth.Projects.v2.Project, versions: Labrinth.Versions.v2.Version[], onInstall: (versionId?: string) => void, - hints?: { preferredLoader?: string; preferredGameVersion?: string }, + hints?: { preferredLoader?: string; preferredGameVersion?: string; showProjectInfo?: boolean }, ) { currentProject = project currentVersions = versions @@ -379,10 +386,10 @@ export function createContentInstall(opts: { trackEvent('ProjectInstall', { loader: profile.loader, game_version: profile.game_version, - id: currentProject.id, + id: currentProject!.id, version_id: version.id, - project_type: currentProject.project_type, - title: currentProject.title, + project_type: currentProject!.project_type, + title: currentProject!.title, source: 'ProjectInstallModal', }) currentCallback(version.id) @@ -433,10 +440,10 @@ export function createContentInstall(opts: { trackEvent('ProjectInstall', { loader: data.loader, game_version: data.gameVersion, - id: currentProject.id, + id: currentProject!.id, version_id: version.id, - project_type: currentProject.project_type, - title: currentProject.title, + project_type: currentProject!.project_type, + title: currentProject!.title, source: 'ProjectInstallModal', }) @@ -470,28 +477,28 @@ export function createContentInstall(opts: { if (project.project_type === 'modpack') { const version = versionId ?? project.versions[project.versions.length - 1] const packs = await list() + const existingPack = packs.find((pack) => pack.linked_data?.project_id === project.id) - if ( - packs.length === 0 || - !packs.find((pack) => pack.linked_data?.project_id === project.id) - ) { - await packInstall( - project.id, - version, - project.title, - project.icon_url, - createInstanceCallback, - ) - trackEvent('PackInstall', { - id: project.id, - version_id: version, - title: project.title, - source, - }) - callback(version) - } else { - installConfirmModalRef?.show(project, version, callback, createInstanceCallback) + if (existingPack) { + pendingModpackInstall = { project, version, source, callback, createInstanceCallback } + modpackAlreadyInstalledModalRef?.show(existingPack.name, existingPack.path) + return } + + await packInstall( + project.id, + version, + project.title, + project.icon_url, + createInstanceCallback, + ) + trackEvent('PackInstall', { + id: project.id, + version_id: version, + title: project.title, + source, + }) + callback(version) } else if (instancePath) { const [instanceOrNull, instanceProjects, versions] = await Promise.all([ get(instancePath), @@ -577,8 +584,31 @@ export function createContentInstall(opts: { setContentInstallModal(ref: ModalRef) { modalRef = ref }, - setInstallConfirmModal(ref: InstallConfirmModalRef) { - installConfirmModalRef = ref + setModpackAlreadyInstalledModal(ref: ModpackAlreadyInstalledModalRef) { + modpackAlreadyInstalledModalRef = ref + }, + async handleModpackDuplicateCreateAnyway() { + if (!pendingModpackInstall) return + const { project, version, source, callback, createInstanceCallback } = pendingModpackInstall + pendingModpackInstall = null + await packInstall( + project.id, + version, + project.title, + project.icon_url, + createInstanceCallback, + ) + trackEvent('PackInstall', { + id: project.id, + version_id: version, + title: project.title, + source, + }) + callback(version) + }, + handleModpackDuplicateGoToInstance(instancePath: string) { + pendingModpackInstall = null + opts.router.push(`/instance/${encodeURIComponent(instancePath)}/`) }, setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) { incompatibilityWarningModalRef = ref diff --git a/apps/app-frontend/src/providers/setup/creation-modal.ts b/apps/app-frontend/src/providers/setup/creation-modal.ts index 8d4b1b3ae5..4020fe8b37 100644 --- a/apps/app-frontend/src/providers/setup/creation-modal.ts +++ b/apps/app-frontend/src/providers/setup/creation-modal.ts @@ -1,18 +1,33 @@ -import type { AbstractWebNotificationManager, CreationFlowContextValue } from '@modrinth/ui' -import { provide, useTemplateRef } from 'vue' +import type { + AbstractWebNotificationManager, + CreationFlowContextValue, + CreationFlowModal, +} from '@modrinth/ui' +import { provide, ref, useTemplateRef } from 'vue' +import type { ComponentExposed } from 'vue-component-type-helpers' import { useRouter } from 'vue-router' +import type ModpackAlreadyInstalledModal from '@/components/ui/modal/ModpackAlreadyInstalledModal.vue' import { trackEvent } from '@/helpers/analytics' import { get_project_versions, get_search_results } from '@/helpers/cache.js' import { import_instance } from '@/helpers/import.js' import { create_profile_and_install, create_profile_and_install_from_file } from '@/helpers/pack' import { create, list } from '@/helpers/profile.js' +import type { InstanceLoader } from '@/helpers/types' export function setupCreationModal(notificationManager: AbstractWebNotificationManager) { const { handleError } = notificationManager const router = useRouter() - const installationModal = useTemplateRef('installationModal') + const installationModal = + useTemplateRef>('installationModal') + const modpackAlreadyInstalledModal = ref>() + + function setModpackAlreadyInstalledModal( + modal: InstanceType, + ) { + modpackAlreadyInstalledModal.value = modal + } async function fetchExistingInstanceNames(): Promise { const instances = await list().catch(handleError) @@ -23,10 +38,34 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM installationModal.value?.show() }) - async function handleCreate(config: CreationFlowContextValue) { - installationModal.value?.hide() + async function proceedWithModpackCreation( + projectId: string, + versionId: string, + name: string, + iconUrl?: string, + ) { + await create_profile_and_install(projectId, versionId, name, iconUrl).catch(handleError) + trackEvent('InstanceCreate', { source: 'CreationModalModpack' }) + } + async function handleCreate(config: CreationFlowContextValue) { try { + if (config.modpackSelection.value) { + const { projectId, versionId, name, iconUrl } = config.modpackSelection.value + + const instances = await list().catch(handleError) + const existingInstance = instances?.find((i) => i.linked_data?.project_id === projectId) + + if (existingInstance) { + pendingModpackCreation.value = { projectId, versionId, name, iconUrl } + installationModal.value?.hide() + modpackAlreadyInstalledModal.value?.show(existingInstance.name, existingInstance.path) + return + } + } + + installationModal.value?.hide() + if (config.isImportMode.value) { for (const [launcherName, instanceSet] of Object.entries( config.importSelectedInstances.value, @@ -43,8 +82,7 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM if (config.modpackSelection.value) { const { projectId, versionId, name, iconUrl } = config.modpackSelection.value - await create_profile_and_install(projectId, versionId, name, iconUrl).catch(handleError) - trackEvent('InstanceCreate', { source: 'CreationModalModpack' }) + await proceedWithModpackCreation(projectId, versionId, name, iconUrl) return } @@ -66,26 +104,40 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM await create( name, - config.selectedGameVersion.value, - loader, + config.selectedGameVersion.value!, + loader as InstanceLoader, loaderVersion, iconPath, false, ).catch(handleError) trackEvent('InstanceCreate', { - profile_name: name, - game_version: config.selectedGameVersion.value, - loader, - loader_version: loaderVersion, - has_icon: !!iconPath, source: 'CreationModal', }) } catch (err) { - handleError(err) + handleError(err as Error) } } + const pendingModpackCreation = ref<{ + projectId: string + versionId: string + name: string + iconUrl?: string + } | null>(null) + + async function handleModpackDuplicateCreateAnyway() { + if (!pendingModpackCreation.value) return + const { projectId, versionId, name, iconUrl } = pendingModpackCreation.value + pendingModpackCreation.value = null + await proceedWithModpackCreation(projectId, versionId, name, iconUrl) + } + + function handleModpackDuplicateGoToInstance(instancePath: string) { + pendingModpackCreation.value = null + router.push(`/instance/${encodeURIComponent(instancePath)}/`) + } + function handleBrowseModpacks() { installationModal.value?.hide() router.push('/browse/modpack') @@ -113,5 +165,8 @@ export function setupCreationModal(notificationManager: AbstractWebNotificationM handleBrowseModpacks, searchModpacks, getProjectVersions, + setModpackAlreadyInstalledModal, + handleModpackDuplicateCreateAnyway, + handleModpackDuplicateGoToInstance, } } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 08370f6418..1c023e0cec 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -75,6 +75,7 @@ "semver": "^7.5.4", "three": "^0.172.0", "vue-confetti-explosion": "^1.0.2", + "vue-router": "*", "vue-typed-virtual-list": "^1.0.10", "vue3-ace-editor": "^2.2.4", "vue3-apexcharts": "^1.5.2", diff --git a/packages/app-lib/src/state/instances/content.rs b/packages/app-lib/src/state/instances/content.rs index 826ba5844a..ffe7be0733 100644 --- a/packages/app-lib/src/state/instances/content.rs +++ b/packages/app-lib/src/state/instances/content.rs @@ -35,8 +35,9 @@ pub struct ContentItem { pub file_name: String, /// Relative path to the file within the profile pub file_path: String, - /// SHA1 hash of the file - pub hash: String, + /// Stable frontend identifier (SHA1 hash of file content, survives renames). + /// Not a project or version ID. + pub id: String, /// File size in bytes pub size: u64, /// Whether the file is enabled (not .disabled) @@ -542,7 +543,7 @@ async fn profile_files_to_content_items( ContentItem { file_name: file.file_name.clone(), file_path: path.clone(), - hash: file.hash.clone(), + id: file.hash.clone(), size: file.size, enabled: !file.file_name.ends_with(".disabled"), project_type: file.project_type, @@ -726,7 +727,7 @@ pub async fn dependencies_to_content_items( ) }), file_path: String::new(), - hash: String::new(), + id: String::new(), size: version .and_then(|v| v.files.first()) .map(|f| f.size as u64) diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts index 9905214c31..f1f921902b 100644 --- a/packages/assets/generated-icons.ts +++ b/packages/assets/generated-icons.ts @@ -19,6 +19,7 @@ import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component' import _ArrowUpIcon from './icons/arrow-up.svg?component' import _ArrowUpDownIcon from './icons/arrow-up-down.svg?component' import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component' +import _ArrowUpZAIcon from './icons/arrow-up-z-a.svg?component' import _AsteriskIcon from './icons/asterisk.svg?component' import _BadgeCheckIcon from './icons/badge-check.svg?component' import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component' @@ -404,6 +405,7 @@ export const ArrowLeftRightIcon = _ArrowLeftRightIcon export const ArrowUpIcon = _ArrowUpIcon export const ArrowUpDownIcon = _ArrowUpDownIcon export const ArrowUpRightIcon = _ArrowUpRightIcon +export const ArrowUpZAIcon = _ArrowUpZAIcon export const AsteriskIcon = _AsteriskIcon export const BadgeCheckIcon = _BadgeCheckIcon export const BadgeDollarSignIcon = _BadgeDollarSignIcon diff --git a/packages/assets/icons/arrow-up-z-a.svg b/packages/assets/icons/arrow-up-z-a.svg new file mode 100644 index 0000000000..0e68ae5815 --- /dev/null +++ b/packages/assets/icons/arrow-up-z-a.svg @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/packages/ui/src/components/base/FloatingActionBar.vue b/packages/ui/src/components/base/FloatingActionBar.vue index a9d872c1bf..0fb04c271b 100644 --- a/packages/ui/src/components/base/FloatingActionBar.vue +++ b/packages/ui/src/components/base/FloatingActionBar.vue @@ -1,11 +1,48 @@ @@ -27,9 +65,11 @@ onUnmounted(() => { aria-live="polite" > @@ -81,4 +121,12 @@ onUnmounted(() => { .intercom-lightweight-app-launcher { z-index: 9 !important; } + +.bar-compact .bar-label { + display: none; +} + +.bar-compact .cq-show-icon { + display: block; +} diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue index 00ace6bc87..2638cf9865 100644 --- a/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue +++ b/packages/ui/src/components/flows/creation-flow-modal/components/FinalConfigStage.vue @@ -100,15 +100,17 @@ diff --git a/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts index d2ee28b0d6..41294299f5 100644 --- a/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts +++ b/packages/ui/src/components/flows/creation-flow-modal/stages/final-config-stage.ts @@ -44,7 +44,7 @@ export const stageConfig: StageConfigInput = { icon: isFinish ? PlusIcon : RightArrowIcon, iconPosition: isFinish ? ('before' as const) : ('after' as const), color: isReset ? ('red' as const) : isFinish ? ('brand' as const) : undefined, - disabled: isForwardBlocked(ctx), + disabled: isForwardBlocked(ctx) || ctx.isBackingUp.value, loading: isFinish && ctx.loading.value, onClick: () => { if (isFinish) { diff --git a/packages/ui/src/components/servers/backups/BackupProgressAdmonition.vue b/packages/ui/src/components/servers/backups/BackupProgressAdmonition.vue index d1110a901d..3dc3273035 100644 --- a/packages/ui/src/components/servers/backups/BackupProgressAdmonition.vue +++ b/packages/ui/src/components/servers/backups/BackupProgressAdmonition.vue @@ -109,7 +109,7 @@ const description = computed(() => { const messages = defineMessages({ fallbackName: { id: 'servers.backups.admonition.fallback-name', - defaultMessage: 'your backup', + defaultMessage: 'Your backup', }, backupQueuedTitle: { id: 'servers.backups.admonition.backup-queued.title', diff --git a/packages/ui/src/components/servers/backups/BackupProgressAdmonitions.vue b/packages/ui/src/components/servers/backups/BackupProgressAdmonitions.vue index b6ae219062..1e56e0671c 100644 --- a/packages/ui/src/components/servers/backups/BackupProgressAdmonitions.vue +++ b/packages/ui/src/components/servers/backups/BackupProgressAdmonitions.vue @@ -84,10 +84,10 @@ const admonitions = computed(() => { // 1. Active WS entries (real-time progress from backupsState) for (const [id, entry] of backupsState.entries()) { const backup = findBackup(id) + seenIds.add(id) if (entry.create && entry.create.state === 'ongoing') { const key = `${id}:create` if (!dismissedIds.has(key)) { - seenIds.add(id) result.push({ key, backupId: id, @@ -102,7 +102,6 @@ const admonitions = computed(() => { if (entry.restore && entry.restore.state === 'ongoing') { const key = `${id}:restore` if (!dismissedIds.has(key)) { - seenIds.add(id) result.push({ key, backupId: id, diff --git a/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue b/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue index 369bc2f48a..d0acb339b5 100644 --- a/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue +++ b/packages/ui/src/components/servers/files/explorer/TeleportOverflowMenu.vue @@ -178,10 +178,10 @@ const calculateMenuPosition = () => { top = Math.max(margin, window.innerHeight - menuHeight - margin) } - if (triggerRect.left + menuWidth + margin <= window.innerWidth) { - left = triggerRect.left - } else if (triggerRect.right - menuWidth - margin >= 0) { + if (triggerRect.right - menuWidth >= margin) { left = triggerRect.right - menuWidth + } else if (triggerRect.left + menuWidth + margin <= window.innerWidth) { + left = triggerRect.left } else { left = Math.max(margin, window.innerWidth - menuWidth - margin) } diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue index 082d811bf3..d3e0ed5f56 100644 --- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue +++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue @@ -87,22 +87,23 @@ const deleteHovered = ref(false) :class="{ 'opacity-50': disabled }" >
-
+
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 1e731024ad..2f5359338d 100644 --- a/packages/ui/src/layouts/shared/content-tab/components/ContentSelectionBar.vue +++ b/packages/ui/src/layouts/shared/content-tab/components/ContentSelectionBar.vue @@ -1,5 +1,5 @@ diff --git a/apps/frontend/src/pages/settings/applications.vue b/apps/frontend/src/pages/settings/applications.vue index d39106ceb5..4cf333ba33 100644 --- a/apps/frontend/src/pages/settings/applications.vue +++ b/apps/frontend/src/pages/settings/applications.vue @@ -283,10 +283,6 @@ definePageMeta({ middleware: 'auth', }) -useHead({ - title: () => `${formatMessage(messages.headTitle)} - Modrinth`, -}) - const messages = defineMessages({ headTitle: { id: 'settings.applications.head-title', @@ -420,6 +416,10 @@ const messages = defineMessages({ }, }) +useHead({ + title: () => `${formatMessage(messages.headTitle)} - Modrinth`, +}) + const { scopesToLabels } = useScopes() const scopeCategories = computed(() => { diff --git a/apps/frontend/src/pages/settings/index.vue b/apps/frontend/src/pages/settings/index.vue index 17e9547a69..5626d3d147 100644 --- a/apps/frontend/src/pages/settings/index.vue +++ b/apps/frontend/src/pages/settings/index.vue @@ -193,13 +193,13 @@ import MessageBanner from '~/components/ui/MessageBanner.vue' import type { DisplayLocation } from '~/plugins/cosmetics' import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts' +const { addNotification } = injectNotificationManager() +const { formatMessage } = useVIntl() + useHead({ title: () => `${formatMessage(messages.headTitle)} - Modrinth`, }) -const { addNotification } = injectNotificationManager() -const { formatMessage } = useVIntl() - const messages = defineMessages({ headTitle: { id: 'settings.head-title', diff --git a/apps/frontend/src/pages/settings/pats.vue b/apps/frontend/src/pages/settings/pats.vue index ce0ae7a0bf..264988874f 100644 --- a/apps/frontend/src/pages/settings/pats.vue +++ b/apps/frontend/src/pages/settings/pats.vue @@ -7,51 +7,65 @@ :proceed-label="formatMessage(deleteModalMessages.action)" @proceed="removePat(deletePatIndex)" /> - -
- - - -
-
-

- {{ category.name }} -

-
- +
+
+ + +
+ +
+ +
+
+

+ {{ category.name }} +

+
+ +
- - -

+ +
+ + +

+
+
- +
@@ -199,6 +213,7 @@ import { injectModrinthClient, injectNotificationManager, IntlFormatted, + NewModal, StyledInput, useFormatDateTime, useRelativeTime, @@ -206,7 +221,6 @@ import { } from '@modrinth/ui' import { useQuery, useQueryClient } from '@tanstack/vue-query' -import Modal from '~/components/ui/Modal.vue' import { getScopeValue, hasScope, @@ -403,7 +417,7 @@ async function createPat() { scopes: Number(scopesVal.value), expires: data.$dayjs(expires.value).toISOString(), }) - pats.value.push(res) + queryClient.setQueryData(['pat'], (old) => [...(old || []), res]) patModal.value.hide() } catch (err) { addNotification({ diff --git a/apps/frontend/src/pages/settings/profile.vue b/apps/frontend/src/pages/settings/profile.vue index c5da261eea..3e62e9477c 100644 --- a/apps/frontend/src/pages/settings/profile.vue +++ b/apps/frontend/src/pages/settings/profile.vue @@ -99,10 +99,6 @@ import { const { addNotification } = injectNotificationManager() const { formatMessage } = useVIntl() -useHead({ - title: () => `${formatMessage(messages.headTitle)} - Modrinth`, -}) - definePageMeta({ middleware: 'auth', }) @@ -139,6 +135,10 @@ const messages = defineMessages({ }, }) +useHead({ + title: () => `${formatMessage(messages.headTitle)} - Modrinth`, +}) + const auth = await useAuth() // Avatar state (separate from useSavable) From 3b604cfdc0aee690ff2ab7aea0cbcce7679b3c83 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 19 Mar 2026 00:16:30 +0000 Subject: [PATCH 05/45] Get AutoMod to ignore .rpo files (#5616) --- apps/labrinth/src/queue/moderation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs index f006793e9a..4ff4b69e21 100644 --- a/apps/labrinth/src/queue/moderation.rs +++ b/apps/labrinth/src/queue/moderation.rs @@ -336,7 +336,7 @@ impl AutomatedModerationQueue { || file.name().starts_with("overrides/resourcepacks") || file.name().starts_with("client-overrides/resourcepacks") { - if file.name().matches('/').count() > 2 || file.name().ends_with(".txt") { + if file.name().matches('/').count() > 2 || file.name().ends_with(".txt") || file.name().ends_with(".rpo") { continue; } From 93c81631a94a1b08d22949940494c0855523307d Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Thu, 19 Mar 2026 15:26:15 +0000 Subject: [PATCH 06/45] fix: NaN cmp-info (#5619) * fix: NaN cmp-info * fix: ssr * fix: lint --- .../src/components/ui/charts/ChartDisplay.vue | 75 ++++++++++------- .../src/pages/dashboard/analytics.vue | 24 ++++-- apps/frontend/src/pages/legal/cmp-info.vue | 2 +- apps/frontend/src/utils/analytics.js | 82 +++++++++++-------- 4 files changed, 110 insertions(+), 73 deletions(-) diff --git a/apps/frontend/src/components/ui/charts/ChartDisplay.vue b/apps/frontend/src/components/ui/charts/ChartDisplay.vue index 39564d4cb1..5dbb2dbdf6 100644 --- a/apps/frontend/src/components/ui/charts/ChartDisplay.vue +++ b/apps/frontend/src/components/ui/charts/ChartDisplay.vue @@ -8,7 +8,7 @@ {{ analytics.error.value }}
-
+

Loading analytics...

@@ -315,6 +315,7 @@ import { Card, DropdownSelect, useCompactNumber, + useDebugLogger, useFormatMoney, useFormatNumber, } from '@modrinth/ui' @@ -332,6 +333,7 @@ import { intToRgba, } from '~/utils/analytics.js' +const debug = useDebugLogger('ChartDisplay') const formatNumber = useFormatNumber() const { formatCompactNumber } = useCompactNumber() const formatMoney = useFormatMoney() @@ -339,6 +341,8 @@ const formatMoney = useFormatMoney() const router = useNativeRouter() const theme = useTheme() +debug('setup start', { server: import.meta.server, client: import.meta.client }) + const props = withDefaults( defineProps<{ projects?: any[] @@ -359,6 +363,11 @@ const props = withDefaults( const projects = computed(() => props.projects || []) +debug('projects from props', { + count: projects.value.length, + ids: projects.value.map((p: any) => p.id), +}) + // const selectedChart = ref('downloads') const selectedChart = computed({ get: () => { @@ -438,46 +447,47 @@ const isUsingProjectColors = computed({ }, }) -const startDate = ref(dayjs().startOf('day')) -const endDate = ref(dayjs().endOf('day')) -const timeResolution = ref(30) -const isInitialized = ref(false) +const defaultRange = props.ranges.find( + (r) => r.getLabel([dayjs(), dayjs()]) === 'Previous 30 days', +)! +const initialDates = defaultRange.getDates(dayjs()) + +const internalRange: Ref = ref(defaultRange) +const startDate = ref(initialDates.startDate) +const endDate = ref(initialDates.endDate) +const timeResolution = ref(defaultRange.timeResolution) + +debug('default range initialized', { + range: defaultRange.getLabel([dayjs(), dayjs()]), + startDate: startDate.value.toISOString(), + endDate: endDate.value.toISOString(), + timeResolution: timeResolution.value, +}) onBeforeMount(() => { - // Load cached data and range from localStorage - cache. + debug('onBeforeMount') if (import.meta.client) { const rangeLabel = localStorage.getItem('analyticsSelectedRange') + debug('localStorage range', { rangeLabel }) if (rangeLabel) { - const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)! + const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel) - if (range !== undefined) { + if (range) { internalRange.value = range - const ranges = range.getDates(dayjs()) + const dates = range.getDates(dayjs()) timeResolution.value = range.timeResolution - startDate.value = ranges.startDate - endDate.value = ranges.endDate + startDate.value = dates.startDate + endDate.value = dates.endDate + debug('range overridden from localStorage', { + startDate: dates.startDate.toISOString(), + endDate: dates.endDate.toISOString(), + timeResolution: range.timeResolution, + }) } } } }) -onMounted(() => { - if (internalRange.value === null) { - internalRange.value = props.ranges.find( - (r) => r.getLabel([dayjs(), dayjs()]) === 'Previous 30 days', - )! - } - - const ranges = selectedRange.value.getDates(dayjs()) - startDate.value = ranges.startDate - endDate.value = ranges.endDate - timeResolution.value = selectedRange.value.timeResolution - - isInitialized.value = true -}) - -const internalRange: Ref = ref(null as unknown as RangeObject) - const selectedRange = computed({ get: () => { return internalRange.value @@ -499,6 +509,7 @@ const selectedRange = computed({ }, }) +debug('calling useFetchAllAnalytics') const analytics = useFetchAllAnalytics( resetCharts, projects, @@ -507,9 +518,15 @@ const analytics = useFetchAllAnalytics( startDate, endDate, timeResolution, - isInitialized, ) +debug('awaiting analytics.fetch()') +await analytics.fetch() +debug('analytics.fetch() resolved', { + loading: analytics.loading.value, + error: analytics.error.value, +}) + const formattedCategorySubtitle = computed(() => { return ( selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? 'Loading...' diff --git a/apps/frontend/src/pages/dashboard/analytics.vue b/apps/frontend/src/pages/dashboard/analytics.vue index 0301240f1b..25d8376438 100644 --- a/apps/frontend/src/pages/dashboard/analytics.vue +++ b/apps/frontend/src/pages/dashboard/analytics.vue @@ -1,15 +1,23 @@ diff --git a/apps/frontend/src/pages/legal/cmp-info.vue b/apps/frontend/src/pages/legal/cmp-info.vue index f65d3007d1..fff4dc340e 100644 --- a/apps/frontend/src/pages/legal/cmp-info.vue +++ b/apps/frontend/src/pages/legal/cmp-info.vue @@ -195,7 +195,7 @@ const { data: transparencyInformation } = useQuery({ queryFn: () => client.labrinth.payouts_v3.getPlatformRevenue(), }) -const platformRevenue = computed(() => (transparencyInformation.value as any)?.all_time) +const platformRevenue = computed(() => Number((transparencyInformation.value as any)?.all_time)) const platformRevenueData = computed( () => (transparencyInformation.value as any)?.data?.slice(0, 5) ?? [], ) diff --git a/apps/frontend/src/utils/analytics.js b/apps/frontend/src/utils/analytics.js index aa1a435a3a..c516de1287 100644 --- a/apps/frontend/src/utils/analytics.js +++ b/apps/frontend/src/utils/analytics.js @@ -1,4 +1,4 @@ -import { injectI18n } from '@modrinth/ui' +import { injectI18n, useDebugLogger } from '@modrinth/ui' import dayjs from 'dayjs' import { computed, ref, watch } from 'vue' @@ -314,8 +314,15 @@ export const useFetchAllAnalytics = ( startDate = ref(dayjs().subtract(30, 'days')), endDate = ref(dayjs()), timeResolution = ref(1440), - isInitialized = ref(false), ) => { + const debug = useDebugLogger('useFetchAllAnalytics') + debug('init', { + projectCount: projects.value?.length, + personalRevenue, + startDate: startDate.value?.toISOString(), + endDate: endDate.value?.toISOString(), + }) + const downloadData = ref(null) const viewData = ref(null) const revenueData = ref(null) @@ -340,7 +347,22 @@ export const useFetchAllAnalytics = ( revenue: processRevAnalytics(revenueData.value, projects.value, theme.active), })) + const buildQuery = () => { + const q = { + start_date: startDate.value.toISOString(), + end_date: endDate.value.toISOString(), + resolution_minutes: timeResolution.value, + } + + if (projects.value?.length) { + q.project_ids = JSON.stringify(projects.value.map((p) => p.id)) + } + + return q + } + const fetchData = async (query) => { + debug('fetchData called', { query }) const normalQuery = new URLSearchParams(query) const revenueQuery = new URLSearchParams(query) @@ -355,6 +377,7 @@ export const useFetchAllAnalytics = ( loading.value = true error.value = null + debug('fetching all 5 endpoints...') const responses = await Promise.all([ useFetchAnalytics(`analytics/downloads?${qs}`), useFetchAnalytics(`analytics/views?${qs}`), @@ -362,16 +385,21 @@ export const useFetchAllAnalytics = ( useFetchAnalytics(`analytics/countries/downloads?${qs}`), useFetchAnalytics(`analytics/countries/views?${qs}`), ]) + debug('all 5 endpoints resolved', { + downloads: Object.keys(responses[0] || {}).length, + views: Object.keys(responses[1] || {}).length, + revenue: Object.keys(responses[2] || {}).length, + }) - // collect project ids from projects.value into a set const projectIds = new Set() if (projects.value) { projects.value.forEach((p) => projectIds.add(p.id)) } else { - // if projects.value is not set, we assume that we want all project ids Object.keys(responses[0] || {}).forEach((id) => projectIds.add(id)) } + debug('filtering to projectIds', { count: projectIds.size }) + const filterProjectIds = (data) => { const filtered = {} Object.entries(data).forEach(([id, values]) => { @@ -389,43 +417,27 @@ export const useFetchAllAnalytics = ( downloadsByCountry.value = responses[3] || {} viewsByCountry.value = responses[4] || {} } catch (e) { + debug('fetchData error', e) error.value = e } finally { loading.value = false + debug('fetchData done, loading=false') + } + } + + const fetch = async () => { + debug('fetch() called', { projectCount: projects.value?.length }) + await fetchData(buildQuery()) + if (onDataRefresh) { + onDataRefresh() } } watch( - [ - () => startDate.value, - () => endDate.value, - () => timeResolution.value, - () => projects.value, - () => isInitialized.value, - ], - async () => { - if (!isInitialized.value) { - return - } - - const q = { - start_date: startDate.value.toISOString(), - end_date: endDate.value.toISOString(), - resolution_minutes: timeResolution.value, - } - - if (projects.value?.length) { - q.project_ids = JSON.stringify(projects.value.map((p) => p.id)) - } - - await fetchData(q) - - if (onDataRefresh) { - onDataRefresh() - } - }, - { - immediate: true, + [() => startDate.value, () => endDate.value, () => timeResolution.value, () => projects.value], + (newVals, oldVals) => { + debug('watch triggered', { new: newVals, old: oldVals }) + fetch() }, ) @@ -474,6 +486,6 @@ export const useFetchAllAnalytics = ( totalData, loading, error, - isInitialized, + fetch, } } From 2128fa7ade7bd9b60345b671506a666c02b6b3dd Mon Sep 17 00:00:00 2001 From: Truman Gao <106889354+tdgao@users.noreply.github.com> Date: Thu, 19 Mar 2026 09:53:53 -0700 Subject: [PATCH 07/45] refactor: TabbedModal to use NewModal and DI (#5612) * refactor: tabbed modal to use NewModal * refactor: use DI for instance settings modal instead of passing down props * pnpm prepr --- .../ui/instance_settings/GeneralSettings.vue | 33 +- .../ui/instance_settings/HooksSettings.vue | 13 +- .../InstallationSettings.vue | 81 +++-- .../ui/instance_settings/JavaSettings.vue | 29 +- .../ui/instance_settings/WindowSettings.vue | 15 +- .../components/ui/modal/AppSettingsModal.vue | 88 +++-- .../ui/modal/InstanceSettingsModal.vue | 46 ++- apps/app-frontend/src/helpers/types.d.ts | 6 - .../src/providers/instance-settings.ts | 14 + .../ui/src/components/modal/TabbedModal.vue | 177 ++++++---- .../src/stories/modal/TabbedModal.stories.ts | 320 ++++++++++++++++++ 11 files changed, 584 insertions(+), 238 deletions(-) create mode 100644 apps/app-frontend/src/providers/instance-settings.ts create mode 100644 packages/ui/src/stories/modal/TabbedModal.stories.ts diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue index ead9416bfa..f8d4052209 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue @@ -18,8 +18,9 @@ import { useRouter } from 'vue-router' import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue' import { trackEvent } from '@/helpers/analytics' import { duplicate, edit, edit_icon, list, remove } from '@/helpers/profile' +import { injectInstanceSettings } from '@/providers/instance-settings' -import type { GameInstance, InstanceSettingsTabProps } from '../../../helpers/types' +import type { GameInstance } from '../../../helpers/types' const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() @@ -27,21 +28,21 @@ const router = useRouter() const deleteConfirmModal = ref() -const props = defineProps() +const { instance } = injectInstanceSettings() -const title = ref(props.instance.name) -const icon: Ref = ref(props.instance.icon_path) -const groups = ref(props.instance.groups) +const title = ref(instance.name) +const icon: Ref = ref(instance.icon_path) +const groups = ref(instance.groups) const newCategoryInput = ref('') -const installing = computed(() => props.instance.install_stage !== 'installed') +const installing = computed(() => instance.install_stage !== 'installed') async function duplicateProfile() { - await duplicate(props.instance.path).catch(handleError) + await duplicate(instance.path).catch(handleError) trackEvent('InstanceDuplicate', { - loader: props.instance.loader, - game_version: props.instance.game_version, + loader: instance.loader, + game_version: instance.game_version, }) } @@ -52,7 +53,7 @@ const availableGroups = computed(() => [ async function resetIcon() { icon.value = undefined - await edit_icon(props.instance.path, null).catch(handleError) + await edit_icon(instance.path, null).catch(handleError) trackEvent('InstanceRemoveIcon') } @@ -70,7 +71,7 @@ async function setIcon() { if (!value) return icon.value = value - await edit_icon(props.instance.path, icon.value).catch(handleError) + await edit_icon(instance.path, icon.value).catch(handleError) trackEvent('InstanceSetIcon') } @@ -101,7 +102,7 @@ watch( [title, groups, groups], async () => { if (removing.value) return - await edit(props.instance.path, editProfileObject.value).catch(handleError) + await edit(instance.path, editProfileObject.value).catch(handleError) }, { deep: true }, ) @@ -109,11 +110,11 @@ watch( const removing = ref(false) async function removeProfile() { removing.value = true - const path = props.instance.path + const path = instance.path trackEvent('InstanceRemove', { - loader: props.instance.loader, - game_version: props.instance.game_version, + loader: instance.loader, + game_version: instance.game_version, }) await router.push({ path: '/' }) @@ -218,7 +219,7 @@ const messages = defineMessages({ :src="icon ? convertFileSrc(icon) : icon" size="108px" class="!border-4 group-hover:brightness-75" - :tint-by="props.instance.path" + :tint-by="instance.path" no-shadow />
diff --git a/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue index ff2bb35e31..3061ea7fc6 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue @@ -10,22 +10,21 @@ import { computed, ref, watch } from 'vue' import { edit } from '@/helpers/profile' import { get } from '@/helpers/settings.ts' +import { injectInstanceSettings } from '@/providers/instance-settings' -import type { AppSettings, Hooks, InstanceSettingsTabProps } from '../../../helpers/types' +import type { AppSettings, Hooks } from '../../../helpers/types' const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() -const props = defineProps() +const { instance } = injectInstanceSettings() const globalSettings = (await get().catch(handleError)) as AppSettings const overrideHooks = ref( - !!props.instance.hooks.pre_launch || - !!props.instance.hooks.wrapper || - !!props.instance.hooks.post_exit, + !!instance.hooks.pre_launch || !!instance.hooks.wrapper || !!instance.hooks.post_exit, ) -const hooks = ref(props.instance.hooks ?? globalSettings.hooks) +const hooks = ref(instance.hooks ?? globalSettings.hooks) const editProfileObject = computed(() => { const editProfile: { @@ -41,7 +40,7 @@ const editProfileObject = computed(() => { watch( [overrideHooks, hooks], async () => { - await edit(props.instance.path, editProfileObject.value) + await edit(instance.path, editProfileObject.value) }, { deep: true }, ) diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 9874e53468..2aa0201b0b 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -27,17 +27,15 @@ import { update_repair_modrinth, } from '@/helpers/profile' import { get_game_versions, get_loaders } from '@/helpers/tags' +import { injectInstanceSettings } from '@/providers/instance-settings' -import type { InstanceSettingsTabProps, Manifest } from '../../../helpers/types' +import type { Manifest } from '../../../helpers/types' const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() const queryClient = useQueryClient() -const props = defineProps() -const emit = defineEmits<{ - unlinked: [] -}>() +const { instance, offline, isMinecraftServer, onUnlinked } = injectInstanceSettings() const [ fabric_versions, @@ -75,9 +73,9 @@ const [ ]) const { data: modpackInfo } = useQuery({ - queryKey: computed(() => ['linkedModpackInfo', props.instance.path]), - queryFn: () => get_linked_modpack_info(props.instance.path, 'must_revalidate'), - enabled: computed(() => !!props.instance.linked_data?.project_id && !props.offline), + queryKey: computed(() => ['linkedModpackInfo', instance.path]), + queryFn: () => get_linked_modpack_info(instance.path, 'must_revalidate'), + enabled: computed(() => !!instance.linked_data?.project_id && !offline), }) const repairing = ref(false) @@ -103,13 +101,13 @@ function getManifest(loader: string) { provideAppBackup({ async createBackup() { const allProfiles = await list() - const prefix = `${props.instance.name} - Backup #` + const prefix = `${instance.name} - Backup #` const existingNums = allProfiles .filter((p) => p.name.startsWith(prefix)) .map((p) => parseInt(p.name.slice(prefix.length), 10)) .filter((n) => !isNaN(n)) const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1 - const newPath = await duplicate(props.instance.path) + const newPath = await duplicate(instance.path) await edit(newPath, { name: `${prefix}${nextNum}` }) }, }) @@ -120,30 +118,27 @@ provideInstallationSettings({ const rows = [ { label: formatMessage(commonMessages.platformLabel), - value: formatLoaderLabel(props.instance.loader), + value: formatLoaderLabel(instance.loader), }, { label: formatMessage(commonMessages.gameVersionLabel), - value: props.instance.game_version, + value: instance.game_version, }, ] - if (props.instance.loader !== 'vanilla' && props.instance.loader_version) { + if (instance.loader !== 'vanilla' && instance.loader_version) { rows.push({ label: formatMessage(messages.loaderVersion, { - loader: formatLoaderLabel(props.instance.loader), + loader: formatLoaderLabel(instance.loader), }), - value: props.instance.loader_version, + value: instance.loader_version, }) } return rows }), - isLinked: computed(() => !!props.instance.linked_data?.locked), + isLinked: computed(() => !!instance.linked_data?.locked), isBusy: computed( () => - props.instance.install_stage !== 'installed' || - repairing.value || - reinstalling.value || - !!props.offline, + instance.install_stage !== 'installed' || repairing.value || reinstalling.value || !!offline, ), modpack: computed(() => { if (!modpackInfo.value) return null @@ -154,9 +149,9 @@ provideInstallationSettings({ versionNumber: modpackInfo.value.version?.version_number, } }), - currentPlatform: computed(() => props.instance.loader), - currentGameVersion: computed(() => props.instance.game_version), - currentLoaderVersion: computed(() => props.instance.loader_version ?? ''), + currentPlatform: computed(() => instance.loader), + currentGameVersion: computed(() => instance.game_version), + currentLoaderVersion: computed(() => instance.loader_version ?? ''), availablePlatforms: loaders?.value?.map((x) => x.name) ?? [], resolveGameVersions(loader, showSnapshots) { @@ -199,50 +194,50 @@ provideInstallationSettings({ if (platform !== 'vanilla' && loaderVersionId) { editProfile.loader_version = loaderVersionId } - await edit(props.instance.path, editProfile).catch(handleError) + await edit(instance.path, editProfile).catch(handleError) }, afterSave: async () => { - await install(props.instance.path, false).catch(handleError) + await install(instance.path, false).catch(handleError) trackEvent('InstanceRepair', { - loader: props.instance.loader, - game_version: props.instance.game_version, + loader: instance.loader, + game_version: instance.game_version, }) }, async repair() { repairing.value = true - await install(props.instance.path, true).catch(handleError) + await install(instance.path, true).catch(handleError) repairing.value = false trackEvent('InstanceRepair', { - loader: props.instance.loader, - game_version: props.instance.game_version, + loader: instance.loader, + game_version: instance.game_version, }) }, async reinstallModpack() { reinstalling.value = true - await update_repair_modrinth(props.instance.path).catch(handleError) + await update_repair_modrinth(instance.path).catch(handleError) reinstalling.value = false trackEvent('InstanceRepair', { - loader: props.instance.loader, - game_version: props.instance.game_version, + loader: instance.loader, + game_version: instance.game_version, }) }, async unlinkModpack() { - await edit(props.instance.path, { + await edit(instance.path, { linked_data: null as unknown as undefined, }) await queryClient.invalidateQueries({ - queryKey: ['linkedModpackInfo', props.instance.path], + queryKey: ['linkedModpackInfo', instance.path], }) - emit('unlinked') + onUnlinked() }, getCachedModpackVersions: () => null, async fetchModpackVersions() { - const versions = await get_project_versions(props.instance.linked_data!.project_id!).catch( + const versions = await get_project_versions(instance.linked_data!.project_id!).catch( handleError, ) return (versions ?? []) as Labrinth.Versions.v2.Version[] @@ -255,25 +250,25 @@ provideInstallationSettings({ }, async onModpackVersionConfirm(version) { - await update_managed_modrinth_version(props.instance.path, version.id) + await update_managed_modrinth_version(instance.path, version.id) await queryClient.invalidateQueries({ - queryKey: ['linkedModpackInfo', props.instance.path], + queryKey: ['linkedModpackInfo', instance.path], }) }, updaterModalProps: computed(() => ({ isApp: true, currentVersionId: - modpackInfo.value?.update_version_id ?? props.instance.linked_data?.version_id ?? '', + modpackInfo.value?.update_version_id ?? instance.linked_data?.version_id ?? '', projectIconUrl: modpackInfo.value?.project?.icon_url, projectName: modpackInfo.value?.project?.title ?? 'Modpack', - currentGameVersion: props.instance.game_version, - currentLoader: props.instance.loader, + currentGameVersion: instance.game_version, + currentLoader: instance.loader, })), isServer: false, isApp: true, - showModpackVersionActions: !props.isMinecraftServer, + showModpackVersionActions: !isMinecraftServer.value, repairing, reinstalling, }) diff --git a/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue index 0622de837a..39efa5fb1f 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue @@ -14,34 +14,31 @@ import JavaSelector from '@/components/ui/JavaSelector.vue' import useMemorySlider from '@/composables/useMemorySlider' import { edit, get_optimal_jre_key } from '@/helpers/profile' import { get } from '@/helpers/settings.ts' +import { injectInstanceSettings } from '@/providers/instance-settings' -import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' +import type { AppSettings } from '../../../helpers/types' const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() -const props = defineProps() +const { instance } = injectInstanceSettings() const globalSettings = (await get().catch(handleError)) as unknown as AppSettings -const overrideJavaInstall = ref(!!props.instance.java_path) -const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError)) -const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path }) +const overrideJavaInstall = ref(!!instance.java_path) +const optimalJava = readonly(await get_optimal_jre_key(instance.path).catch(handleError)) +const javaInstall = ref({ path: optimalJava.path ?? instance.java_path }) -const overrideJavaArgs = ref((props.instance.extra_launch_args?.length ?? 0) > 0) -const javaArgs = ref( - (props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '), -) +const overrideJavaArgs = ref((instance.extra_launch_args?.length ?? 0) > 0) +const javaArgs = ref((instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' ')) -const overrideEnvVars = ref((props.instance.custom_env_vars?.length ?? 0) > 0) +const overrideEnvVars = ref((instance.custom_env_vars?.length ?? 0) > 0) const envVars = ref( - (props.instance.custom_env_vars ?? globalSettings.custom_env_vars) - .map((x) => x.join('=')) - .join(' '), + (instance.custom_env_vars ?? globalSettings.custom_env_vars).map((x) => x.join('=')).join(' '), ) -const overrideMemorySettings = ref(!!props.instance.memory) -const memory = ref(props.instance.memory ?? globalSettings.memory) +const overrideMemorySettings = ref(!!instance.memory) +const memory = ref(instance.memory ?? globalSettings.memory) const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as { maxMemory: number snapPoints: number[] @@ -79,7 +76,7 @@ watch( memory, ], async () => { - await edit(props.instance.path, editProfileObject.value) + await edit(instance.path, editProfileObject.value) }, { deep: true }, ) diff --git a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue index 8bc1bc5fed..342c820ccc 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue @@ -11,24 +11,23 @@ import { computed, type Ref, ref, watch } from 'vue' import { edit } from '@/helpers/profile' import { get } from '@/helpers/settings.ts' +import { injectInstanceSettings } from '@/providers/instance-settings' -import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types' +import type { AppSettings } from '../../../helpers/types' const { handleError } = injectNotificationManager() const { formatMessage } = useVIntl() -const props = defineProps() +const { instance } = injectInstanceSettings() const globalSettings = (await get().catch(handleError)) as AppSettings -const overrideWindowSettings = ref( - !!props.instance.game_resolution || !!props.instance.force_fullscreen, -) +const overrideWindowSettings = ref(!!instance.game_resolution || !!instance.force_fullscreen) const resolution: Ref<[number, number]> = ref( - props.instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]), + instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]), ) const fullscreenSetting: Ref = ref( - props.instance.force_fullscreen ?? globalSettings.force_fullscreen, + instance.force_fullscreen ?? globalSettings.force_fullscreen, ) const editProfileObject = computed(() => { @@ -47,7 +46,7 @@ const editProfileObject = computed(() => { watch( [overrideWindowSettings, resolution, fullscreenSetting], async () => { - await edit(props.instance.path, editProfileObject.value) + await edit(instance.path, editProfileObject.value) }, { deep: true }, ) diff --git a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue index 3328fed4e6..1635b46a70 100644 --- a/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/AppSettingsModal.vue @@ -20,9 +20,8 @@ import { } from '@modrinth/ui' import { getVersion } from '@tauri-apps/api/app' import { platform as getOsPlatform, version as getOsVersion } from '@tauri-apps/plugin-os' -import { computed, ref, watch } from 'vue' +import { ref, watch } from 'vue' -import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import AppearanceSettings from '@/components/ui/settings/AppearanceSettings.vue' import DefaultInstanceSettings from '@/components/ui/settings/DefaultInstanceSettings.vue' import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue' @@ -106,15 +105,13 @@ const tabs = [ }, ] -const modal = ref() +const modal = ref | null>(null) function show() { - modal.value.show() + modal.value?.show() } -const isOpen = computed(() => modal.value?.isOpen) - -defineExpose({ show, isOpen }) +defineExpose({ show }) const { progress, version: downloadingVersion } = injectAppUpdateDownloadProgress() @@ -138,8 +135,8 @@ function devModeCount() { settings.value.developer_mode = !!themeStore.devMode devModeCounter.value = 0 - if (!themeStore.devMode && tabs[modal.value.selectedTab].developerOnly) { - modal.value.setTab(0) + if (!themeStore.devMode && tabs[modal.value!.selectedTab].developerOnly) { + modal.value!.setTab(0) } } } @@ -152,49 +149,46 @@ const messages = defineMessages({ }) diff --git a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue index 3543a19b34..b866f2e02e 100644 --- a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue @@ -12,14 +12,13 @@ import { Avatar, commonMessages, defineMessage, - NewModal, TabbedModal, type TabbedModalTab, useVIntl, } from '@modrinth/ui' import { useQueryClient } from '@tanstack/vue-query' import { convertFileSrc } from '@tauri-apps/api/core' -import { computed, nextTick, ref, useTemplateRef, watch } from 'vue' +import { computed, nextTick, ref, watch } from 'vue' import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue' import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue' @@ -28,12 +27,16 @@ import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue' import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue' import { get_project_v3 } from '@/helpers/cache' import { get_linked_modpack_info } from '@/helpers/profile' +import { provideInstanceSettings } from '@/providers/instance-settings' -import type { InstanceSettingsTabProps } from '../../../helpers/types' +import type { GameInstance } from '../../../helpers/types' const { formatMessage } = useVIntl() -const props = defineProps() +const props = defineProps<{ + instance: GameInstance + offline?: boolean +}>() const emit = defineEmits<{ unlinked: [] }>() @@ -41,6 +44,13 @@ const emit = defineEmits<{ const isMinecraftServer = ref(false) const handleUnlinked = () => emit('unlinked') +provideInstanceSettings({ + instance: props.instance, + offline: props.offline, + isMinecraftServer, + onUnlinked: handleUnlinked, +}) + watch( () => props.instance, (instance) => { @@ -58,7 +68,7 @@ watch( { immediate: true }, ) -const tabs = computed[]>(() => [ +const tabs = computed(() => [ { name: defineMessage({ id: 'instance.settings.tabs.general', @@ -102,8 +112,7 @@ const tabs = computed[]>(() => [ ]) const queryClient = useQueryClient() -const modal = ref() -const tabbedModal = useTemplateRef('tabbedModal') +const tabbedModal = ref | null>(null) function show(tabIndex?: number) { if (props.instance.linked_data?.project_id) { @@ -112,7 +121,7 @@ function show(tabIndex?: number) { queryFn: () => get_linked_modpack_info(props.instance.path, 'stale_while_revalidate'), }) } - modal.value.show() + tabbedModal.value?.show() if (tabIndex !== undefined) { nextTick(() => tabbedModal.value?.setTab(tabIndex)) } @@ -121,8 +130,9 @@ function show(tabIndex?: number) { defineExpose({ show }) - - - + diff --git a/apps/app-frontend/src/helpers/types.d.ts b/apps/app-frontend/src/helpers/types.d.ts index 46fe5406a2..7c5f1d25e7 100644 --- a/apps/app-frontend/src/helpers/types.d.ts +++ b/apps/app-frontend/src/helpers/types.d.ts @@ -128,9 +128,3 @@ type AppSettings = { prev_custom_dir?: string migrated: boolean } - -export type InstanceSettingsTabProps = { - instance: GameInstance - offline?: boolean - isMinecraftServer?: boolean -} diff --git a/apps/app-frontend/src/providers/instance-settings.ts b/apps/app-frontend/src/providers/instance-settings.ts new file mode 100644 index 0000000000..c6f9afafb6 --- /dev/null +++ b/apps/app-frontend/src/providers/instance-settings.ts @@ -0,0 +1,14 @@ +import { createContext } from '@modrinth/ui' +import type { Ref } from 'vue' + +import type { GameInstance } from '@/helpers/types' + +export interface InstanceSettingsContext { + instance: GameInstance + offline?: boolean + isMinecraftServer: Ref + onUnlinked: () => void +} + +export const [injectInstanceSettings, provideInstanceSettings] = + createContext('InstanceSettingsModal', 'instanceSettings') diff --git a/packages/ui/src/components/modal/TabbedModal.vue b/packages/ui/src/components/modal/TabbedModal.vue index 05e495c08e..2955e24792 100644 --- a/packages/ui/src/components/modal/TabbedModal.vue +++ b/packages/ui/src/components/modal/TabbedModal.vue @@ -1,24 +1,40 @@ + + diff --git a/packages/ui/src/stories/modal/TabbedModal.stories.ts b/packages/ui/src/stories/modal/TabbedModal.stories.ts new file mode 100644 index 0000000000..6503064cf1 --- /dev/null +++ b/packages/ui/src/stories/modal/TabbedModal.stories.ts @@ -0,0 +1,320 @@ +import { + CoffeeIcon, + GameIcon, + GaugeIcon, + InfoIcon, + LanguagesIcon, + MonitorIcon, + PaintbrushIcon, + ReportIcon, + SettingsIcon, + ShieldIcon, + WrenchIcon, +} from '@modrinth/assets' +import type { StoryObj } from '@storybook/vue3-vite' +import { defineComponent, h, ref } from 'vue' + +import ButtonStyled from '../../components/base/ButtonStyled.vue' +import TabbedModal from '../../components/modal/TabbedModal.vue' + +function makeTabContent(label: string, lines = 3) { + return defineComponent({ + name: `${label}Tab`, + render() { + return h('div', { class: 'space-y-4 py-2' }, [ + h('h2', { class: 'text-xl font-bold text-contrast m-0' }, label), + ...Array.from({ length: lines }, (_, i) => + h('p', { class: 'text-secondary m-0' }, `${label} content paragraph ${i + 1}.`), + ), + ]) + }, + }) +} + +const meta = { + title: 'Modal/TabbedModal', + // @ts-ignore + component: TabbedModal, +} + +export default meta + +export const Default: StoryObj = { + render: () => ({ + components: { TabbedModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const tabs = [ + { + name: { id: 'general', defaultMessage: 'General' }, + icon: InfoIcon, + content: makeTabContent('General'), + }, + { + name: { id: 'appearance', defaultMessage: 'Appearance' }, + icon: PaintbrushIcon, + content: makeTabContent('Appearance'), + }, + { + name: { id: 'privacy', defaultMessage: 'Privacy' }, + icon: ShieldIcon, + content: makeTabContent('Privacy'), + }, + ] + return { modalRef, tabs } + }, + template: /* html */ ` +
+ + + + +
+ `, + }), +} + +export const WithTitleSlot: StoryObj = { + render: () => ({ + components: { TabbedModal, ButtonStyled, SettingsIcon }, + setup() { + const modalRef = ref | null>(null) + const tabs = [ + { + name: { id: 'general', defaultMessage: 'General' }, + icon: InfoIcon, + content: makeTabContent('General'), + }, + { + name: { id: 'appearance', defaultMessage: 'Appearance' }, + icon: PaintbrushIcon, + content: makeTabContent('Appearance'), + }, + ] + return { modalRef, tabs } + }, + template: /* html */ ` +
+ + + + + + +
+ `, + }), +} + +export const WithFooter: StoryObj = { + render: () => ({ + components: { TabbedModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const tabs = [ + { + name: { id: 'general', defaultMessage: 'General' }, + icon: InfoIcon, + content: makeTabContent('General'), + }, + { + name: { id: 'appearance', defaultMessage: 'Appearance' }, + icon: PaintbrushIcon, + content: makeTabContent('Appearance'), + }, + { + name: { id: 'privacy', defaultMessage: 'Privacy' }, + icon: ShieldIcon, + content: makeTabContent('Privacy'), + }, + ] + return { modalRef, tabs } + }, + template: /* html */ ` +
+ + + + + + +
+ `, + }), +} + +export const WithBadge: StoryObj = { + render: () => ({ + components: { TabbedModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const tabs = [ + { + name: { id: 'general', defaultMessage: 'General' }, + icon: InfoIcon, + content: makeTabContent('General'), + }, + { + name: { id: 'language', defaultMessage: 'Language' }, + icon: LanguagesIcon, + content: makeTabContent('Language'), + badge: { id: 'beta', defaultMessage: 'Beta' }, + }, + { + name: { id: 'privacy', defaultMessage: 'Privacy' }, + icon: ShieldIcon, + content: makeTabContent('Privacy'), + }, + ] + return { modalRef, tabs } + }, + template: /* html */ ` +
+ + + + +
+ `, + }), +} + +export const HiddenTabs: StoryObj = { + render: () => ({ + components: { TabbedModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const tabs = [ + { + name: { id: 'general', defaultMessage: 'General' }, + icon: InfoIcon, + content: makeTabContent('General'), + }, + { + name: { id: 'hidden', defaultMessage: 'Hidden Tab' }, + icon: ReportIcon, + content: makeTabContent('Hidden'), + shown: false, + }, + { + name: { id: 'appearance', defaultMessage: 'Appearance' }, + icon: PaintbrushIcon, + content: makeTabContent('Appearance'), + }, + ] + return { modalRef, tabs } + }, + template: /* html */ ` +
+ + + + +
+ `, + }), +} + +export const ManyTabs: StoryObj = { + render: () => ({ + components: { TabbedModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const tabs = [ + { + name: { id: 'general', defaultMessage: 'General' }, + icon: InfoIcon, + content: makeTabContent('General'), + }, + { + name: { id: 'appearance', defaultMessage: 'Appearance' }, + icon: PaintbrushIcon, + content: makeTabContent('Appearance'), + }, + { + name: { id: 'language', defaultMessage: 'Language' }, + icon: LanguagesIcon, + content: makeTabContent('Language'), + }, + { + name: { id: 'privacy', defaultMessage: 'Privacy' }, + icon: ShieldIcon, + content: makeTabContent('Privacy'), + }, + { + name: { id: 'java', defaultMessage: 'Java and memory' }, + icon: CoffeeIcon, + content: makeTabContent('Java and memory'), + }, + { + name: { id: 'instances', defaultMessage: 'Default instance options' }, + icon: GameIcon, + content: makeTabContent('Default instance options'), + }, + { + name: { id: 'resources', defaultMessage: 'Resource management' }, + icon: GaugeIcon, + content: makeTabContent('Resource management'), + }, + { + name: { id: 'window', defaultMessage: 'Window' }, + icon: MonitorIcon, + content: makeTabContent('Window'), + }, + { + name: { id: 'hooks', defaultMessage: 'Launch hooks' }, + icon: WrenchIcon, + content: makeTabContent('Launch hooks'), + }, + ] + return { modalRef, tabs } + }, + template: /* html */ ` +
+ + + + +
+ `, + }), +} + +export const ScrollableContent: StoryObj = { + render: () => ({ + components: { TabbedModal, ButtonStyled }, + setup() { + const modalRef = ref | null>(null) + const tabs = [ + { + name: { id: 'long', defaultMessage: 'Long content' }, + icon: InfoIcon, + content: makeTabContent('Long content', 30), + }, + { + name: { id: 'short', defaultMessage: 'Short content' }, + icon: PaintbrushIcon, + content: makeTabContent('Short content', 2), + }, + ] + return { modalRef, tabs } + }, + template: /* html */ ` +
+ + + + +
+ `, + }), +} From 00e81adbbd6b7654c7e5fa2b41cc4047642bb8bd Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Thu, 19 Mar 2026 20:53:58 +0000 Subject: [PATCH 08/45] fix: reinstall soft_override: true (#5623) --- apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue index 05a1adaea0..b0d9ccd2d9 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue @@ -410,7 +410,7 @@ provideInstallationSettings({ project_id: modpack.value.spec.project_id, version_id: modpack.value.spec.version_id, }, - soft_override: false, + soft_override: true, }) debug('reinstallModpack: installContent succeeded, invalidating') invalidateServerState() From 3c5bd0756d30e21c9868de2690e7d3797a95de03 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Fri, 20 Mar 2026 04:01:19 +0000 Subject: [PATCH 09/45] Index search by original and split title (#5589) * Index search by original and split title * better normalization of title/author names for indexing * replace println with warn * fix test --- .../src/search/backend/typesense/mod.rs | 21 ++++++--- apps/labrinth/src/search/indexing.rs | 45 +++++++++++++++++-- apps/labrinth/src/search/mod.rs | 2 +- 3 files changed, 56 insertions(+), 12 deletions(-) diff --git a/apps/labrinth/src/search/backend/typesense/mod.rs b/apps/labrinth/src/search/backend/typesense/mod.rs index 026b940b76..dec7923e2c 100644 --- a/apps/labrinth/src/search/backend/typesense/mod.rs +++ b/apps/labrinth/src/search/backend/typesense/mod.rs @@ -105,18 +105,25 @@ impl Default for RequestConfig { } fn default_query_by() -> Vec { - ["indexed_title", "slug", "summary", "indexed_author"] - .into_iter() - .map(str::to_string) - .collect() + [ + "name", + "indexed_name", + "slug", + "author", + "indexed_author", + "summary", + ] + .into_iter() + .map(str::to_string) + .collect() } fn default_query_by_weights() -> Vec { - vec![15, 5, 2, 1] + vec![15, 15, 10, 3, 3, 1] } fn default_prefix() -> Vec { - vec![true, true, true, true] + vec![true, true, true, true, true, true] } const fn default_prioritize_exact_match() -> bool { @@ -491,7 +498,7 @@ impl Typesense { let mut fields = vec![ json!({"name": "summary", "type": "string", "facet": false}), json!({"name": "slug", "type": "string", "facet": false}), - json!({"name": "indexed_title", "type": "string", "facet": false, "stem": true}), + json!({"name": "indexed_name", "type": "string", "facet": false, "stem": true}), json!({"name": "indexed_author", "type": "string", "facet": false}), json!({"name": "log_downloads", "type": "float", "sort": true}), json!({"name": "follows", "type": "int32", "facet": true, "sort": true}), diff --git a/apps/labrinth/src/search/indexing.rs b/apps/labrinth/src/search/indexing.rs index cf4a92f931..b081ce9aae 100644 --- a/apps/labrinth/src/search/indexing.rs +++ b/apps/labrinth/src/search/indexing.rs @@ -4,8 +4,10 @@ use eyre::Result; use futures::TryStreamExt; use heck::ToKebabCase; use itertools::Itertools; +use regex::Regex; use std::collections::HashMap; -use tracing::info; +use std::sync::LazyLock; +use tracing::{info, warn}; use crate::database::PgPool; use crate::database::models::loader_fields::{ @@ -25,6 +27,13 @@ use crate::routes::v2_reroute; use crate::search::UploadSearchProject; use crate::util::error::Context; +fn normalize_for_search(s: &str) -> String { + static SPECIAL_CHARS_RE: LazyLock = + LazyLock::new(|| Regex::new(r"[^a-zA-Z0-9-.\s]").expect("valid regex")); + + SPECIAL_CHARS_RE.replace_all(s, "").to_kebab_case() +} + pub async fn index_local( pool: &PgPool, redis: &RedisPool, @@ -262,7 +271,7 @@ pub async fn index_local( { team_owner } else { - println!( + warn!( "org owner not found for project {} id: {}!", project.name, project.id.0 ); @@ -427,7 +436,7 @@ pub async fn index_local( project_id: crate::models::ids::ProjectId::from(project.id) .to_string(), name: project.name.clone(), - indexed_title: project.name.to_kebab_case(), + indexed_name: normalize_for_search(&project.name), summary: project.summary.clone(), categories: categories.clone(), display_categories: display_categories.clone(), @@ -436,7 +445,7 @@ pub async fn index_local( log_downloads: (project.downloads.max(1) as f64).ln(), icon_url: project.icon_url.clone(), author: owner.clone(), - indexed_author: owner.to_kebab_case(), + indexed_author: normalize_for_search(&owner), date_created: project.approved, created_timestamp: project.approved.timestamp(), date_modified: project.updated, @@ -614,3 +623,31 @@ async fn index_versions( Ok(res_versions) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_normalize_for_search_removes_special_chars() { + assert_eq!(normalize_for_search("Xaero's Minimap"), "xaeros-minimap"); + assert_eq!(normalize_for_search("JourneyMap"), "journey-map"); + assert_eq!(normalize_for_search("journey-map"), "journey-map"); + assert_eq!(normalize_for_search("SomeUserName"), "some-user-name"); + } + + #[test] + fn test_normalize_for_search_handles_whitespace() { + assert_eq!( + normalize_for_search("Some Project Name"), + "some-project-name" + ); + assert_eq!(normalize_for_search(" padded "), "padded"); + } + + #[test] + fn test_normalize_for_search_handles_numbers() { + assert_eq!(normalize_for_search("Project 123"), "project-123"); + assert_eq!(normalize_for_search("Test 1.0"), "test-1-0"); + } +} diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs index 545a0c4fc6..843a6519ac 100644 --- a/apps/labrinth/src/search/mod.rs +++ b/apps/labrinth/src/search/mod.rs @@ -230,7 +230,7 @@ pub struct UploadSearchProject { pub author: String, pub indexed_author: String, pub name: String, - pub indexed_title: String, + pub indexed_name: String, pub summary: String, pub categories: Vec, pub display_categories: Vec, From 9e6a6cd385d31ed593c702b66720e69fcd7b299d Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Sat, 21 Mar 2026 18:04:55 +0000 Subject: [PATCH 10/45] fix: withdraw flow bug (zero bal) (#5629) --- apps/frontend/src/providers/creator-withdraw.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/providers/creator-withdraw.ts b/apps/frontend/src/providers/creator-withdraw.ts index 7e544ac3d8..fc9ce21381 100644 --- a/apps/frontend/src/providers/creator-withdraw.ts +++ b/apps/frontend/src/providers/creator-withdraw.ts @@ -408,11 +408,11 @@ export function createWithdrawContext( const stages = computed(() => { const dynamicStages: WithdrawStage[] = [] - const usedLimit = balance?.withdrawn_ytd ?? 0 - const available = balance?.available ?? 0 + const usedLimit = balanceRef.value?.withdrawn_ytd ?? 0 + const available = balanceRef.value?.available ?? 0 const needsTaxForm = - balance?.form_completion_status !== 'complete' && + balanceRef.value?.form_completion_status !== 'complete' && usedLimit + available >= getTaxThreshold(taxComplianceThresholds) const threshold = getTaxThreshold(taxComplianceThresholds) @@ -420,7 +420,7 @@ export function createWithdrawContext( usedLimit, available, total: usedLimit + available, - status: balance?.form_completion_status, + status: balanceRef.value?.form_completion_status, needsTaxForm, taxThreshold: threshold, taxComplianceFilled: `${((usedLimit / threshold) * 100).toFixed(1)}%`, @@ -448,14 +448,14 @@ export function createWithdrawContext( }) const maxWithdrawAmount = computed(() => { - const availableBalance = balance?.available ?? 0 - const formCompleted = balance?.form_completion_status === 'complete' + const availableBalance = balanceRef.value?.available ?? 0 + const formCompleted = balanceRef.value?.form_completion_status === 'complete' if (formCompleted) { return Math.max(0, availableBalance) } - const usedLimit = balance?.withdrawn_ytd ?? 0 + const usedLimit = balanceRef.value?.withdrawn_ytd ?? 0 const remainingLimit = Math.max(0, getTaxThresholdActual(taxComplianceThresholds) - usedLimit) return Math.max(0, Math.min(remainingLimit, availableBalance)) }) From 92eddbe8323f05eb7d90aaf9e29b8caef995c6d2 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Sat, 21 Mar 2026 18:06:03 +0000 Subject: [PATCH 11/45] feat: move switch version inline like update btn for content tab (#5631) * fix: switch version inline same as update btn * fix: lint --- .../app-frontend/src/locales/en-US/index.json | 3 --- apps/app-frontend/src/pages/instance/Mods.vue | 17 +++------------ .../components/ContentCardItem.vue | 21 +++++++++++++++++-- .../components/ContentCardTable.vue | 7 +++++++ .../src/layouts/shared/content-tab/layout.vue | 8 +++++++ .../content-tab/providers/content-manager.ts | 3 +++ .../wrapped/hosting/manage/content.vue | 17 +++------------ packages/ui/src/locales/en-US/index.json | 3 --- 8 files changed, 43 insertions(+), 36 deletions(-) diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 8d9bbdcdea..418f30d826 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -86,9 +86,6 @@ "app.instance.mods.successfully-uploaded": { "message": "Successfully uploaded" }, - "app.instance.mods.switch-version": { - "message": "Switch version" - }, "app.instance.mods.unknown-version": { "message": "Unknown" }, diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index d08ef25327..dfc62c3ad2 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -63,7 +63,7 @@ diff --git a/packages/api-client/src/modules/archon/servers/v1.ts b/packages/api-client/src/modules/archon/servers/v1.ts index 8d9f944ee7..9435bf11c7 100644 --- a/packages/api-client/src/modules/archon/servers/v1.ts +++ b/packages/api-client/src/modules/archon/servers/v1.ts @@ -54,4 +54,16 @@ export class ArchonServersV1Module extends AbstractModule { method: 'DELETE', }) } + + /** + * Reset a world to onboarding + * POST /v1/servers/:id/worlds/:wid/onboard + */ + public async resetToOnboarding(serverId: string, worldId: string): Promise { + await this.client.request(`/servers/${serverId}/worlds/${worldId}/onboard`, { + api: 'archon', + version: 1, + method: 'POST', + }) + } } From 4b4282cfbff07779b9c2e925fa810f2eb62da2ed Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 25 Mar 2026 17:52:12 +0000 Subject: [PATCH 20/45] fix: 500 on oauth authorize page (#5661) --- apps/frontend/src/pages/auth/authorize.vue | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/frontend/src/pages/auth/authorize.vue b/apps/frontend/src/pages/auth/authorize.vue index bd9d76d480..c411819eb7 100644 --- a/apps/frontend/src/pages/auth/authorize.vue +++ b/apps/frontend/src/pages/auth/authorize.vue @@ -5,11 +5,11 @@

{{ formatMessage(commonMessages.errorLabel) }}

- {{ error.data.error }}: - {{ error.data.description }} + {{ error.data?.error }}: + {{ error.data?.description }}

-
+
@@ -164,24 +164,31 @@ const { data: authorizationData, isPending: pending, error, + suspense: authSusp, } = useQuery({ queryKey: computed(() => ['authorization', clientId, redirectUri, scope, state]), queryFn: getFlowIdAuthorization, enabled: computed(() => !!clientId && !!redirectUri && !!scope), }) -const { data: app } = useQuery({ +const { data: app, suspense: appSusp } = useQuery({ queryKey: computed(() => ['oauth/app', clientId]), queryFn: () => client.labrinth.oauth_internal.getApp(clientId), enabled: computed(() => !!clientId), }) -const { data: createdBy } = useQuery({ +const { data: createdBy, suspense: userSusp } = useQuery({ queryKey: computed(() => ['user', app.value?.created_by]), queryFn: () => client.labrinth.users_v2.get(app.value.created_by), enabled: computed(() => !!app.value?.created_by), }) +onServerPrefetch(async () => { + await authSusp() + await appSusp() + await userSusp() +}) + const scopeDefinitions = computed(() => scopesToDefinitions(BigInt(authorizationData.value?.requested_scopes || 0)), ) From 81f19eeb8d304ddae00dc1f0cc45c75be4804283 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Wed, 25 Mar 2026 17:58:13 +0000 Subject: [PATCH 21/45] fix: various content tab hosting bugs (#5662) * fix: qa * fix: lint --- .../src/pages/hosting/manage/[id]/options/loader.vue | 10 ++++++++-- .../shared/content-tab/components/ContentCardItem.vue | 8 ++++---- .../ui/src/layouts/wrapped/hosting/manage/content.vue | 3 ++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue b/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue index 68557c5242..368479623b 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/options/loader.vue @@ -398,11 +398,17 @@ provideInstallationSettings({ const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla' const platformChanged = platform !== currentPlatform + let resolvedLoaderVersion = loaderVersionId + if (!resolvedLoaderVersion && platform !== 'vanilla') { + const versions = getLoaderVersionsForGameVersion(platform, gameVersion) + resolvedLoaderVersion = versions[0]?.id ?? null + } + debug('save: emitting reinstall before API call') emit( 'reinstall', platformChanged - ? { loader: platform, lVersion: loaderVersionId, mVersion: gameVersion } + ? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion } : { mVersion: gameVersion }, ) try { @@ -410,7 +416,7 @@ provideInstallationSettings({ const request: Archon.Content.v1.InstallWorldContent = { content_variant: 'bare', loader: toApiLoader(platform), - version: loaderVersionId ?? '', + version: resolvedLoaderVersion ?? '', game_version: gameVersion || undefined, soft_override: true, } diff --git a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue index 7e12cb55fe..dfc22431c8 100644 --- a/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue +++ b/packages/ui/src/layouts/shared/content-tab/components/ContentCardItem.vue @@ -212,8 +212,8 @@ const deleteHovered = ref(false) > {{ version.version_number.slice(0, Math.ceil(version.version_number.length / 2)) - }} - {{ + }}{{ version.version_number.slice(Math.ceil(version.version_number.length / 2)) }} @@ -223,8 +223,8 @@ const deleteHovered = ref(false) > {{ version.file_name.slice(0, Math.ceil(version.file_name.length / 2)) - }} - {{ + }}{{ version.file_name.slice(Math.ceil(version.file_name.length / 2)) }} diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue index af1aff3f86..11c808469b 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/content.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/content.vue @@ -115,6 +115,7 @@ const contentQuery = useQuery({ queryFn: () => client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), enabled: computed(() => worldId.value !== null), + staleTime: 0, }) const modpackProjectId = computed(() => contentQuery.data.value?.modpack?.spec.project_id ?? null) @@ -483,7 +484,7 @@ function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem { link: `/${addon.owner.type}/${addon.owner.id}`, } : undefined, - id: addon.id, + id: addon.id ?? addon.filename, enabled: !addon.disabled, file_name: addon.filename, project_type: addon.kind, From 0731654a1c6015d9ebf8b99f6aee2d5d35be65b4 Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 26 Mar 2026 06:33:58 +0000 Subject: [PATCH 22/45] maybe fix daedalus (#5665) --- apps/daedalus_client/src/forge.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/daedalus_client/src/forge.rs b/apps/daedalus_client/src/forge.rs index 8bbd7cc943..1549f186cd 100644 --- a/apps/daedalus_client/src/forge.rs +++ b/apps/daedalus_client/src/forge.rs @@ -114,12 +114,12 @@ pub async fn fetch_neo( } let forge_versions = fetch_xml::( - "https://maven.neoforged.net/net/neoforged/forge/maven-metadata.xml", + "https://maven.neoforged.net/releases/net/neoforged/forge/maven-metadata.xml", &semaphore, ) .await?; let neo_versions = fetch_xml::( - "https://maven.neoforged.net/net/neoforged/neoforge/maven-metadata.xml", + "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml", &semaphore, ) .await?; @@ -133,7 +133,7 @@ pub async fn fetch_neo( Ok(ForgeVersion { format_version: 2, - installer_url: format!("https://maven.neoforged.net/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"), + installer_url: format!("https://maven.neoforged.net/releases/net/neoforged/forge/{loader_version}/forge-{loader_version}-installer.jar"), raw: loader_version, loader_version: version_split, game_version: "1.20.1".to_string(), // All NeoForge Forge versions are for 1.20.1 @@ -159,7 +159,7 @@ pub async fn fetch_neo( Ok(ForgeVersion { format_version: 2, - installer_url: format!("https://maven.neoforged.net/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"), + installer_url: format!("https://maven.neoforged.net/releases/net/neoforged/neoforge/{loader_version}/neoforge-{loader_version}-installer.jar"), loader_version: loader_version.clone(), raw: loader_version, game_version, From bf24ed8d12fa844a3c063071050742a20b4e4a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois-Xavier=20Talbot?= <108630700+fetchfern@users.noreply.github.com> Date: Thu, 26 Mar 2026 02:34:04 -0400 Subject: [PATCH 23/45] Add feature flag to force Archon requests to be traced (#5666) --- apps/frontend/src/composables/featureFlags.ts | 1 + apps/frontend/src/helpers/api.ts | 4 ++++ packages/api-client/src/core/abstract-client.ts | 16 ++++++++++++++++ .../api-client/src/platform/xhr-upload-client.ts | 1 + packages/api-client/src/types/client.ts | 8 ++++++++ 5 files changed, 30 insertions(+) diff --git a/apps/frontend/src/composables/featureFlags.ts b/apps/frontend/src/composables/featureFlags.ts index a30b70a5d0..a1acb5878c 100644 --- a/apps/frontend/src/composables/featureFlags.ts +++ b/apps/frontend/src/composables/featureFlags.ts @@ -38,6 +38,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({ showProjectPageQuickServerButton: false, newProjectGeneralSettings: false, newProjectEnvironmentSettings: true, + archonSentryCapture: false, hideRussiaCensorshipBanner: false, disablePrettyProjectUrlRedirects: false, hidePreviewBanner: false, diff --git a/apps/frontend/src/helpers/api.ts b/apps/frontend/src/helpers/api.ts index 5b49bcf3c3..1434fe144f 100644 --- a/apps/frontend/src/helpers/api.ts +++ b/apps/frontend/src/helpers/api.ts @@ -13,6 +13,8 @@ import { } from '@modrinth/api-client' import type { Ref } from 'vue' +import { useFeatureFlags } from '~/composables/featureFlags.ts' + async function getRateLimitKeyFromSecretsStore(): Promise { try { const mod = 'cloudflare:workers' @@ -28,6 +30,7 @@ export function createModrinthClient( auth: Ref<{ token: string | undefined }>, config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string }, ): NuxtModrinthClient { + const flags = useFeatureFlags() const optionalFeatures = [ import.meta.dev ? (new VerboseLoggingFeature() as AbstractFeature) : undefined, ].filter(Boolean) as AbstractFeature[] @@ -35,6 +38,7 @@ export function createModrinthClient( const clientConfig: NuxtClientConfig = { labrinthBaseUrl: config.apiBaseUrl, archonBaseUrl: config.archonBaseUrl, + archonSentryCapture: () => flags.value.archonSentryCapture, rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore, features: [ // for modrinth hosting diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index 42429d3ae3..8ab7fee256 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -126,6 +126,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { ...options.headers, }, } + this.attachArchonSentryCaptureHeader(mergedOptions) const headers = mergedOptions.headers if (headers && 'Content-Type' in headers && headers['Content-Type'] === '') { @@ -309,6 +310,21 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { return headers } + protected attachArchonSentryCaptureHeader(options: RequestOptions): void { + if (options.api !== 'archon' || !options.headers || !this.shouldCaptureArchonRequests()) { + return + } + + options.headers['modrinth-sentry-capture'] = '1' + } + + private shouldCaptureArchonRequests(): boolean { + const archonSentryCapture = this.config.archonSentryCapture + return typeof archonSentryCapture === 'function' + ? archonSentryCapture() + : archonSentryCapture === true + } + /** * Execute the actual HTTP request * diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index de7dd9c233..c47b4c342f 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -46,6 +46,7 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { ...options.headers, }, } + this.attachArchonSentryCaptureHeader(mergedOptions) const context = this.buildUploadContext(url, path, mergedOptions) diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index fa9d20a31c..5196f2209a 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -55,6 +55,14 @@ export interface ClientConfig { */ headers?: Record + /** + * Whether to attach `modrinth-sentry-capture: 1` to Archon requests. + * Can be a callback so apps can drive this from runtime feature flags. + * + * @default false + */ + archonSentryCapture?: boolean | (() => boolean) + /** * Features to enable for this client * Features are applied in the order they appear in this array From da48a12551fcbebb2febb88e378adedcabefc39b Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 26 Mar 2026 06:34:20 +0000 Subject: [PATCH 24/45] Only mark servers as offline if they fail pings 3+ times (#5664) * wip: online status fix * use INCR * properly clear cache --- ...5aabf13190a0b336089e6521022069813cf17.json | 5 +- ...dada47fb382a76fdcabad2077fb1ef6d1010a.json | 5 +- ...ba49f4963315fd7667c6db96e6153e54a2fd2.json | 3 +- ...de1e7cddd68ac956143bef994104280a8dc07.json | 3 +- apps/labrinth/CLAUDE.md | 2 +- apps/labrinth/src/database/redis/mod.rs | 14 ++++ apps/labrinth/src/env.rs | 1 + apps/labrinth/src/queue/server_ping.rs | 75 ++++++++++++++----- 8 files changed, 83 insertions(+), 25 deletions(-) diff --git a/apps/labrinth/.sqlx/query-10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17.json b/apps/labrinth/.sqlx/query-10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17.json index 53cf350301..ed3215a695 100644 --- a/apps/labrinth/.sqlx/query-10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17.json +++ b/apps/labrinth/.sqlx/query-10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17.json @@ -29,7 +29,8 @@ "low", "medium", "high", - "severe" + "severe", + "malware" ] } } @@ -45,7 +46,7 @@ false, true, false, - false + true ] }, "hash": "10e2a3b31ba94b93ed2d6c9753a5aabf13190a0b336089e6521022069813cf17" diff --git a/apps/labrinth/.sqlx/query-263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a.json b/apps/labrinth/.sqlx/query-263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a.json index 23c13cd169..a854453595 100644 --- a/apps/labrinth/.sqlx/query-263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a.json +++ b/apps/labrinth/.sqlx/query-263ad3654f544ffb6061c839d49dada47fb382a76fdcabad2077fb1ef6d1010a.json @@ -44,7 +44,8 @@ "low", "medium", "high", - "severe" + "severe", + "malware" ] } } @@ -79,7 +80,7 @@ true, false, false, - false, + true, null ] }, diff --git a/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json b/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json index 8ca4b69491..419c0ff25c 100644 --- a/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json +++ b/apps/labrinth/.sqlx/query-9369f0659c5fbd08463923a9b2bba49f4963315fd7667c6db96e6153e54a2fd2.json @@ -25,7 +25,8 @@ "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json b/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json index 8cbe94abd5..c20c8ecdab 100644 --- a/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json +++ b/apps/labrinth/.sqlx/query-f2054ae7dcc89b21ed6b2f04526de1e7cddd68ac956143bef994104280a8dc07.json @@ -22,7 +22,8 @@ "low", "medium", "high", - "severe" + "severe", + "malware" ] } } diff --git a/apps/labrinth/CLAUDE.md b/apps/labrinth/CLAUDE.md index 2d496b028d..fe4b705308 100644 --- a/apps/labrinth/CLAUDE.md +++ b/apps/labrinth/CLAUDE.md @@ -8,7 +8,7 @@ When the user refers to "perform[ing] pre-PR checks", do the following: - Run `cargo clippy -p labrinth --all-targets` — there must be ZERO warnings, otherwise CI will fail - DO NOT run tests unless explicitly requested (they take a long time) -- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare` +- Prepare the sqlx cache: cd into `apps/labrinth` and run `cargo sqlx prepare -- --tests` - NEVER run `cargo sqlx prepare --workspace` ## Testing diff --git a/apps/labrinth/src/database/redis/mod.rs b/apps/labrinth/src/database/redis/mod.rs index 1b8ac30c01..51e94cfb16 100644 --- a/apps/labrinth/src/database/redis/mod.rs +++ b/apps/labrinth/src/database/redis/mod.rs @@ -788,6 +788,20 @@ impl RedisConnection { .await?; Ok(values) } + + #[tracing::instrument(skip(self))] + pub async fn incr( + &mut self, + namespace: &str, + id: &str, + ) -> Result, DatabaseError> { + let key = format!("{}_{namespace}:{id}", self.meta_namespace); + let value = cmd("INCR") + .arg(key) + .query_async(&mut self.connection) + .await?; + Ok(value) + } } #[derive(Serialize, Deserialize)] diff --git a/apps/labrinth/src/env.rs b/apps/labrinth/src/env.rs index d275e2caee..ab5c6165b8 100644 --- a/apps/labrinth/src/env.rs +++ b/apps/labrinth/src/env.rs @@ -293,4 +293,5 @@ vars! { SERVER_PING_RETRIES: usize = 3usize; SERVER_PING_MIN_INTERVAL_SEC: u64 = 30u64 * 60; SERVER_PING_TIMEOUT_MS: u64 = 3u64 * 1000; + SERVER_PING_MAX_FAIL_COUNT: u64 = 3u64; } diff --git a/apps/labrinth/src/queue/server_ping.rs b/apps/labrinth/src/queue/server_ping.rs index c65ceb80ba..b53708290e 100644 --- a/apps/labrinth/src/queue/server_ping.rs +++ b/apps/labrinth/src/queue/server_ping.rs @@ -24,6 +24,7 @@ pub struct ServerPingQueue { } pub const REDIS_NAMESPACE: &str = "minecraft_java_server_ping"; +pub const REDIS_FAILURE_NAMESPACE: &str = "minecraft_java_server_ping_failures"; pub const CLICKHOUSE_TABLE: &str = "minecraft_java_server_pings"; impl ServerPingQueue { @@ -118,27 +119,65 @@ impl ServerPingQueue { .await .wrap_err("failed to write ping record")?; - redis - .set_serialized_to_json( - REDIS_NAMESPACE, - project_id, - ping, + let mut updated_project = false; + if data.is_some() { + // ping succeeded; immediately update its online status in redis + + redis + .set_serialized_to_json( + REDIS_NAMESPACE, + project_id, + ping, + None, + ) + .await + .wrap_err("failed to set redis key")?; + updated_project = true; + + redis + .delete(REDIS_FAILURE_NAMESPACE, project_id) + .await + .wrap_err("failed to delete failure count")?; + } else { + // ping failed; if it's failed too many times, mark it as offline in redis + // otherwise, just add to the fail counter + + let failure_count = redis + .incr(REDIS_FAILURE_NAMESPACE, &project_id.to_string()) + .await + .wrap_err("failed to increment failure count")?; + + if let Some(count) = failure_count + && count >= ENV.SERVER_PING_MAX_FAIL_COUNT + { + redis + .set_serialized_to_json( + REDIS_NAMESPACE, + project_id, + ping, + None, + ) + .await + .wrap_err( + "failed to set failed ping record in redis", + )?; + updated_project = true; + } + } + + if updated_project { + DBProject::clear_cache( + (*project_id).into(), None, + None, + &self.redis, ) .await - .wrap_err("failed to set redis key")?; - - DBProject::clear_cache( - (*project_id).into(), - None, - None, - &self.redis, - ) - .await - .inspect_err(|err| { - warn!("failed to clear project cache: {err:#}") - }) - .ok(); + .inspect_err(|err| { + warn!("failed to clear project cache: {err:#}") + }) + .ok(); + } } ch.end() From 274325d97c6700c62e5bbc539e7985f45267e2cf Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Thu, 26 Mar 2026 00:21:19 -0700 Subject: [PATCH 25/45] fix: settings page error (#5668) --- apps/frontend/src/pages/settings/index.vue | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/frontend/src/pages/settings/index.vue b/apps/frontend/src/pages/settings/index.vue index 5626d3d147..1591f1e318 100644 --- a/apps/frontend/src/pages/settings/index.vue +++ b/apps/frontend/src/pages/settings/index.vue @@ -196,10 +196,6 @@ import { isDarkTheme, type Theme } from '~/plugins/theme/index.ts' const { addNotification } = injectNotificationManager() const { formatMessage } = useVIntl() -useHead({ - title: () => `${formatMessage(messages.headTitle)} - Modrinth`, -}) - const messages = defineMessages({ headTitle: { id: 'settings.head-title', @@ -219,6 +215,10 @@ const developerModeBanner = defineMessages({ }, }) +useHead({ + title: () => `${formatMessage(messages.headTitle)} - Modrinth`, +}) + const layoutMode = defineMessages({ rows: { id: 'settings.display.project-list-layouts.mode.rows', From 3c3cde19089ef1d05e368e0bf87b69c9b190d7cb Mon Sep 17 00:00:00 2001 From: Prospector <6166773+Prospector@users.noreply.github.com> Date: Thu, 26 Mar 2026 00:22:40 -0700 Subject: [PATCH 26/45] changelog --- packages/blog/changelog.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/blog/changelog.ts b/packages/blog/changelog.ts index 56b3b94557..be03c31b86 100644 --- a/packages/blog/changelog.ts +++ b/packages/blog/changelog.ts @@ -10,6 +10,22 @@ export type VersionEntry = { } const VERSIONS: VersionEntry[] = [ + { + date: `2026-03-26T07:22:22+00:00`, + product: 'web', + body: `## Fixed +- Fixed error on settings page. +- Fixed the "500 Server Error" error on the "Authorized apps" settings page.`, + }, + { + date: `2026-03-26T07:22:22+00:00`, + product: 'hosting', + body: `## Fixed +- Fixed wrong mod being disabled on hosted servers - Disabling a non-Modrinth mod could incorrectly disable a different mod instead +- Fixed content list not refreshing after installing mods - Navigating back to the content tab after installing something from Browse would show stale data for up to 10 seconds +- Fixed copying mod filenames inserting a newline - Copying a filename or version number from the content tab no longer includes a line break in the middle +- Fixed NeoForge installs sending an empty loader version - Changing to NeoForge on a hosted server could send a blank loader version, resulting in a broken installation`, + }, { date: `2026-03-24T21:14:30-08:00`, product: 'hosting', From f1648298c496dba2a5045777b62e4120fb66f41e Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Thu, 26 Mar 2026 17:53:27 +0000 Subject: [PATCH 27/45] fix: neoforge not existing for 26.1 breaking vers picker (#5674) * fix: neoforge for 26.1 -> other vers being picked not existing causing version picker to break * fix: lint --- .../components/CustomSetupStage.vue | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue index 9c256c6871..536b3057d8 100644 --- a/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue +++ b/packages/ui/src/components/flows/creation-flow-modal/components/CustomSetupStage.vue @@ -254,6 +254,14 @@ async function fetchLoaderManifest(loader: string) { let apiLoader = loader if (apiLoader === 'neoforge') apiLoader = 'neo' + debug( + 'fetchLoaderManifest:', + loader, + 'apiLoader:', + apiLoader, + 'cached:', + !!loaderVersionsCache.value[apiLoader], + ) if (loaderVersionsCache.value[apiLoader]) return try { @@ -262,7 +270,9 @@ async function fetchLoaderManifest(loader: string) { gameVersions: { id: string; loaders: LoaderVersionEntry[] }[] } loaderVersionsCache.value[apiLoader] = data.gameVersions - } catch { + debug('fetchLoaderManifest: loaded', apiLoader, 'gameVersions:', data.gameVersions.length) + } catch (e) { + debug('fetchLoaderManifest: FAILED', apiLoader, e) loaderVersionsCache.value[apiLoader] = [] } } @@ -319,13 +329,32 @@ function getLoaderVersionsForGameVersion( if (apiLoader === 'neoforge') apiLoader = 'neo' const manifest = loaderVersionsCache.value[apiLoader] + debug('getLoaderVersionsForGameVersion:', { + loader, + apiLoader, + gameVersion, + hasManifest: !!manifest, + manifestLength: manifest?.length, + }) if (!manifest) return [] // Some loaders (e.g. Fabric) list all versions under a placeholder entry const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}') - if (placeholder) return placeholder.loaders + if (placeholder) { + debug( + 'getLoaderVersionsForGameVersion: using placeholder, loaders:', + placeholder.loaders.length, + ) + return placeholder.loaders + } const entry = manifest.find((x) => x.id === gameVersion) + debug( + 'getLoaderVersionsForGameVersion: entry for', + gameVersion, + ':', + entry ? entry.loaders.length + ' loaders' : 'NOT FOUND', + ) return entry?.loaders ?? [] } @@ -348,9 +377,12 @@ watch( ) // Watch loader + game version to resolve loader versions +let loaderVersionWatchId = 0 watch( [() => selectedLoader.value, () => selectedGameVersion.value], async ([loader, gameVersion]) => { + const watchId = ++loaderVersionWatchId + debug('watch [loader, gameVersion] fired:', { loader, gameVersion, watchId }) loaderVersionsData.value = [] selectedLoaderVersion.value = null @@ -360,8 +392,8 @@ watch( if (loader === 'paper') { await fetchPaperVersions(gameVersion) + if (watchId !== loaderVersionWatchId) return loaderVersionsLoading.value = false - // Auto-select latest build const builds = paperVersions.value[gameVersion] if (builds?.length) { selectedLoaderVersion.value = `${builds[0]}` @@ -371,8 +403,8 @@ watch( if (loader === 'purpur') { await fetchPurpurVersions(gameVersion) + if (watchId !== loaderVersionWatchId) return loaderVersionsLoading.value = false - // Auto-select latest build const builds = purpurVersions.value[gameVersion] if (builds?.length) { selectedLoaderVersion.value = builds[0] @@ -381,7 +413,18 @@ watch( } await fetchLoaderManifest(loader) + if (watchId !== loaderVersionWatchId) { + debug('watch [loader, gameVersion]: stale execution, skipping', { + watchId, + current: loaderVersionWatchId, + }) + return + } loaderVersionsData.value = getLoaderVersionsForGameVersion(loader, gameVersion) + debug( + 'watch [loader, gameVersion]: loaderVersionsData set, count:', + loaderVersionsData.value.length, + ) loaderVersionsLoading.value = false // Auto-select based on loaderVersionType @@ -395,6 +438,16 @@ watch( ) function autoSelectLoaderVersion() { + debug( + 'autoSelectLoaderVersion: type:', + loaderVersionType.value, + 'dataCount:', + loaderVersionsData.value.length, + 'stableCount:', + loaderVersionsData.value.filter((v) => v.stable).length, + 'first:', + loaderVersionsData.value[0]?.id, + ) if (loaderVersionType.value === 'stable') { const stable = loaderVersionsData.value.find((v) => v.stable) selectedLoaderVersion.value = stable?.id ?? loaderVersionsData.value[0]?.id ?? null @@ -403,7 +456,7 @@ function autoSelectLoaderVersion() { } else if (loaderVersionType.value === 'other' && !selectedLoaderVersion.value) { selectedLoaderVersion.value = loaderVersionsData.value[0]?.id ?? null } - debug('autoSelectLoaderVersion:', selectedLoaderVersion.value, 'type:', loaderVersionType.value) + debug('autoSelectLoaderVersion: result:', selectedLoaderVersion.value) } const loaderVersionOptions = computed[]>(() => { From 706eb800cb56916ac0a2ac8da96ef0290e65f6f6 Mon Sep 17 00:00:00 2001 From: Truman Gao <106889354+tdgao@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:40:44 -0700 Subject: [PATCH 28/45] fix: website visual issues (#5675) * fix no modpack loader showing as resource pack loader * fix table overflow, add game version tags "+ {num}" overflow menu * pnpm prepr --- apps/app-frontend/src/pages/project/Index.vue | 2 +- apps/frontend/src/pages/[type]/[id].vue | 2 +- .../pages/[type]/[id]/version/[version].vue | 22 +++- .../api-client/src/modules/labrinth/types.ts | 1 + .../project/ProjectPageVersions.vue | 120 +++++++++++++++--- .../project/ProjectSidebarCompatibility.vue | 41 ++++-- 6 files changed, 154 insertions(+), 34 deletions(-) diff --git a/apps/app-frontend/src/pages/project/Index.vue b/apps/app-frontend/src/pages/project/Index.vue index 60548fd667..76ddd966ae 100644 --- a/apps/app-frontend/src/pages/project/Index.vue +++ b/apps/app-frontend/src/pages/project/Index.vue @@ -5,7 +5,7 @@ v-if="!isServerProject" :project="data" :tags="{ loaders: allLoaders, gameVersions: allGameVersions }" - :v3-metadata="projectV3" + :project-v3="projectV3" class="project-sidebar-section" /> diff --git a/apps/frontend/src/pages/[type]/[id]/version/[version].vue b/apps/frontend/src/pages/[type]/[id]/version/[version].vue index c3583a79b3..0fdb572624 100644 --- a/apps/frontend/src/pages/[type]/[id]/version/[version].vue +++ b/apps/frontend/src/pages/[type]/[id]/version/[version].vue @@ -341,7 +341,8 @@

Loaders

- + No mod loader +

Game versions

@@ -698,6 +699,25 @@ const title = computed( () => `${isCreating.value ? 'Create Version' : version.value.name} - ${project.value.title}`, ) +const modpackLoaders = computed(() => { + if (project.value.project_type !== 'modpack') { + return [] + } + + if (Array.isArray(version.value.mrpack_loaders) && version.value.mrpack_loaders.length > 0) { + return version.value.mrpack_loaders + } + + return (version.value.loaders ?? []).filter((loader: string) => loader !== 'mrpack') +}) + +const noModpackLoader = computed( + () => + project.value.project_type === 'modpack' && + modpackLoaders.value.length === 1 && + modpackLoaders.value[0] === 'minecraft', +) + const description = computed( () => `Download ${project.value.title} ${ diff --git a/packages/api-client/src/modules/labrinth/types.ts b/packages/api-client/src/modules/labrinth/types.ts index 71ce707acb..2426d700d9 100644 --- a/packages/api-client/src/modules/labrinth/types.ts +++ b/packages/api-client/src/modules/labrinth/types.ts @@ -593,6 +593,7 @@ export namespace Labrinth { categories: string[] additional_categories: string[] loaders: string[] + mrpack_loaders: string[] versions: string[] icon_url?: string link_urls: Record diff --git a/packages/ui/src/components/project/ProjectPageVersions.vue b/packages/ui/src/components/project/ProjectPageVersions.vue index 6417f2ed90..30ef621f8d 100644 --- a/packages/ui/src/components/project/ProjectPageVersions.vue +++ b/packages/ui/src/components/project/ProjectPageVersions.vue @@ -3,7 +3,7 @@
-
{{ version.version_number }}
-
{{ version.name }}
+
+ {{ version.version_number }} +
+
+ {{ version.name }} +
@@ -127,7 +132,7 @@ v-for="gameVersion in formatVersionsForDisplay( version.game_versions, gameVersions, - )" + ).slice(0, maxGameVersionTags)" :key="`version-tag-${gameVersion}`" v-tooltip="`Toggle filter for ${gameVersion}`" class="z-[1]" @@ -137,21 +142,61 @@ > {{ gameVersion }} + + + +{{ + formatVersionsForDisplay(version.game_versions, gameVersions).length - + maxGameVersionTags + }} + + +
- - - - + +
@@ -162,7 +207,7 @@ class="z-[1] text-center" > - {{ formatMessage(tag.label) }} + {{ formatMessage(tag.label).replace('and', '&') }}
@@ -233,6 +278,7 @@ import { type GameVersionTag, type Version, } from '@modrinth/utils' +import { Menu } from 'floating-vue' import { computed, type Ref, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' @@ -251,6 +297,11 @@ const formatDateTime = useFormatDateTime({ type VersionWithDisplayUrlEnding = Version & { displayUrlEnding: string environment?: Labrinth.Projects.v3.Environment + mrpack_loaders?: string[] +} + +type DisplayVersion = VersionWithDisplayUrlEnding & { + noModLoader: boolean } const props = withDefaults( @@ -278,6 +329,39 @@ const props = withDefaults( }, ) +function getModpackLoaders(version: VersionWithDisplayUrlEnding): string[] { + if (props.project.project_type !== 'modpack') { + return version.loaders + } + + if (version.mrpack_loaders?.length) { + return version.mrpack_loaders + } + + return version.loaders.filter((loader) => loader !== 'mrpack') +} + +function hasNoModLoader(loaders: string[]): boolean { + return ( + props.project.project_type === 'modpack' && loaders.length === 1 && loaders[0] === 'minecraft' + ) +} + +const normalizedVersions = computed(() => + props.versions.map((version) => { + const loaders = getModpackLoaders(version) + const noModLoader = hasNoModLoader(loaders) + + return { + ...version, + loaders: noModLoader ? [] : loaders, + noModLoader, + } + }), +) + +const maxGameVersionTags = 6 + const currentPage: Ref = ref(1) const pageSize: Ref = ref(20) const versionFilters: Ref | null> = ref(null) @@ -296,7 +380,7 @@ const hasMultipleEnvironments = computed(() => { }) const filteredVersions = computed(() => { - return props.versions.filter( + return normalizedVersions.value.filter( (version) => hasAnySelected(version.game_versions, selectedGameVersions.value) && hasAnySelected(version.loaders, selectedPlatforms.value) && diff --git a/packages/ui/src/components/project/ProjectSidebarCompatibility.vue b/packages/ui/src/components/project/ProjectSidebarCompatibility.vue index 9a544d03f5..ca4396c0ba 100644 --- a/packages/ui/src/components/project/ProjectSidebarCompatibility.vue +++ b/packages/ui/src/components/project/ProjectSidebarCompatibility.vue @@ -15,15 +15,22 @@

{{ formatMessage(messages.platforms) }}

- - - - + +
@@ -85,6 +92,7 @@
diff --git a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue index f8d4052209..bcac05a6a7 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/GeneralSettings.vue @@ -30,19 +30,19 @@ const deleteConfirmModal = ref() const { instance } = injectInstanceSettings() -const title = ref(instance.name) -const icon: Ref = ref(instance.icon_path) -const groups = ref(instance.groups) +const title = ref(instance.value.name) +const icon: Ref = ref(instance.value.icon_path) +const groups = ref([...instance.value.groups]) const newCategoryInput = ref('') -const installing = computed(() => instance.install_stage !== 'installed') +const installing = computed(() => instance.value.install_stage !== 'installed') async function duplicateProfile() { - await duplicate(instance.path).catch(handleError) + await duplicate(instance.value.path).catch(handleError) trackEvent('InstanceDuplicate', { - loader: instance.loader, - game_version: instance.game_version, + loader: instance.value.loader, + game_version: instance.value.game_version, }) } @@ -53,7 +53,7 @@ const availableGroups = computed(() => [ async function resetIcon() { icon.value = undefined - await edit_icon(instance.path, null).catch(handleError) + await edit_icon(instance.value.path, null).catch(handleError) trackEvent('InstanceRemoveIcon') } @@ -71,7 +71,7 @@ async function setIcon() { if (!value) return icon.value = value - await edit_icon(instance.path, icon.value).catch(handleError) + await edit_icon(instance.value.path, icon.value).catch(handleError) trackEvent('InstanceSetIcon') } @@ -102,7 +102,7 @@ watch( [title, groups, groups], async () => { if (removing.value) return - await edit(instance.path, editProfileObject.value).catch(handleError) + await edit(instance.value.path, editProfileObject.value).catch(handleError) }, { deep: true }, ) @@ -110,11 +110,11 @@ watch( const removing = ref(false) async function removeProfile() { removing.value = true - const path = instance.path + const path = instance.value.path trackEvent('InstanceRemove', { - loader: instance.loader, - game_version: instance.game_version, + loader: instance.value.loader, + game_version: instance.value.game_version, }) await router.push({ path: '/' }) diff --git a/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue index 3061ea7fc6..ae576b6fce 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/HooksSettings.vue @@ -22,9 +22,11 @@ const { instance } = injectInstanceSettings() const globalSettings = (await get().catch(handleError)) as AppSettings const overrideHooks = ref( - !!instance.hooks.pre_launch || !!instance.hooks.wrapper || !!instance.hooks.post_exit, + !!instance.value.hooks.pre_launch || + !!instance.value.hooks.wrapper || + !!instance.value.hooks.post_exit, ) -const hooks = ref(instance.hooks ?? globalSettings.hooks) +const hooks = ref(instance.value.hooks ?? globalSettings.hooks) const editProfileObject = computed(() => { const editProfile: { @@ -40,7 +42,7 @@ const editProfileObject = computed(() => { watch( [overrideHooks, hooks], async () => { - await edit(instance.path, editProfileObject.value) + await edit(instance.value.path, editProfileObject.value) }, { deep: true }, ) diff --git a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue index 2aa0201b0b..3ef0be1651 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/InstallationSettings.vue @@ -73,9 +73,9 @@ const [ ]) const { data: modpackInfo } = useQuery({ - queryKey: computed(() => ['linkedModpackInfo', instance.path]), - queryFn: () => get_linked_modpack_info(instance.path, 'must_revalidate'), - enabled: computed(() => !!instance.linked_data?.project_id && !offline), + queryKey: computed(() => ['linkedModpackInfo', instance.value.path]), + queryFn: () => get_linked_modpack_info(instance.value.path, 'must_revalidate'), + enabled: computed(() => !!instance.value.linked_data?.project_id && !offline), }) const repairing = ref(false) @@ -101,13 +101,13 @@ function getManifest(loader: string) { provideAppBackup({ async createBackup() { const allProfiles = await list() - const prefix = `${instance.name} - Backup #` + const prefix = `${instance.value.name} - Backup #` const existingNums = allProfiles .filter((p) => p.name.startsWith(prefix)) .map((p) => parseInt(p.name.slice(prefix.length), 10)) .filter((n) => !isNaN(n)) const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1 - const newPath = await duplicate(instance.path) + const newPath = await duplicate(instance.value.path) await edit(newPath, { name: `${prefix}${nextNum}` }) }, }) @@ -118,27 +118,30 @@ provideInstallationSettings({ const rows = [ { label: formatMessage(commonMessages.platformLabel), - value: formatLoaderLabel(instance.loader), + value: formatLoaderLabel(instance.value.loader), }, { label: formatMessage(commonMessages.gameVersionLabel), - value: instance.game_version, + value: instance.value.game_version, }, ] - if (instance.loader !== 'vanilla' && instance.loader_version) { + if (instance.value.loader !== 'vanilla' && instance.value.loader_version) { rows.push({ label: formatMessage(messages.loaderVersion, { - loader: formatLoaderLabel(instance.loader), + loader: formatLoaderLabel(instance.value.loader), }), - value: instance.loader_version, + value: instance.value.loader_version, }) } return rows }), - isLinked: computed(() => !!instance.linked_data?.locked), + isLinked: computed(() => !!instance.value.linked_data?.locked), isBusy: computed( () => - instance.install_stage !== 'installed' || repairing.value || reinstalling.value || !!offline, + instance.value.install_stage !== 'installed' || + repairing.value || + reinstalling.value || + !!offline, ), modpack: computed(() => { if (!modpackInfo.value) return null @@ -149,9 +152,9 @@ provideInstallationSettings({ versionNumber: modpackInfo.value.version?.version_number, } }), - currentPlatform: computed(() => instance.loader), - currentGameVersion: computed(() => instance.game_version), - currentLoaderVersion: computed(() => instance.loader_version ?? ''), + currentPlatform: computed(() => instance.value.loader), + currentGameVersion: computed(() => instance.value.game_version), + currentLoaderVersion: computed(() => instance.value.loader_version ?? ''), availablePlatforms: loaders?.value?.map((x) => x.name) ?? [], resolveGameVersions(loader, showSnapshots) { @@ -194,50 +197,50 @@ provideInstallationSettings({ if (platform !== 'vanilla' && loaderVersionId) { editProfile.loader_version = loaderVersionId } - await edit(instance.path, editProfile).catch(handleError) + await edit(instance.value.path, editProfile).catch(handleError) }, afterSave: async () => { - await install(instance.path, false).catch(handleError) + await install(instance.value.path, false).catch(handleError) trackEvent('InstanceRepair', { - loader: instance.loader, - game_version: instance.game_version, + loader: instance.value.loader, + game_version: instance.value.game_version, }) }, async repair() { repairing.value = true - await install(instance.path, true).catch(handleError) + await install(instance.value.path, true).catch(handleError) repairing.value = false trackEvent('InstanceRepair', { - loader: instance.loader, - game_version: instance.game_version, + loader: instance.value.loader, + game_version: instance.value.game_version, }) }, async reinstallModpack() { reinstalling.value = true - await update_repair_modrinth(instance.path).catch(handleError) + await update_repair_modrinth(instance.value.path).catch(handleError) reinstalling.value = false trackEvent('InstanceRepair', { - loader: instance.loader, - game_version: instance.game_version, + loader: instance.value.loader, + game_version: instance.value.game_version, }) }, async unlinkModpack() { - await edit(instance.path, { + await edit(instance.value.path, { linked_data: null as unknown as undefined, }) await queryClient.invalidateQueries({ - queryKey: ['linkedModpackInfo', instance.path], + queryKey: ['linkedModpackInfo', instance.value.path], }) onUnlinked() }, getCachedModpackVersions: () => null, async fetchModpackVersions() { - const versions = await get_project_versions(instance.linked_data!.project_id!).catch( + const versions = await get_project_versions(instance.value.linked_data!.project_id!).catch( handleError, ) return (versions ?? []) as Labrinth.Versions.v2.Version[] @@ -250,20 +253,20 @@ provideInstallationSettings({ }, async onModpackVersionConfirm(version) { - await update_managed_modrinth_version(instance.path, version.id) + await update_managed_modrinth_version(instance.value.path, version.id) await queryClient.invalidateQueries({ - queryKey: ['linkedModpackInfo', instance.path], + queryKey: ['linkedModpackInfo', instance.value.path], }) }, updaterModalProps: computed(() => ({ isApp: true, currentVersionId: - modpackInfo.value?.update_version_id ?? instance.linked_data?.version_id ?? '', + modpackInfo.value?.update_version_id ?? instance.value.linked_data?.version_id ?? '', projectIconUrl: modpackInfo.value?.project?.icon_url, projectName: modpackInfo.value?.project?.title ?? 'Modpack', - currentGameVersion: instance.game_version, - currentLoader: instance.loader, + currentGameVersion: instance.value.game_version, + currentLoader: instance.value.loader, })), isServer: false, diff --git a/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue index 39efa5fb1f..0649621d1b 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/JavaSettings.vue @@ -25,20 +25,24 @@ const { instance } = injectInstanceSettings() const globalSettings = (await get().catch(handleError)) as unknown as AppSettings -const overrideJavaInstall = ref(!!instance.java_path) -const optimalJava = readonly(await get_optimal_jre_key(instance.path).catch(handleError)) -const javaInstall = ref({ path: optimalJava.path ?? instance.java_path }) +const overrideJavaInstall = ref(!!instance.value.java_path) +const optimalJava = readonly(await get_optimal_jre_key(instance.value.path).catch(handleError)) +const javaInstall = ref({ path: optimalJava.path ?? instance.value.java_path }) -const overrideJavaArgs = ref((instance.extra_launch_args?.length ?? 0) > 0) -const javaArgs = ref((instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' ')) - -const overrideEnvVars = ref((instance.custom_env_vars?.length ?? 0) > 0) -const envVars = ref( - (instance.custom_env_vars ?? globalSettings.custom_env_vars).map((x) => x.join('=')).join(' '), +const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0) +const javaArgs = ref( + (instance.value.extra_launch_args ?? globalSettings.extra_launch_args).join(' '), ) -const overrideMemorySettings = ref(!!instance.memory) -const memory = ref(instance.memory ?? globalSettings.memory) +const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0) +const envVars = ref( + (instance.value.custom_env_vars ?? globalSettings.custom_env_vars) + .map((x) => x.join('=')) + .join(' '), +) + +const overrideMemorySettings = ref(!!instance.value.memory) +const memory = ref(instance.value.memory ?? globalSettings.memory) const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as { maxMemory: number snapPoints: number[] @@ -76,7 +80,7 @@ watch( memory, ], async () => { - await edit(instance.path, editProfileObject.value) + await edit(instance.value.path, editProfileObject.value) }, { deep: true }, ) diff --git a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue index 342c820ccc..f5ba69568a 100644 --- a/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue +++ b/apps/app-frontend/src/components/ui/instance_settings/WindowSettings.vue @@ -22,12 +22,14 @@ const { instance } = injectInstanceSettings() const globalSettings = (await get().catch(handleError)) as AppSettings -const overrideWindowSettings = ref(!!instance.game_resolution || !!instance.force_fullscreen) +const overrideWindowSettings = ref( + !!instance.value.game_resolution || !!instance.value.force_fullscreen, +) const resolution: Ref<[number, number]> = ref( - instance.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]), + instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]), ) const fullscreenSetting: Ref = ref( - instance.force_fullscreen ?? globalSettings.force_fullscreen, + instance.value.force_fullscreen ?? globalSettings.force_fullscreen, ) const editProfileObject = computed(() => { @@ -46,7 +48,7 @@ const editProfileObject = computed(() => { watch( [overrideWindowSettings, resolution, fullscreenSetting], async () => { - await edit(instance.path, editProfileObject.value) + await edit(instance.value.path, editProfileObject.value) }, { deep: true }, ) diff --git a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue index b866f2e02e..76532a30c5 100644 --- a/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue +++ b/apps/app-frontend/src/components/ui/modal/InstanceSettingsModal.vue @@ -44,8 +44,10 @@ const emit = defineEmits<{ const isMinecraftServer = ref(false) const handleUnlinked = () => emit('unlinked') +const instanceRef = computed(() => props.instance) + provideInstanceSettings({ - instance: props.instance, + instance: instanceRef, offline: props.offline, isMinecraftServer, onUnlinked: handleUnlinked, diff --git a/apps/app-frontend/src/components/ui/modal/ModpackAlreadyInstalledModal.vue b/apps/app-frontend/src/components/ui/modal/ModpackAlreadyInstalledModal.vue index 6b16e80229..daf238793f 100644 --- a/apps/app-frontend/src/components/ui/modal/ModpackAlreadyInstalledModal.vue +++ b/apps/app-frontend/src/components/ui/modal/ModpackAlreadyInstalledModal.vue @@ -1,21 +1,31 @@