fix: use localstorage for sync state during install (#6057)

* fix: use localstorage for sync state during install

* fix: lint
This commit is contained in:
Calum H.
2026-05-09 22:02:42 +01:00
committed by GitHub
parent 07f9e3aedc
commit c7602602e5
9 changed files with 1262 additions and 757 deletions

View File

@@ -5,14 +5,17 @@ import {
type BrowseSelectedProject,
createContext,
type CreationFlowContextValue,
flushInstallQueue,
flushStoredServerAddonInstallQueue,
getStoredServerAddonInstallQueue,
injectModrinthClient,
injectNotificationManager,
type PendingServerContentInstall,
type PendingServerContentInstallType,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -89,37 +92,6 @@ function readQueryString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function getQueueStorageKey(serverId: string | null, worldId: string | null) {
if (!serverId || !worldId) return null
return `server-install-queue:${serverId}:${worldId}`
}
function readStoredQueue(serverId: string | null, worldId: string | null) {
const key = getQueueStorageKey(serverId, worldId)
if (!key) return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
try {
const raw = localStorage.getItem(key)
if (!raw) return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
return new Map<string, BrowseInstallPlan<InstallableSearchResult>>(JSON.parse(raw))
} catch {
return new Map<string, BrowseInstallPlan<InstallableSearchResult>>()
}
}
function writeStoredQueue(
serverId: string | null,
worldId: string | null,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
const key = getQueueStorageKey(serverId, worldId)
if (!key) return
if (plans.size === 0) {
localStorage.removeItem(key)
return
}
localStorage.setItem(key, JSON.stringify(Array.from(plans.entries())))
}
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
@@ -235,7 +207,7 @@ export function createServerInstallContent(opts: {
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const { addNotification, handleError } = injectNotificationManager()
const { handleError } = injectNotificationManager()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
@@ -340,7 +312,7 @@ export function createServerInstallContent(opts: {
}
if (resolvedWorldId) {
queuedServerInstalls.value = readStoredQueue(sid, resolvedWorldId)
queuedServerInstalls.value = readStoredServerInstallQueue(sid, resolvedWorldId)
await refreshServerInstalledContent(sid, resolvedWorldId)
}
}
@@ -358,7 +330,7 @@ export function createServerInstallContent(opts: {
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredQueue(sid, wid)
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
@@ -367,7 +339,7 @@ export function createServerInstallContent(opts: {
}
if (wid !== prevWid) {
queuedServerInstalls.value = readStoredQueue(sid, wid)
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
}
if (wid && (sid !== prevSid || wid !== prevWid)) {
@@ -432,11 +404,21 @@ export function createServerInstallContent(opts: {
setQueuedServerInstallPlans(nextPlans)
}
function setStoredServerInstallPlans(
serverId: string,
worldId: string,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
if (serverId === serverIdQuery.value && worldId === effectiveServerWorldId.value) {
queuedServerInstalls.value = plans
}
writeStoredServerInstallQueue(serverId, worldId, plans)
}
async function flushQueuedServerInstalls(
serverId: string | null = serverIdQuery.value,
worldId: string | null = effectiveServerWorldId.value,
) {
if (queuedServerInstalls.value.size === 0) return true
if (isInstallingQueuedServerInstalls.value) return false
if (!serverId || !worldId) {
@@ -444,50 +426,53 @@ export function createServerInstallContent(opts: {
return false
}
const installedProjectIds = new Set<string>()
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(serverId, worldId)
if (queuedPlans.size === 0) return true
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
total: queuedServerInstalls.value.size,
total: queuedPlans.size,
}
try {
const result = await flushInstallQueue({
queue: {
get: () => queuedServerInstalls.value,
set: (plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>) => {
queuedServerInstalls.value = plans
writeStoredQueue(serverId, worldId, plans)
},
},
install: async (plan) => {
await client.archon.content_v1.addAddon(serverId, worldId, {
project_id: plan.projectId,
version_id: plan.versionId,
})
installedProjectIds.add(plan.projectId)
},
onError: (error, plan) => {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
handleError(error as Error)
},
onProgress: (completed, total) => {
queuedInstallProgress.value = { completed, total }
},
const result = await flushStoredServerAddonInstallQueue({
serverId,
worldId,
install: (plans) =>
client.archon.content_v1.addAddons(
serverId,
worldId,
plans.map((plan) => ({
project_id: plan.projectId,
version_id: plan.versionId,
})),
),
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
})
if (installedProjectIds.size > 0) {
serverContentProjectIds.value = new Set([
...serverContentProjectIds.value,
...installedProjectIds,
])
serverContentInstallKeys.value = new Set([
...serverContentInstallKeys.value,
...installedProjectIds,
])
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
return result.ok
queuedInstallProgress.value = {
completed: result.flushedPlans.length,
total: result.flushedPlans.length,
}
serverContentProjectIds.value = new Set([
...serverContentProjectIds.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
serverContentInstallKeys.value = new Set([
...serverContentInstallKeys.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
return true
} finally {
isInstallingQueuedServerInstalls.value = false
queuedInstallProgress.value = { completed: 0, total: 0 }
@@ -522,17 +507,7 @@ export function createServerInstallContent(opts: {
.catch((err) => handleError(err as Error))
}
await router.push(backUrl)
const ok = await flushQueuedServerInstalls(sid, wid)
if (!ok) {
queuedServerInstalls.value = new Map()
writeStoredQueue(sid, wid, new Map())
addNotification({
type: 'error',
title: 'Some projects failed to install',
text: 'Failed projects were not added. You can try installing them again.',
})
}
void flushQueuedServerInstalls(sid, wid)
return true
}
@@ -545,7 +520,7 @@ export function createServerInstallContent(opts: {
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
queuedServerInstalls.value = plans
writeStoredQueue(serverIdQuery.value, effectiveServerWorldId.value, plans)
writeStoredServerInstallQueue(serverIdQuery.value, effectiveServerWorldId.value, plans)
}
function onServerFlowBack() {

View File

@@ -0,0 +1,746 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import type {
BrowseInstallContentType,
BrowseInstallPlan,
BrowseSearchState,
CreationFlowContextValue,
FilterValue,
PendingServerContentInstall,
PendingServerContentInstallType,
} from '@modrinth/ui'
import {
addPendingServerContentInstalls,
commonMessages,
defineMessages,
flushStoredServerAddonInstallQueue,
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
useVIntl,
writePendingServerContentInstallBaseline,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import type { ComputedRef, Ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { navigateTo, useRoute } from '#app'
import { queryAsString } from '~/utils/router'
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
type ServerInstallBrowseSearchState = Pick<
BrowseSearchState,
'currentFilters' | 'overriddenProvidedFilterTypes'
>
type ServerInstallProjectType = {
id?: string
}
type ServerInstallDebug = (...args: unknown[]) => void
export interface ServerInstallModalHandle {
show: () => void | Promise<void>
hide: () => void
ctx?: CreationFlowContextValue | null
}
export interface ServerInstallSearchResult extends Labrinth.Search.v2.ResultSearchProject {
installed?: boolean
}
export interface UseServerInstallContentOptions {
projectType: ComputedRef<ServerInstallProjectType | undefined>
onboardingModalRef: Ref<ServerInstallModalHandle | null>
debug?: ServerInstallDebug
}
const messages = defineMessages({
unsupportedContentType: {
id: 'discover.install.error.unsupported-content-type',
defaultMessage: 'This content type cannot be installed to a server from browse.',
},
noServerWorld: {
id: 'discover.install.error.no-server-world',
defaultMessage: 'No server world is available for install.',
},
backToSetup: {
id: 'discover.install.back-to-setup',
defaultMessage: 'Back to setup',
},
cancelReset: {
id: 'discover.install.cancel-reset',
defaultMessage: 'Cancel reset',
},
backToServer: {
id: 'discover.install.back-to-server',
defaultMessage: 'Back to server',
},
resetModpackHeading: {
id: 'discover.install.heading.reset-modpack',
defaultMessage: 'Selecting modpack to install after reset',
},
})
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${ownerId}`,
}
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
export function useServerInstallContent({
projectType,
onboardingModalRef,
debug = () => {},
}: UseServerInstallContentOptions) {
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const route = useRoute()
const { handleError } = injectNotificationManager()
let browseSearchState: ServerInstallBrowseSearchState | null = null
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
const {
data: serverData,
isLoading: serverDataLoading,
error: serverDataError,
} = useQuery({
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
queryFn: () => {
debug('serverData queryFn firing for:', currentServerId.value)
return client.archon.servers_v0.get(currentServerId.value!)
},
enabled: computed(() => {
const enabled = !!currentServerId.value
debug('serverData enabled:', enabled)
return enabled
}),
})
watch(serverData, (val) =>
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
)
watch(serverDataLoading, (val) => debug('serverData loading:', val))
watch(serverDataError, (val) => {
if (val) debug('serverData error:', val)
})
const serverIcon = computed(() => {
if (!currentServerId.value || !import.meta.client) return null
return localStorage.getItem(`server-icon-${currentServerId.value}`)
})
const serverHideInstalled = ref(false)
const hideSelectedServerInstalls = ref(false)
const installingProjectIds = ref<Set<string>>(new Set())
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIdsInitialized = ref(false)
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<ServerInstallSearchResult>>>(
readStoredServerInstallQueue(currentServerId.value, currentWorldId.value),
)
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
id: plan.projectId,
name: plan.project.title ?? formatMessage(commonMessages.projectLabel),
iconUrl: plan.project.icon_url ?? null,
})),
)
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const serverInstallQueue = {
get: () => queuedServerInstalls.value,
set: (plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>) => {
queuedServerInstalls.value = plans
writeStoredServerInstallQueue(currentServerId.value, currentWorldId.value, plans)
},
}
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
const { data: serverContentData, error: serverContentError } = useQuery({
queryKey: contentQueryKey,
queryFn: () =>
client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
})
function setBrowseSearchState(state: ServerInstallBrowseSearchState) {
browseSearchState = state
}
function setStoredServerInstallPlans(
serverId: string,
worldId: string,
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
if (serverId === currentServerId.value && worldId === currentWorldId.value) {
queuedServerInstalls.value = plans
}
writeStoredServerInstallQueue(serverId, worldId, plans)
}
async function getQueuedInstallOwner(project: ServerInstallSearchResult) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: plan.project.title ?? formatMessage(commonMessages.projectLabel),
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: plan.project.slug ?? plan.projectId,
iconUrl: plan.project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
),
)
}
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
next.add(projectId)
} else {
next.delete(projectId)
}
installingProjectIds.value = next
}
function markProjectInstalled(projectId: string) {
optimisticallyInstalledProjectIds.value = new Set([
...optimisticallyInstalledProjectIds.value,
projectId,
])
}
function getServerInstalledProjectIds(data = serverContentData.value) {
return new Set(
(data?.addons ?? [])
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
}
function getServerInstalledContentKeys(data = serverContentData.value) {
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
}
function syncHiddenInstalledProjectIds() {
hiddenInstalledProjectIds.value = new Set([
...getServerInstalledProjectIds(),
...optimisticallyInstalledProjectIds.value,
])
hiddenInstalledProjectIdsInitialized.value = true
}
const serverFilters = computed<FilterValue[]>(() => {
debug(
'serverFilters recomputing, serverData:',
!!serverData.value,
'projectType:',
projectType.value?.id,
)
const filters: FilterValue[] = []
if (serverData.value && projectType.value?.id !== 'modpack') {
const gameVersion = serverData.value.mc_version
if (gameVersion) {
filters.push({ type: 'game_version', option: gameVersion })
}
const platform = serverData.value.loader?.toLowerCase()
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
if (platform && modLoaders.includes(platform)) {
filters.push({ type: 'mod_loader', option: platform })
}
const pluginLoaders = ['paper', 'purpur']
if (platform && pluginLoaders.includes(platform)) {
filters.push({ type: 'plugin_loader', option: platform })
}
if (projectType.value?.id === 'mod') {
filters.push({ type: 'environment', option: 'server' })
}
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
for (const x of hiddenInstalledProjectIds.value) {
filters.push({
type: 'project_id',
option: `project_id:${x}`,
negative: true,
})
}
}
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
for (const id of queuedServerInstallProjectIds.value) {
filters.push({
type: 'project_id',
option: `project_id:${id}`,
negative: true,
})
}
}
}
if (currentServerId.value && projectType.value?.id === 'modpack') {
filters.push(
{ type: 'environment', option: 'client' },
{ type: 'environment', option: 'server' },
)
}
debug('serverFilters result:', filters)
return filters
})
function getCurrentServerInstallType(): BrowseInstallContentType {
const type = projectType.value?.id
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
return type
}
throw new Error(formatMessage(messages.unsupportedContentType))
}
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
return getTargetInstallPreferences(
{
gameVersion: serverData.value?.mc_version,
loader: serverData.value?.loader,
},
contentType,
)
}
function getInstallProjectVersions(projectId: string) {
return client.labrinth.versions_v2.getProjectVersions(projectId, {
include_changelog: false,
})
}
function clearQueuedServerInstalls() {
serverInstallQueue.set(new Map())
}
function removeQueuedServerInstall(projectId: string) {
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.delete(projectId)
serverInstallQueue.set(nextPlans)
}
async function flushQueuedServerInstalls(
serverId: string | null = currentServerId.value,
worldId: string | null = currentWorldId.value,
) {
if (isInstallingQueuedServerInstalls.value) return false
if (!serverId || !worldId) {
handleError(new Error(formatMessage(messages.noServerWorld)))
return false
}
const queuedPlans = getStoredServerAddonInstallQueue<ServerInstallSearchResult>(
serverId,
worldId,
)
if (queuedPlans.size === 0) return true
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
total: queuedPlans.size,
}
try {
const result = await flushStoredServerAddonInstallQueue({
serverId,
worldId,
install: (plans) =>
client.archon.content_v1.addAddons(
serverId,
worldId,
plans.map((plan) => ({
project_id: plan.projectId,
version_id: plan.versionId,
})),
),
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
for (const plan of result.flushedPlans) {
markProjectInstalled(plan.projectId)
}
queuedInstallProgress.value = {
completed: result.flushedPlans.length,
total: result.flushedPlans.length,
}
if (result.flushedPlans.length > 0) {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
])
}
return true
} finally {
isInstallingQueuedServerInstalls.value = false
queuedInstallProgress.value = { completed: 0, total: 0 }
}
}
async function discardQueuedServerInstallsAndBack() {
clearQueuedServerInstalls()
await navigateTo(serverBackUrl.value)
}
async function installQueuedServerInstallsAndBack() {
const sid = currentServerId.value
const wid = currentWorldId.value
const backUrl = serverBackUrl.value
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, [
...getServerInstalledContentKeys(),
...optimisticallyInstalledProjectIds.value,
])
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
await navigateTo(backUrl)
void flushQueuedServerInstalls(sid, wid)
return true
}
async function serverInstall(project: ServerInstallSearchResult) {
if (!serverData.value || !currentServerId.value || !currentWorldId.value) {
handleError(new Error('No server to install to.'))
return
}
if (!browseSearchState) {
handleError(new Error('Search state is not ready.'))
return
}
const contentType = getCurrentServerInstallType()
const isModpack = contentType === 'modpack'
try {
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
removeQueuedServerInstall(project.project_id)
return
}
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
setProjectInstalling(project.project_id, true)
}
await requestInstall({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
providedFilters: isModpack ? [] : serverFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: browseSearchState.overriddenProvidedFilterTypes.value,
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
install: async (plan) => {
const modalInstance = onboardingModalRef.value
if (!modalInstance) {
setProjectInstalling(plan.projectId, false)
return
}
onboardingInstallingProject.value = plan.project
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
if (!ctx) return
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: plan.projectId,
versionId: plan.versionId,
name: plan.project.title,
iconUrl: plan.project.icon_url ?? undefined,
}
ctx.modal.value?.setStage('final-config')
},
})
} catch (e) {
console.error(e)
if (isModpack) {
setProjectInstalling(project.project_id, false)
}
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
} finally {
if (!isModpack) {
setProjectInstalling(project.project_id, false)
}
}
}
const onboardingInstallingProject = ref<ServerInstallSearchResult | null>(null)
function onOnboardingHide() {
if (onboardingInstallingProject.value) {
setProjectInstalling(onboardingInstallingProject.value.project_id, false)
onboardingInstallingProject.value = null
}
}
function onOnboardingBack() {
onboardingModalRef.value?.hide()
}
async function onModpackFlowCreate(config: CreationFlowContextValue) {
if (!currentServerId.value || !currentWorldId.value || !config.modpackSelection.value) return
try {
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties: config.buildProperties(),
} satisfies Archon.Content.v1.InstallWorldContent)
if (fromContext.value === 'onboarding') {
await client.archon.servers_v1.endIntro(currentServerId.value)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
} else {
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
}
} catch (e) {
handleError(new Error(`Error installing modpack: ${e}`))
config.loading.value = false
}
}
const serverBackUrl = computed(() => {
if (!serverData.value) return ''
const id = serverData.value.server_id
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
if (fromContext.value === 'reset-server')
return `/hosting/manage/${id}?openSettings=installation`
return `/hosting/manage/${id}/content`
})
const serverBackLabel = computed(() => {
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
return formatMessage(messages.backToServer)
})
const serverBrowseHeading = computed(() =>
fromContext.value === 'reset-server'
? formatMessage(messages.resetModpackHeading)
: formatMessage(commonMessages.installingContentLabel),
)
const installContext = computed(() => {
if (!serverData.value) return null
return {
name: serverData.value.name,
loader: serverData.value.loader ?? '',
gameVersion: serverData.value.mc_version ?? '',
serverId: currentServerId.value,
upstream: serverData.value.upstream,
iconSrc: serverIcon.value,
isMedal: serverData.value.is_medal,
backUrl: serverBackUrl.value,
backLabel: serverBackLabel.value,
heading: serverBrowseHeading.value,
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
onBack: flushQueuedServerInstalls,
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
installSelected: installQueuedServerInstallsAndBack,
}
})
watch(serverContentError, (error) => {
if (error) {
console.error('Failed to load server content:', error)
handleError(error)
}
})
watch(
serverContentData,
(data) => {
if (!data) return
if (!hiddenInstalledProjectIdsInitialized.value) {
syncHiddenInstalledProjectIds()
}
},
{ immediate: true },
)
if (route.query.shi && projectType.value?.id !== 'modpack') {
serverHideInstalled.value = route.query.shi === 'true'
}
watch(serverHideInstalled, (hideInstalled) => {
if (hideInstalled) {
syncHiddenInstalledProjectIds()
}
})
watch([currentServerId, currentWorldId], ([serverId, worldId], [prevServerId, prevWorldId]) => {
if (serverId !== prevServerId || worldId !== prevWorldId) {
queuedServerInstalls.value = readStoredServerInstallQueue(serverId, worldId)
}
})
watch(queuedServerInstallCount, (count) => {
if (count === 0) {
hideSelectedServerInstalls.value = false
}
})
return {
currentServerId,
fromContext,
currentWorldId,
serverData,
serverContentData,
serverFilters,
serverHideInstalled,
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
installContext,
setBrowseSearchState,
syncHiddenInstalledProjectIds,
serverInstall,
onOnboardingHide,
onOnboardingBack,
onModpackFlowCreate,
}
}

View File

@@ -1241,12 +1241,6 @@
"discover.install.error.no-server-world": {
"message": "No server world is available for install."
},
"discover.install.error.some-projects-failed.description": {
"message": "Failed projects were not added. You can try installing them again."
},
"discover.install.error.some-projects-failed.title": {
"message": "Some projects failed to install"
},
"discover.install.error.unsupported-content-type": {
"message": "This content type cannot be installed to a server from browse."
},

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import type { Labrinth } from '@modrinth/api-client'
import {
BookmarkIcon,
CheckIcon,
@@ -11,47 +11,37 @@ import {
MoreVerticalIcon,
SpinnerIcon,
} from '@modrinth/assets'
import type {
BrowseInstallContentType,
BrowseInstallPlan,
CardAction,
CreationFlowContextValue,
PendingServerContentInstall,
PendingServerContentInstallType,
} from '@modrinth/ui'
import type { CardAction } from '@modrinth/ui'
import {
addPendingServerContentInstalls,
BrowseInstallHeader,
BrowsePageLayout,
BrowseSidebar,
commonMessages,
CreationFlowModal,
defineMessages,
flushInstallQueue,
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
PROJECT_DEP_MARKER_QUERY,
provideBrowseManager,
readPendingServerContentInstalls,
removePendingServerContentInstall,
requestInstall,
SelectedProjectsFloatingBar,
useBrowseSearch,
useDebugLogger,
useStickyObserver,
useVIntl,
writePendingServerContentInstallBaseline,
} from '@modrinth/ui'
import { cycleValue } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import { projectQueryOptions } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import type {
ServerInstallModalHandle,
ServerInstallSearchResult,
} from '~/composables/use-server-install-content'
import { useServerInstallContent } from '~/composables/use-server-install-content'
import { withLabrinthCanaryHeader } from '~/helpers/canary.ts'
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
@@ -75,8 +65,6 @@ const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
const { addNotification, handleError } = injectNotificationManager()
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
@@ -161,524 +149,33 @@ function cycleSearchDisplayMode() {
)
}
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
const onboardingModalRef = ref<ServerInstallModalHandle | null>(null)
const {
data: serverData,
isLoading: serverDataLoading,
error: serverDataError,
} = useQuery({
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
queryFn: () => {
debug('serverData queryFn firing for:', currentServerId.value)
return client.archon.servers_v0.get(currentServerId.value!)
},
enabled: computed(() => {
const enabled = !!currentServerId.value
debug('serverData enabled:', enabled)
return enabled
}),
})
watch(serverData, (val) =>
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
)
watch(serverDataLoading, (val) => debug('serverData loading:', val))
watch(serverDataError, (val) => {
if (val) debug('serverData error:', val)
})
const serverIcon = computed(() => {
if (!currentServerId.value || !import.meta.client) return null
return localStorage.getItem(`server-icon-${currentServerId.value}`)
})
const serverHideInstalled = ref(false)
const hideSelectedServerInstalls = ref(false)
const installingProjectIds = ref<Set<string>>(new Set())
const optimisticallyInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIds = ref<Set<string>>(new Set())
const hiddenInstalledProjectIdsInitialized = ref(false)
interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject {
installed?: boolean
}
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(new Map())
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
const selectedServerInstallProjects = computed(() =>
Array.from(queuedServerInstalls.value.values()).map((plan) => ({
id: plan.projectId,
name: plan.project.title ?? formatMessage(commonMessages.projectLabel),
iconUrl: plan.project.icon_url ?? null,
})),
)
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const serverInstallQueue = {
get: () => queuedServerInstalls.value,
set: (plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>) => {
queuedServerInstalls.value = plans
},
}
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${ownerId}`,
}
}
async function getQueuedInstallOwner(project: InstallableSearchResult) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<InstallableSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: plan.project.title ?? formatMessage(commonMessages.projectLabel),
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: plan.project.slug ?? plan.projectId,
iconUrl: plan.project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
),
)
}
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
next.add(projectId)
} else {
next.delete(projectId)
}
installingProjectIds.value = next
}
function markProjectInstalled(projectId: string) {
optimisticallyInstalledProjectIds.value = new Set([
...optimisticallyInstalledProjectIds.value,
projectId,
])
}
function getServerInstalledProjectIds(data = serverContentData.value) {
return new Set(
(data?.addons ?? [])
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
}
function getServerInstalledContentKeys(data = serverContentData.value) {
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
}
function syncHiddenInstalledProjectIds() {
hiddenInstalledProjectIds.value = new Set([
...getServerInstalledProjectIds(),
...optimisticallyInstalledProjectIds.value,
])
hiddenInstalledProjectIdsInitialized.value = true
}
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
const { data: serverContentData, error: serverContentError } = useQuery({
queryKey: contentQueryKey,
queryFn: () => client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
})
watch(serverContentError, (error) => {
if (error) {
console.error('Failed to load server content:', error)
handleError(error)
}
})
watch(
currentServerId,
fromContext,
serverData,
serverContentData,
(data) => {
if (!data) return
if (!hiddenInstalledProjectIdsInitialized.value) {
syncHiddenInstalledProjectIds()
}
},
{ immediate: true },
)
const installContentMutation = useMutation({
mutationFn: ({
serverId,
worldId,
projectId,
versionId,
}: {
serverId: string
worldId: string
projectId: string
versionId: string
}) =>
client.archon.content_v1.addAddon(serverId, worldId, {
project_id: projectId,
version_id: versionId,
}),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', variables.serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
},
serverFilters,
serverHideInstalled,
hideSelectedServerInstalls,
installingProjectIds,
optimisticallyInstalledProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
installContext,
setBrowseSearchState,
syncHiddenInstalledProjectIds,
serverInstall,
onOnboardingHide,
onOnboardingBack,
onModpackFlowCreate,
} = useServerInstallContent({
projectType,
onboardingModalRef,
debug,
})
if (route.query.shi && projectType.value?.id !== 'modpack') {
serverHideInstalled.value = route.query.shi === 'true'
}
watch(serverHideInstalled, (hideInstalled) => {
if (hideInstalled) {
syncHiddenInstalledProjectIds()
}
})
const serverFilters = computed(() => {
debug(
'serverFilters recomputing, serverData:',
!!serverData.value,
'projectType:',
projectType.value?.id,
)
const filters = []
if (serverData.value && projectType.value?.id !== 'modpack') {
const gameVersion = serverData.value.mc_version
if (gameVersion) {
filters.push({ type: 'game_version', option: gameVersion })
}
const platform = serverData.value.loader?.toLowerCase()
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
if (platform && modLoaders.includes(platform)) {
filters.push({ type: 'mod_loader', option: platform })
}
const pluginLoaders = ['paper', 'purpur']
if (platform && pluginLoaders.includes(platform)) {
filters.push({ type: 'plugin_loader', option: platform })
}
if (projectType.value?.id === 'mod') {
filters.push({ type: 'environment', option: 'server' })
}
if (serverHideInstalled.value && hiddenInstalledProjectIds.value.size > 0) {
for (const x of hiddenInstalledProjectIds.value) {
filters.push({
type: 'project_id',
option: `project_id:${x}`,
negative: true,
})
}
}
if (hideSelectedServerInstalls.value && queuedServerInstallProjectIds.value.size > 0) {
for (const id of queuedServerInstallProjectIds.value) {
filters.push({
type: 'project_id',
option: `project_id:${id}`,
negative: true,
})
}
}
}
if (currentServerId.value && projectType.value?.id === 'modpack') {
filters.push(
{ type: 'environment', option: 'client' },
{ type: 'environment', option: 'server' },
)
}
debug('serverFilters result:', filters)
return filters
})
function getCurrentServerInstallType(): BrowseInstallContentType {
const type = projectType.value?.id
if (type === 'modpack' || type === 'mod' || type === 'plugin' || type === 'datapack') {
return type
}
throw new Error(formatMessage(messages.unsupportedContentType))
}
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
return getTargetInstallPreferences(
{
gameVersion: serverData.value?.mc_version,
loader: serverData.value?.loader,
},
contentType,
)
}
function getInstallProjectVersions(projectId: string) {
return client.labrinth.versions_v2.getProjectVersions(projectId, {
include_changelog: false,
})
}
function clearQueuedServerInstalls() {
queuedServerInstalls.value = new Map()
}
function removeQueuedServerInstall(projectId: string) {
const nextPlans = new Map(queuedServerInstalls.value)
nextPlans.delete(projectId)
queuedServerInstalls.value = nextPlans
}
watch([currentServerId, currentWorldId], ([serverId, worldId], [prevServerId, prevWorldId]) => {
if (serverId !== prevServerId || worldId !== prevWorldId) {
clearQueuedServerInstalls()
}
})
async function flushQueuedServerInstalls(
serverId: string | null = currentServerId.value,
worldId: string | null = currentWorldId.value,
) {
if (queuedServerInstalls.value.size === 0) return true
if (isInstallingQueuedServerInstalls.value) return false
if (!serverId || !worldId) {
handleError(new Error(formatMessage(messages.noServerWorld)))
return false
}
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
total: queuedServerInstalls.value.size,
}
try {
const result = await flushInstallQueue({
queue: serverInstallQueue,
install: async (plan) => {
await installContentMutation.mutateAsync({
serverId,
worldId,
projectId: plan.projectId,
versionId: plan.versionId,
})
markProjectInstalled(plan.projectId)
},
onError: (error, plan) => {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
handleError(error as Error)
},
onProgress: (completed, total) => {
queuedInstallProgress.value = { completed, total }
},
})
return result.ok
} finally {
isInstallingQueuedServerInstalls.value = false
queuedInstallProgress.value = { completed: 0, total: 0 }
}
}
async function discardQueuedServerInstallsAndBack() {
clearQueuedServerInstalls()
await navigateTo(serverBackUrl.value)
}
async function installQueuedServerInstallsAndBack() {
const sid = currentServerId.value
const wid = currentWorldId.value
const backUrl = serverBackUrl.value
const plans = new Map(queuedServerInstalls.value)
if (sid && wid) {
writePendingServerContentInstallBaseline(sid, wid, [
...getServerInstalledContentKeys(),
...optimisticallyInstalledProjectIds.value,
])
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
await navigateTo(backUrl)
const ok = await flushQueuedServerInstalls(sid, wid)
if (!ok) {
queuedServerInstalls.value = new Map()
addNotification({
type: 'error',
title: formatMessage(messages.someProjectsFailedTitle),
text: formatMessage(messages.someProjectsFailedText),
})
}
return true
}
async function serverInstall(project: InstallableSearchResult) {
if (!serverData.value || !currentServerId.value || !currentWorldId.value) {
handleError(new Error('No server to install to.'))
return
}
const contentType = getCurrentServerInstallType()
const isModpack = contentType === 'modpack'
try {
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
removeQueuedServerInstall(project.project_id)
return
}
if (isModpack || !queuedServerInstallProjectIds.value.has(project.project_id)) {
setProjectInstalling(project.project_id, true)
}
await requestInstall({
project,
contentType,
mode: isModpack ? 'immediate' : 'queue',
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
providedFilters: isModpack ? [] : serverFilters.value,
overriddenProvidedFilterTypes: isModpack
? []
: searchState.overriddenProvidedFilterTypes.value,
targetPreferences: getServerInstallTargetPreferences(contentType),
getProjectVersions: getInstallProjectVersions,
queue: serverInstallQueue,
install: async (plan) => {
const modalInstance = onboardingModalRef.value
if (!modalInstance) {
setProjectInstalling(plan.projectId, false)
return
}
onboardingInstallingProject.value = plan.project
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: plan.projectId,
versionId: plan.versionId,
name: plan.project.title,
iconUrl: plan.project.icon_url ?? undefined,
}
ctx.modal.value?.setStage('final-config')
},
})
} catch (e) {
console.error(e)
if (isModpack) {
setProjectInstalling(project.project_id, false)
}
handleError(e instanceof Error ? e : new Error(`Error installing content ${e}`))
} finally {
if (!isModpack) {
setProjectInstalling(project.project_id, false)
}
}
}
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
@@ -750,7 +247,7 @@ function getCardActions(
): CardAction[] {
if (currentProjectType === 'server') return []
const projectResult = result as InstallableSearchResult
const projectResult = result as ServerInstallSearchResult
if (flags.value.showDiscoverProjectButtons) {
return [
@@ -834,126 +331,7 @@ function getCardActions(
return []
}
const onboardingModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
function onOnboardingHide() {
if (onboardingInstallingProject.value) {
setProjectInstalling(onboardingInstallingProject.value.project_id, false)
onboardingInstallingProject.value = null
}
}
function onOnboardingBack() {
onboardingModalRef.value?.hide()
}
async function onModpackFlowCreate(config: CreationFlowContextValue) {
if (!currentServerId.value || !config.modpackSelection.value) return
try {
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties: config.buildProperties(),
} satisfies Archon.Content.v1.InstallWorldContent)
if (fromContext.value === 'onboarding') {
await client.archon.servers_v1.endIntro(currentServerId.value)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
} else {
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
}
} catch (e) {
handleError(new Error(`Error installing modpack: ${e}`))
config.loading.value = false
}
}
const serverBackUrl = computed(() => {
if (!serverData.value) return ''
const id = serverData.value.server_id
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
if (fromContext.value === 'reset-server') return `/hosting/manage/${id}?openSettings=installation`
return `/hosting/manage/${id}/content`
})
const serverBackLabel = computed(() => {
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
return formatMessage(messages.backToServer)
})
const serverBrowseHeading = computed(() =>
fromContext.value === 'reset-server'
? formatMessage(messages.resetModpackHeading)
: formatMessage(commonMessages.installingContentLabel),
)
const installContext = computed(() => {
if (!serverData.value) return null
return {
name: serverData.value.name,
loader: serverData.value.loader ?? '',
gameVersion: serverData.value.mc_version ?? '',
serverId: currentServerId.value,
upstream: serverData.value.upstream,
iconSrc: serverIcon.value,
isMedal: serverData.value.is_medal,
backUrl: serverBackUrl.value,
backLabel: serverBackLabel.value,
heading: serverBrowseHeading.value,
queuedCount: queuedServerInstallCount.value,
selectedProjects: selectedServerInstallProjects.value,
isInstallingSelected: isInstallingQueuedServerInstalls.value,
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
onBack: flushQueuedServerInstalls,
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
installSelected: installQueuedServerInstallsAndBack,
}
})
const messages = defineMessages({
unsupportedContentType: {
id: 'discover.install.error.unsupported-content-type',
defaultMessage: 'This content type cannot be installed to a server from browse.',
},
noServerWorld: {
id: 'discover.install.error.no-server-world',
defaultMessage: 'No server world is available for install.',
},
someProjectsFailedTitle: {
id: 'discover.install.error.some-projects-failed.title',
defaultMessage: 'Some projects failed to install',
},
someProjectsFailedText: {
id: 'discover.install.error.some-projects-failed.description',
defaultMessage: 'Failed projects were not added. You can try installing them again.',
},
backToSetup: {
id: 'discover.install.back-to-setup',
defaultMessage: 'Back to setup',
},
cancelReset: {
id: 'discover.install.cancel-reset',
defaultMessage: 'Cancel reset',
},
backToServer: {
id: 'discover.install.back-to-server',
defaultMessage: 'Back to server',
},
resetModpackHeading: {
id: 'discover.install.heading.reset-modpack',
defaultMessage: 'Selecting modpack to install after reset',
},
gameVersionProvidedByServer: {
id: 'search.filter.locked.server-game-version.title',
defaultMessage: 'Game version is provided by the server',
@@ -1009,12 +387,7 @@ const searchState = useBrowseSearch({
maxResultsOptions: currentMaxResultsOptions,
displayMode: resultsDisplayMode,
})
watch(queuedServerInstallCount, (count) => {
if (count === 0) {
hideSelectedServerInstalls.value = false
}
})
setBrowseSearchState(searchState)
watch(
() =>