feat: implement improved flow for server panel edit installation (#5711)

* feat: implement improved flow for server panel edit installation

* feat: installation form finalized

* feat: error state for InstallingBanner

* feat: action button refactor + save banner text fix

* fix: lint

* fix: content card alignment

* feat: better copy

* fix: lint

* fix: hide shift click + fix NeoForge chip

* fix: lint
This commit is contained in:
Calum H.
2026-04-01 19:53:19 +02:00
committed by GitHub
parent c52abece44
commit fa4711ff7b
19 changed files with 785 additions and 168 deletions

View File

@@ -4,19 +4,19 @@
<div class="flex flex-col gap-4 md:w-[400px]"> <div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0"> <p class="m-0">
Are you sure you want to Are you sure you want to
<span class="lowercase">{{ confirmActionText }}</span> the server? <span class="lowercase">{{ pendingAction }}</span> the server?
</p> </p>
<Checkbox <Checkbox
v-model="dontAskAgain" v-model="dontAskAgain"
label="Don't ask me again" label="Don't ask me again"
class="text-sm" class="text-sm"
:disabled="!powerAction" :disabled="!pendingAction"
/> />
<div class="flex flex-row gap-4"> <div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="executePowerAction"> <ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button> <button>
<CheckIcon class="h-5 w-5" /> <CheckIcon class="h-5 w-5" />
{{ confirmActionText }} server {{ pendingAction }} server
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled @click="resetPowerAction"> <ButtonStyled @click="resetPowerAction">
@@ -31,11 +31,11 @@
<NewModal <NewModal
ref="detailsModal" ref="detailsModal"
:header="`All of ${serverName || 'Server'} info`" :header="`All of ${server.name || 'Server'} info`"
@close="closeDetailsModal" @close="detailsModal?.hide()"
> >
<ServerInfoLabels <ServerInfoLabels
:server-data="serverData" :server-data="server"
:show-game-label="true" :show-game-label="true"
:show-loader-label="true" :show-loader-label="true"
:uptime-seconds="uptimeSeconds" :uptime-seconds="uptimeSeconds"
@@ -43,9 +43,9 @@
class="mb-6 flex flex-col gap-2" class="mb-6 flex flex-col gap-2"
/> />
<div v-if="flags.advancedDebugInfo" class="markdown-body"> <div v-if="flags.advancedDebugInfo" class="markdown-body">
<pre>{{ serverData }}</pre> <pre>{{ server }}</pre>
</div> </div>
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal"> <ButtonStyled type="standard" color="brand" @click="detailsModal?.hide()">
<button class="w-full">Close</button> <button class="w-full">Close</button>
</ButtonStyled> </ButtonStyled>
</NewModal> </NewModal>
@@ -62,14 +62,14 @@
<button :disabled="!canTakeAction" @click="initiateAction('Stop')"> <button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<div class="flex gap-1"> <div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" /> <StopCircleIcon class="h-5 w-5" />
<span>{{ isStoppingState ? 'Stopping...' : 'Stop' }}</span> <span>{{ isStopping ? 'Stopping...' : 'Stop' }}</span>
</div> </div>
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled type="standard" color="brand" size="large"> <ButtonStyled type="standard" color="brand" size="large">
<button v-tooltip="busyReason" :disabled="!canTakeAction" @click="handlePrimaryAction"> <button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center"> <div v-if="isTransitioning" class="grid place-content-center">
<LoadingIcon /> <LoadingIcon />
</div> </div>
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else /> <component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
@@ -116,8 +116,16 @@ import {
UpdatedIcon, UpdatedIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels } from '@modrinth/ui' import {
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils' ButtonStyled,
Checkbox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
ServerInfoLabels,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core' import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -126,70 +134,60 @@ import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue' import PanelSpinner from './PanelSpinner.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue' import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const flags = useFeatureFlags() type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
interface PowerAction {
action: ServerPowerAction
nextState: ServerState
}
const props = defineProps<{ const props = defineProps<{
isOnline: boolean disabled?: boolean
isActioning: boolean
isInstalling: boolean
disabled: boolean
serverName?: string
serverData: object
uptimeSeconds: number uptimeSeconds: number
busyReason?: string
}>()
const emit = defineEmits<{
(e: 'action', action: ServerPowerAction): void
}>() }>()
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const router = useRouter() const router = useRouter()
const serverId = router.currentRoute.value.params.id const client = injectModrinthClient()
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null) const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null)
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null) const detailsModal = ref<InstanceType<typeof NewModal> | null>(null)
const pendingAction = ref<PowerAction | null>(null)
const dontAskAgain = ref(false)
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, { const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false, powerDontAskAgain: false,
}) })
const serverState = ref<ServerState>(props.isOnline ? 'running' : 'stopped') const isInstalling = computed(() => server.value.status === 'installing')
const powerAction = ref<PowerAction | null>(null) const isRunning = computed(() => powerState.value === 'running')
const dontAskAgain = ref(false) const isStopping = computed(() => powerState.value === 'stopping')
const startingDelay = ref(false) const isTransitioning = computed(
() => powerState.value === 'starting' || powerState.value === 'stopping',
)
const showStopButton = computed(() => isRunning.value || isStopping.value)
const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
)
const canTakeAction = computed( const canTakeAction = computed(
() => !props.isActioning && !startingDelay.value && !isTransitionState.value && !props.busyReason, () => !isTransitioning.value && !props.disabled && busyReasons.value.length === 0,
) )
const isRunning = computed(() => serverState.value === 'running')
const isTransitionState = computed(() =>
['starting', 'stopping', 'restarting'].includes(serverState.value),
)
const isStoppingState = computed(() => serverState.value === 'stopping')
const showStopButton = computed(() => isRunning.value || isStoppingState.value)
const primaryActionText = computed(() => { const primaryActionText = computed(() => {
const states: Partial<Record<ServerState, string>> = { switch (powerState.value) {
starting: 'Starting...', case 'starting':
restarting: 'Restarting...', return 'Starting...'
running: 'Restart', case 'stopping':
stopping: 'Stopping...', return 'Stopping...'
stopped: 'Start', case 'running':
return 'Restart'
default:
return 'Start'
} }
return states[serverState.value]
})
const confirmActionText = computed(() => {
if (!powerAction.value) return ''
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1)
}) })
const menuOptions = computed(() => [ const menuOptions = computed(() => [
...(props.isInstalling ...(isInstalling.value
? [] ? []
: [ : [
{ {
@@ -221,28 +219,31 @@ const menuOptions = computed(() => [
]) ])
async function copyId() { async function copyId() {
await navigator.clipboard.writeText(serverId as string) await navigator.clipboard.writeText(serverId)
} }
function initiateAction(action: ServerPowerAction) { async function sendPowerAction(action: PowerAction) {
try {
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error performing ${action} on server:`, error)
addNotification({
type: 'error',
title: `Failed to ${action.toLowerCase()} server`,
text: 'An error occurred while performing this action.',
})
}
}
function initiateAction(action: PowerAction) {
if (!canTakeAction.value) return if (!canTakeAction.value) return
const stateMap: Record<ServerPowerAction, ServerState> = {
Start: 'starting',
Stop: 'stopping',
Restart: 'restarting',
Kill: 'stopping',
}
if (action === 'Start') { if (action === 'Start') {
emit('action', action) sendPowerAction(action)
serverState.value = stateMap[action]
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
return return
} }
powerAction.value = { action, nextState: stateMap[action] } pendingAction.value = action
if (userPreferences.value.powerDontAskAgain) { if (userPreferences.value.powerDontAskAgain) {
executePowerAction() executePowerAction()
@@ -256,41 +257,20 @@ function handlePrimaryAction() {
} }
function executePowerAction() { function executePowerAction() {
if (!powerAction.value) return if (!pendingAction.value) return
const { action, nextState } = powerAction.value sendPowerAction(pendingAction.value)
emit('action', action)
serverState.value = nextState
if (dontAskAgain.value) { if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true userPreferences.value.powerDontAskAgain = true
} }
if (action === 'Start') {
startingDelay.value = true
setTimeout(() => (startingDelay.value = false), 5000)
}
resetPowerAction() resetPowerAction()
} }
function resetPowerAction() { function resetPowerAction() {
confirmActionModal.value?.hide() confirmActionModal.value?.hide()
powerAction.value = null pendingAction.value = null
dontAskAgain.value = false dontAskAgain.value = false
} }
function closeDetailsModal() {
detailsModal.value?.hide()
}
watch(
() => props.isOnline,
(online) => (serverState.value = online ? 'running' : 'stopped'),
)
watch(
() => router.currentRoute.value.fullPath,
() => closeDetailsModal(),
)
</script> </script>

View File

@@ -18,8 +18,8 @@
</button> </button>
</ButtonStyled> </ButtonStyled>
<ButtonStyled v-if="props.restart" type="standard" color="brand"> <ButtonStyled v-if="props.restart" type="standard" color="brand">
<button :disabled="props.isUpdating" @click="saveAndRestart"> <button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
{{ props.isUpdating ? 'Saving...' : 'Save & restart' }} {{ powerButtonLabel }}
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
@@ -30,7 +30,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui' import { ButtonStyled, injectModrinthClient, injectModrinthServerContext } from '@modrinth/ui'
import { computed } from 'vue'
const props = defineProps<{ const props = defineProps<{
isUpdating: boolean isUpdating: boolean
@@ -42,10 +43,23 @@ const props = defineProps<{
}>() }>()
const client = injectModrinthClient() const client = injectModrinthClient()
const { powerState } = injectModrinthServerContext()
const saveAndRestart = async () => { const isStopped = computed(() => powerState.value === 'stopped' || powerState.value === 'crashed')
const isTransitioning = computed(
() => powerState.value === 'starting' || powerState.value === 'stopping',
)
const powerButtonLabel = computed(() => {
if (props.isUpdating) return 'Saving...'
if (isTransitioning.value) return isStopped.value ? 'Save & start' : 'Save & restart'
return isStopped.value ? 'Save & start' : 'Save & restart'
})
const saveAndPower = async () => {
props.save() props.save()
await client.archon.servers_v0.power(props.serverId, 'Restart') await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
} }
</script> </script>

View File

@@ -172,17 +172,7 @@
</template> </template>
<template #actions> <template #actions>
<div v-if="isConnected && !serverData.flows?.intro" class="flex gap-2"> <div v-if="isConnected && !serverData.flows?.intro" class="flex gap-2">
<PanelServerActionButton <PanelServerActionButton :disabled="!!error" :uptime-seconds="uptimeSeconds" />
:is-online="isServerRunning"
:is-actioning="isActioning"
:is-installing="serverData.status === 'installing'"
:disabled="isActioning || !!error"
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
:busy-reason="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
@action="sendPowerAction"
/>
</div> </div>
</template> </template>
</ContentPageHeader> </ContentPageHeader>
@@ -320,12 +310,14 @@
> >
<InstallingBanner <InstallingBanner
v-if=" v-if="
(serverData.status === 'installing' || isSyncingContent) && (serverData.status === 'installing' || isSyncingContent || contentError) &&
syncProgress?.phase !== 'Analyzing' syncProgress?.phase !== 'Analyzing'
" "
data-pyro-server-installing data-pyro-server-installing
class="mb-4" class="mb-4"
:progress="syncProgress" :progress="syncProgress"
:content-error="contentError"
@retry="handleContentRetry"
> >
<template #icon> <template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" /> <ServerIcon :image="serverImage" class="!h-6 !w-6" />
@@ -398,9 +390,8 @@ import {
ServerNotice, ServerNotice,
ServerOnboardingPanelPage, ServerOnboardingPanelPage,
useDebugLogger, useDebugLogger,
useVIntl,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { PowerAction, Stats } from '@modrinth/utils' import type { Stats } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query' import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core' import { useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify' import DOMPurify from 'dompurify'
@@ -416,7 +407,6 @@ import { useServerProject } from '~/composables/servers/use-server-project.ts'
import { useModrinthServersConsole } from '~/store/console.ts' import { useModrinthServersConsole } from '~/store/console.ts'
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient() const client = injectModrinthClient()
const isReconnecting = ref(false) const isReconnecting = ref(false)
@@ -505,7 +495,6 @@ const modrinthServersConsole = useModrinthServersConsole()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const cpuData = ref<number[]>([]) const cpuData = ref<number[]>([])
const ramData = ref<number[]>([]) const ramData = ref<number[]>([])
const isActioning = ref(false)
const isServerRunning = computed(() => serverPowerState.value === 'running') const isServerRunning = computed(() => serverPowerState.value === 'running')
const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped') const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped')
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>() const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
@@ -518,6 +507,7 @@ const markBackupCancelled = (backupId: string) => {
// Parthenon state event // Parthenon state event
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null) const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const contentError = ref<Archon.Websocket.v0.SyncContentError | null>(null)
const syncProgressActive = ref(false) const syncProgressActive = ref(false)
const isAwaitingPostInstallRefresh = ref(false) const isAwaitingPostInstallRefresh = ref(false)
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn( const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
@@ -845,6 +835,7 @@ const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
serverStatus: serverData.value?.status, serverStatus: serverData.value?.status,
}) })
syncProgress.value = data.progress syncProgress.value = data.progress
contentError.value = data.content_error
// Sync power state from the state event // Sync power state from the state event
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> = const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
@@ -878,6 +869,7 @@ const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
hasSeenInstallProgress = true hasSeenInstallProgress = true
} else if ( } else if (
data.progress == null && data.progress == null &&
data.content_error == null &&
serverData.value.status === 'installing' && serverData.value.status === 'installing' &&
hasSeenInstallProgress hasSeenInstallProgress
) { ) {
@@ -889,6 +881,18 @@ const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
} }
} }
async function handleContentRetry() {
if (!worldId.value) return
try {
await client.archon.content_v1.repair(serverId, worldId.value)
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to retry installation',
})
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => { const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
stopUptimeUpdates() stopUptimeUpdates()
uptimeSeconds.value = data.uptime uptimeSeconds.value = data.uptime
@@ -1219,43 +1223,6 @@ const updateGraphData = (dataArray: number[], newValue: number): number[] => {
return updated return updated
} }
const toAdverb = (word: string) => {
if (word.endsWith('p')) {
return word + 'ping'
}
if (word.endsWith('e')) {
return word.slice(0, -1) + 'ing'
}
if (word.endsWith('ie')) {
return word.slice(0, -2) + 'ying'
}
return word + 'ing'
}
const sendPowerAction = async (action: PowerAction) => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1)
try {
isActioning.value = true
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error)
notifyError(
`Error ${toAdverb(actionName)} server`,
'An error occurred while performing this action.',
)
} finally {
isActioning.value = false
}
}
const notifyError = (title: string, text: string) => {
addNotification({
title,
text,
type: 'error',
})
}
const nodeUnavailableDetails = computed(() => [ const nodeUnavailableDetails = computed(() => [
{ {
label: 'Server ID', label: 'Server ID',

View File

@@ -8,7 +8,7 @@
@proceed="confirmResetToOnboarding" @proceed="confirmResetToOnboarding"
/> />
<InstallationSettingsLayout ref="installationSettingsLayout"> <InstallationSettingsLayout ref="installationSettingsLayout" @reset-server="setupModal?.show()">
<template #extra> <template #extra>
<div class="flex flex-col gap-2.5"> <div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{ <span class="text-lg font-semibold text-contrast">{{
@@ -455,6 +455,9 @@ provideInstallationSettings({
debug('save: called with', { platform, gameVersion, loaderVersionId }) debug('save: called with', { platform, gameVersion, loaderVersionId })
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla' const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
const platformChanged = platform !== currentPlatform const platformChanged = platform !== currentPlatform
const gameVersionChanged = gameVersion !== (server.value?.mc_version ?? '')
const loaderVersionChanged =
loaderVersionId !== null && loaderVersionId !== (server.value?.loader_version ?? '')
let resolvedLoaderVersion = loaderVersionId let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') { if (!resolvedLoaderVersion && platform !== 'vanilla') {
@@ -465,12 +468,12 @@ provideInstallationSettings({
debug('save: emitting reinstall before API call') debug('save: emitting reinstall before API call')
emit( emit(
'reinstall', 'reinstall',
platformChanged platformChanged || loaderVersionChanged
? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion } ? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion }
: { mVersion: gameVersion }, : { mVersion: gameVersion },
) )
try { try {
if (platformChanged) { if (platformChanged || loaderVersionChanged) {
const request: Archon.Content.v1.InstallWorldContent = { const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare', content_variant: 'bare',
loader: toApiLoader(platform), loader: toApiLoader(platform),
@@ -478,9 +481,9 @@ provideInstallationSettings({
game_version: gameVersion || undefined, game_version: gameVersion || undefined,
soft_override: true, soft_override: true,
} }
debug('save: platform changed, calling installContent', request) debug('save: platform/loader version changed, calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request) await client.archon.content_v1.installContent(serverId, worldId.value!, request)
} else { } else if (gameVersionChanged) {
debug('save: game version only, calling applyGameVersionUpdate', gameVersion) debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion) await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
} }
@@ -662,8 +665,66 @@ provideInstallationSettings({
isApp: false, isApp: false,
showModpackVersionActions: computed(() => modpack.value?.spec.platform === 'modrinth'), showModpackVersionActions: computed(() => modpack.value?.spec.platform === 'modrinth'),
lockPlatform: true, lockPlatform: false,
hideLoaderVersion: true, hideLoaderVersion: false,
async disableAllContent() {
debug('disableAllContent: fetching all addons')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
const items = (addons.addons ?? [])
.filter((a) => !a.disabled)
.map((a) => ({ kind: a.kind, filename: a.filename }))
if (items.length > 0) {
debug('disableAllContent: disabling', items.length, 'addons')
await client.archon.content_v1.disableAddons(serverId, worldId.value!, items)
}
debug('disableAllContent: done')
},
async disableIncompatibleContent(diffs) {
debug('disableIncompatibleContent: processing', diffs.length, 'diffs')
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
const removedFiles = new Set(diffs.filter((d) => d.type === 'removed').map((d) => d.fileName))
const items = (addons.addons ?? [])
.filter((a) => !a.disabled && removedFiles.has(a.filename))
.map((a) => ({ kind: a.kind, filename: a.filename }))
if (items.length > 0) {
debug('disableIncompatibleContent: disabling', items.length, 'addons')
await client.archon.content_v1.disableAddons(serverId, worldId.value!, items)
}
debug('disableIncompatibleContent: done')
},
async saveWithoutAutoFix(platform, gameVersion, loaderVersionId) {
debug('saveWithoutAutoFix: called with', { platform, gameVersion, loaderVersionId })
let resolvedLoaderVersion = loaderVersionId
if (!resolvedLoaderVersion && platform !== 'vanilla') {
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
resolvedLoaderVersion = versions[0]?.id ?? null
}
emit('reinstall', { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion })
try {
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: resolvedLoaderVersion ?? '',
game_version: gameVersion || undefined,
soft_override: true,
}
debug('saveWithoutAutoFix: calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
debug('saveWithoutAutoFix: succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('saveWithoutAutoFix: failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToSaveSettings),
})
throw err
}
},
async previewSave(_platform, gameVersion, _loaderVersionId, signal) { async previewSave(_platform, gameVersion, _loaderVersionId, signal) {
const result = await client.archon.content_v1.getUpdateGameVersionPreview( const result = await client.archon.content_v1.getUpdateGameVersionPreview(

View File

@@ -611,6 +611,11 @@ export namespace Archon {
percent: number percent: number
} }
export type SyncContentError = {
step: string
description: string
}
export type WSStateEvent = { export type WSStateEvent = {
event: 'state' event: 'state'
debug: string debug: string
@@ -620,6 +625,7 @@ export namespace Archon {
target: 'start' | 'stop' | 'restart' | null target: 'start' | 'stop' | 'restart' | null
uptime: number uptime: number
progress: SyncContentProgress | null progress: SyncContentProgress | null
content_error: SyncContentError | null
} }
// Outgoing messages (client -> server) // Outgoing messages (client -> server)

View File

@@ -147,6 +147,7 @@ watch(
// Always fetch the actual latest version from the API since search index can be stale // Always fetch the actual latest version from the API since search index can be stale
try { try {
const versions = await ctx.getProjectVersions(projectId) const versions = await ctx.getProjectVersions(projectId)
if (ctx.modpackSearchProjectId.value !== projectId) return
if (versions.length > 0) { if (versions.length > 0) {
const version = versions[0] const version = versions[0]
ctx.modpackSelection.value = { ctx.modpackSelection.value = {

View File

@@ -307,8 +307,19 @@ export function createCreationFlowContext(
isImportMode.value = false isImportMode.value = false
setupType.value = type setupType.value = type
if (type === 'modpack') { if (type === 'modpack') {
selectedLoader.value = null
selectedLoaderVersion.value = null
loaderVersionType.value = 'stable'
modal.value?.setStage('modpack') modal.value?.setStage('modpack')
} else { } else {
modpackSelection.value = null
modpackFile.value = null
modpackFilePath.value = null
if (type === 'vanilla') {
selectedLoader.value = null
selectedLoaderVersion.value = null
loaderVersionType.value = 'stable'
}
// both custom and vanilla go to custom-setup // both custom and vanilla go to custom-setup
// vanilla just hides loader chips via hideLoaderChips computed // vanilla just hides loader chips via hideLoaderChips computed
modal.value?.setStage('custom-setup') modal.value?.setStage('custom-setup')

View File

@@ -1,12 +1,17 @@
<template> <template>
<Admonition type="info" show-actions-underneath> <Admonition :type="contentError ? 'critical' : 'info'" :show-actions-underneath="!contentError">
<template #icon> <template #icon>
<slot name="icon"> <slot v-if="!contentError" name="icon">
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" /> <SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
</slot> </slot>
</template> </template>
<template #header>We're preparing your server!</template> <template #header>
<template v-if="progress">{{ phaseLabel }}</template> {{ contentError ? 'Installation error' : "We're preparing your server!" }}
</template>
<template v-if="contentError">
{{ errorLabel }}
</template>
<template v-else-if="progress">{{ phaseLabel }}</template>
<div v-else class="ticker-container"> <div v-else class="ticker-container">
<div class="ticker-content"> <div class="ticker-content">
<div <div
@@ -19,7 +24,15 @@
</div> </div>
</div> </div>
</div> </div>
<template #actions> <template v-if="contentError" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button class="!border" @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
Retry
</button>
</ButtonStyled>
</template>
<template v-if="!contentError" #actions>
<ProgressBar <ProgressBar
v-if="progress" v-if="progress"
:progress="progress.percent" :progress="progress.percent"
@@ -33,10 +46,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg' import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
import { computed, onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import Admonition from '../base/Admonition.vue' import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import ProgressBar from '../base/ProgressBar.vue' import ProgressBar from '../base/ProgressBar.vue'
export interface SyncProgress { export interface SyncProgress {
@@ -44,10 +59,48 @@ export interface SyncProgress {
percent: number percent: number
} }
export interface ContentError {
step: string
description: string
}
const props = defineProps<{ const props = defineProps<{
progress?: SyncProgress | null progress?: SyncProgress | null
contentError?: ContentError | null
}>() }>()
const emit = defineEmits<{
retry: []
}>()
const errorLabel = computed(() => {
const desc = props.contentError?.description?.toLowerCase()
const step = props.contentError?.step
if (step === 'modloader') {
if (desc === 'the specified version may be incorrect') {
return 'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.'
}
if (desc === 'this version is not yet supported') {
return 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.'
}
if (desc === 'internal error') {
return 'An internal error occurred while installing the platform. Please try again.'
}
}
if (step === 'modpack') {
if (desc?.includes('no primary file')) {
return 'The modpack version has no downloadable file. It may have been packaged incorrectly.'
}
if (desc?.includes('failed to install')) {
return 'Failed to install the modpack. It may be corrupted or incompatible.'
}
}
return props.contentError?.description ?? 'An unexpected error occurred during installation.'
})
const phaseLabel = computed(() => { const phaseLabel = computed(() => {
switch (props.progress?.phase) { switch (props.progress?.phase) {
case 'InstallingLoader': case 'InstallingLoader':

View File

@@ -138,7 +138,7 @@ onUnmounted(() => {
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md" class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md"
:class="{ 'opacity-50': disabled }" :class="{ 'opacity-50': disabled }"
> >
<div class="flex flex-wrap items-center justify-between gap-4"> <div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 flex-1 items-center gap-4"> <div class="flex min-w-0 flex-1 items-center gap-4">
<AutoLink :to="projectLink" class="shrink-0"> <AutoLink :to="projectLink" class="shrink-0">
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow raised /> <Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow raised />

View File

@@ -13,6 +13,7 @@
<InlineBackupCreator <InlineBackupCreator
ref="backupCreator" ref="backupCreator"
:backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'" :backup-name="backupTip ? `Before bulk update (${backupTip})` : 'Before bulk update'"
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
@update:buttons-disabled="buttonsDisabled = $event" @update:buttons-disabled="buttonsDisabled = $event"
/> />
</div> </div>
@@ -68,6 +69,11 @@ const messages = defineMessages({
id: 'content.confirm-bulk-update.update-button', id: 'content.confirm-bulk-update.update-button',
defaultMessage: 'Update {count, plural, one {# project} other {# projects}}', defaultMessage: 'Update {count, plural, one {# project} other {# projects}}',
}, },
shiftClickHint: {
id: 'content.confirm-bulk-update.shift-click-hint',
defaultMessage:
'Hold Shift while clicking "Update all" to skip this confirmation in the future.',
},
}) })
defineProps<{ defineProps<{

View File

@@ -44,7 +44,7 @@
/> />
</div> </div>
<span v-if="!props.hideShiftClickHint" class="text-secondary"> <span v-if="!props.hideShiftClickHint" class="text-secondary">
{{ formatMessage(messages.shiftClickHint) }} {{ props.shiftClickHintOverride ?? formatMessage(messages.shiftClickHint) }}
</span> </span>
</div> </div>
</template> </template>
@@ -61,6 +61,7 @@ import { useInlineBackup } from '../../composables/use-inline-backup'
const props = defineProps<{ const props = defineProps<{
backupName: string backupName: string
hideShiftClickHint?: boolean hideShiftClickHint?: boolean
shiftClickHintOverride?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{

View File

@@ -73,6 +73,7 @@
<InlineBackupCreator <InlineBackupCreator
ref="backupCreator" ref="backupCreator"
backup-name="Before version change" backup-name="Before version change"
hide-shift-click-hint
@update:buttons-disabled="buttonsDisabled = $event" @update:buttons-disabled="buttonsDisabled = $event"
/> />
</div> </div>

View File

@@ -0,0 +1,194 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" :closable="!loading" no-padding>
<div class="flex max-w-[500px] flex-col gap-6 p-6">
<Admonition
:type="variant === 'loader-change' ? 'critical' : 'warning'"
:header="
variant === 'loader-change'
? formatMessage(messages.loaderChangeTitle)
: formatMessage(messages.gameVersionWarningTitle)
"
>
<div class="flex flex-col gap-3">
<span>
{{
variant === 'loader-change'
? formatMessage(messages.loaderChangeBody)
: formatMessage(messages.gameVersionWarningBody)
}}
</span>
<div v-if="variant === 'loader-change'">
<ButtonStyled color="red">
<button :disabled="loading" @click="handleResetServer">
<TrashIcon class="size-5" />
{{ formatMessage(commonMessages.resetServerButton) }}
</button>
</ButtonStyled>
</div>
</div>
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="
variant === 'loader-change' ? 'Before loader change' : 'Before version change'
"
hide-shift-click-hint
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button :disabled="loading" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<template v-if="variant === 'game-version-change'">
<ButtonStyled>
<button :disabled="buttonsDisabled || loading" @click="handleDisableConflicts">
<SpinnerIcon
v-if="loading && loadingAction === 'disable-conflicts'"
class="size-5 animate-spin"
/>
<PowerOffIcon v-else class="size-5" />
{{ formatMessage(messages.disableConflictsButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="buttonsDisabled || loading" @click="handleAutoFix">
<SpinnerIcon
v-if="loading && loadingAction === 'auto-fix'"
class="size-5 animate-spin"
/>
<HammerIcon v-else class="size-5" />
{{ formatMessage(messages.autoFixButton) }}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled color="red">
<button :disabled="buttonsDisabled || loading" @click="handleConfirmLoaderChange">
<SpinnerIcon v-if="loading" class="size-5 animate-spin" />
<CircleAlertIcon v-else class="size-5" />
{{ formatMessage(messages.changeLoaderButton) }}
</button>
</ButtonStyled>
</template>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
CircleAlertIcon,
HammerIcon,
PowerOffIcon,
SpinnerIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from '../../content-tab/components/modals/InlineBackupCreator.vue'
defineProps<{
variant: 'loader-change' | 'game-version-change'
loading?: boolean
}>()
const emit = defineEmits<{
'confirm-loader-change': []
'auto-fix': []
'disable-conflicts': []
'reset-server': []
cancel: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const buttonsDisabled = ref(false)
const loadingAction = ref<'auto-fix' | 'disable-conflicts' | null>(null)
function show(e?: MouseEvent) {
loadingAction.value = null
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
function handleCancel() {
hide()
emit('cancel')
}
function handleConfirmLoaderChange() {
emit('confirm-loader-change')
}
function handleAutoFix() {
loadingAction.value = 'auto-fix'
emit('auto-fix')
}
function handleDisableConflicts() {
loadingAction.value = 'disable-conflicts'
emit('disable-conflicts')
}
function handleResetServer() {
hide()
emit('reset-server')
}
const messages = defineMessages({
header: {
id: 'installation-settings.incompatible-content.header',
defaultMessage: 'Incompatible content installed',
},
loaderChangeTitle: {
id: 'installation-settings.incompatible-content.loader-change-title',
defaultMessage: 'Changing loaders is destructive',
},
loaderChangeBody: {
id: 'installation-settings.incompatible-content.loader-change-body',
defaultMessage:
'When changing the loader, all installed content will be disabled. We recommend resetting your server instead.',
},
gameVersionWarningTitle: {
id: 'installation-settings.incompatible-content.game-version-warning-title',
defaultMessage: 'Incompatibility warning',
},
gameVersionWarningBody: {
id: 'installation-settings.incompatible-content.game-version-warning-body',
defaultMessage:
'When changing the game version, we can either disable incompatible installed content or attempt to resolve the incompatibilities.',
},
changeLoaderButton: {
id: 'installation-settings.incompatible-content.change-loader-button',
defaultMessage: 'Change loader',
},
autoFixButton: {
id: 'installation-settings.incompatible-content.auto-fix-button',
defaultMessage: 'Auto-fix',
},
disableConflictsButton: {
id: 'installation-settings.incompatible-content.disable-conflicts-button',
defaultMessage: 'Disable conflicts',
},
})
defineExpose({ show, hide })
</script>

View File

@@ -1 +1 @@
export { useInstallationForm } from './use-installation-form' export { type IncompatibleContentVariant, useInstallationForm } from './use-installation-form'

View File

@@ -6,13 +6,19 @@ import { formatLoaderLabel } from '#ui/utils/loaders'
import type { ContentUpdaterModal } from '../../content-tab' import type { ContentUpdaterModal } from '../../content-tab'
import type ContentDiffModal from '../components/ContentDiffModal.vue' import type ContentDiffModal from '../components/ContentDiffModal.vue'
import type IncompatibleContentModal from '../components/IncompatibleContentModal.vue'
import type { InstallationSettingsContext } from '../providers/installation-settings' import type { InstallationSettingsContext } from '../providers/installation-settings'
import type { ContentDiffPreview } from '../types' import type { ContentDiffPreview } from '../types'
export type IncompatibleContentVariant = 'loader-change' | 'game-version-change'
export function useInstallationForm( export function useInstallationForm(
ctx: InstallationSettingsContext, ctx: InstallationSettingsContext,
updaterModalRef: Ref<InstanceType<typeof ContentUpdaterModal> | null | undefined>, updaterModalRef: Ref<InstanceType<typeof ContentUpdaterModal> | null | undefined>,
contentDiffModalRef?: Ref<InstanceType<typeof ContentDiffModal> | null | undefined>, contentDiffModalRef?: Ref<InstanceType<typeof ContentDiffModal> | null | undefined>,
incompatibleContentModalRef?: Ref<
InstanceType<typeof IncompatibleContentModal> | null | undefined
>,
) { ) {
const isEditing = ref(false) const isEditing = ref(false)
const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value) const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value)
@@ -22,6 +28,7 @@ export function useInstallationForm(
const isSaving = ref(false) const isSaving = ref(false)
const isVerifying = ref(false) const isVerifying = ref(false)
const pendingPreview = ref<ContentDiffPreview | null>(null) const pendingPreview = ref<ContentDiffPreview | null>(null)
const incompatibleContentVariant = ref<IncompatibleContentVariant | null>(null)
let abortController: AbortController | null = null let abortController: AbortController | null = null
const gameVersionOptions = computed(() => const gameVersionOptions = computed(() =>
@@ -75,9 +82,26 @@ export function useInstallationForm(
async function save() { async function save() {
isSaving.value = true isSaving.value = true
try { try {
const platformChanged = selectedPlatform.value !== ctx.currentPlatform.value
const isModded = ctx.currentPlatform.value !== 'vanilla' const isModded = ctx.currentPlatform.value !== 'vanilla'
const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value
if (platformChanged && ctx.disableAllContent) {
isSaving.value = false
incompatibleContentVariant.value = 'loader-change'
await nextTick()
incompatibleContentModalRef?.value?.show()
return
}
if (isModded && gameVersionChanged && ctx.disableIncompatibleContent) {
isSaving.value = false
incompatibleContentVariant.value = 'game-version-change'
await nextTick()
incompatibleContentModalRef?.value?.show()
return
}
if (ctx.previewSave && isModded && gameVersionChanged) { if (ctx.previewSave && isModded && gameVersionChanged) {
isVerifying.value = true isVerifying.value = true
abortController = new AbortController() abortController = new AbortController()
@@ -127,6 +151,111 @@ export function useInstallationForm(
} }
} }
async function confirmLoaderChange() {
try {
if (ctx.disableAllContent) {
await ctx.disableAllContent()
}
incompatibleContentVariant.value = null
await performSave()
} catch {
incompatibleContentVariant.value = null
isSaving.value = false
}
}
async function confirmAutoFix() {
try {
if (ctx.previewSave) {
isVerifying.value = true
abortController = new AbortController()
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
: null
let preview: ContentDiffPreview | null
try {
preview = await ctx.previewSave(
selectedPlatform.value,
selectedGameVersion.value,
loaderVersionId,
abortController.signal,
)
} finally {
isVerifying.value = false
abortController = null
}
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
pendingPreview.value = preview
incompatibleContentVariant.value = null
await nextTick()
await nextTick()
contentDiffModalRef?.value?.show()
return
}
}
incompatibleContentVariant.value = null
await performSave()
} catch {
incompatibleContentVariant.value = null
isSaving.value = false
}
}
async function confirmDisableConflicts() {
try {
if (ctx.disableIncompatibleContent && ctx.previewSave) {
isVerifying.value = true
abortController = new AbortController()
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
: null
let preview: ContentDiffPreview | null
try {
preview = await ctx.previewSave(
selectedPlatform.value,
selectedGameVersion.value,
loaderVersionId,
abortController.signal,
)
} finally {
isVerifying.value = false
abortController = null
}
if (preview) {
await ctx.disableIncompatibleContent(preview.diffs)
}
}
incompatibleContentVariant.value = null
if (ctx.saveWithoutAutoFix) {
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
: null
await ctx.saveWithoutAutoFix(
selectedPlatform.value,
selectedGameVersion.value,
loaderVersionId,
)
if (ctx.afterSave) await ctx.afterSave()
isEditing.value = false
isSaving.value = false
} else {
await performSave()
}
} catch {
incompatibleContentVariant.value = null
isSaving.value = false
}
}
async function confirmSave() { async function confirmSave() {
pendingPreview.value = null pendingPreview.value = null
try { try {
@@ -138,6 +267,7 @@ export function useInstallationForm(
function cancelPreview() { function cancelPreview() {
pendingPreview.value = null pendingPreview.value = null
incompatibleContentVariant.value = null
isSaving.value = false isSaving.value = false
} }
@@ -262,7 +392,11 @@ export function useInstallationForm(
hasChanges, hasChanges,
save, save,
pendingPreview, pendingPreview,
incompatibleContentVariant,
confirmSave, confirmSave,
confirmLoaderChange,
confirmAutoFix,
confirmDisableConflicts,
cancelPreview, cancelPreview,
cancelEditing, cancelEditing,
updatingModpack, updatingModpack,

View File

@@ -24,6 +24,7 @@ import Combobox from '#ui/components/base/Combobox.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue' import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages' import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue' import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue' import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
@@ -31,6 +32,7 @@ import ConfirmRepairModal from '../content-tab/components/modals/ConfirmRepairMo
import ConfirmUnlinkModal from '../content-tab/components/modals/ConfirmUnlinkModal.vue' import ConfirmUnlinkModal from '../content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../content-tab/components/modals/ContentUpdaterModal.vue' import ContentUpdaterModal from '../content-tab/components/modals/ContentUpdaterModal.vue'
import ContentDiffModal from './components/ContentDiffModal.vue' import ContentDiffModal from './components/ContentDiffModal.vue'
import IncompatibleContentModal from './components/IncompatibleContentModal.vue'
import { useInstallationForm } from './composables' import { useInstallationForm } from './composables'
import { injectInstallationSettings } from './providers/installation-settings' import { injectInstallationSettings } from './providers/installation-settings'
@@ -44,11 +46,17 @@ const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>() const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const contentDiffModal = ref<InstanceType<typeof ContentDiffModal>>() const contentDiffModal = ref<InstanceType<typeof ContentDiffModal>>()
const incompatibleContentModal = ref<InstanceType<typeof IncompatibleContentModal>>()
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>() const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
const pendingUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null) const pendingUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isUpdateDowngrade = ref(false) const isUpdateDowngrade = ref(false)
const form = useInstallationForm(ctx, contentUpdaterModal, contentDiffModal) const form = useInstallationForm(
ctx,
contentUpdaterModal,
contentDiffModal,
incompatibleContentModal,
)
function handleBeforeUnload(e: BeforeUnloadEvent) { function handleBeforeUnload(e: BeforeUnloadEvent) {
if (form.isSaving.value) { if (form.isSaving.value) {
@@ -133,6 +141,16 @@ function handleUnlink() {
ctx.unlinkModpack() ctx.unlinkModpack()
} }
const emit = defineEmits<{
'reset-server': []
}>()
function handleIncompatibleResetServer() {
form.cancelPreview()
form.cancelEditing()
emit('reset-server')
}
defineExpose({ defineExpose({
cancelEditing: () => form.cancelEditing(), cancelEditing: () => form.cancelEditing(),
}) })
@@ -477,6 +495,8 @@ const messages = defineMessages({
<Chips <Chips
v-model="form.selectedPlatform.value" v-model="form.selectedPlatform.value"
:items="ctx.availablePlatforms" :items="ctx.availablePlatforms"
:format-label="formatLoaderLabel"
:capitalize="false"
:disabled-items="disabledPlatforms" :disabled-items="disabledPlatforms"
:disabled-tooltip="formatMessage(messages.platformLockTooltip)" :disabled-tooltip="formatMessage(messages.platformLockTooltip)"
:aria-label="formatMessage(messages.selectPlatformAriaLabel)" :aria-label="formatMessage(messages.selectPlatformAriaLabel)"
@@ -708,8 +728,20 @@ const messages = defineMessages({
@unlink="handleUnlink" @unlink="handleUnlink"
/> />
<IncompatibleContentModal
v-if="form.incompatibleContentVariant.value"
ref="incompatibleContentModal"
:variant="form.incompatibleContentVariant.value"
:loading="form.isVerifying.value || form.isSaving.value"
@confirm-loader-change="form.confirmLoaderChange()"
@auto-fix="form.confirmAutoFix()"
@disable-conflicts="form.confirmDisableConflicts()"
@reset-server="handleIncompatibleResetServer"
@cancel="form.cancelPreview()"
/>
<ContentDiffModal <ContentDiffModal
v-if="form.pendingPreview.value" v-if="form.pendingPreview.value && !form.incompatibleContentVariant.value"
ref="contentDiffModal" ref="contentDiffModal"
:header="formatMessage(messages.confirmVersionChangeHeader)" :header="formatMessage(messages.confirmVersionChangeHeader)"
:description=" :description="

View File

@@ -4,6 +4,7 @@ import type { ComputedRef, Ref } from 'vue'
import { createContext } from '#ui/providers/create-context' import { createContext } from '#ui/providers/create-context'
import type { import type {
ContentDiffItem,
ContentDiffPreview, ContentDiffPreview,
GameVersionOption, GameVersionOption,
InstallationInfoRow, InstallationInfoRow,
@@ -61,6 +62,26 @@ export interface InstallationSettingsContext {
lockPlatform?: boolean lockPlatform?: boolean
hideLoaderVersion?: boolean hideLoaderVersion?: boolean
/** Bulk-disable all addons on the server (used before switching loaders). */
disableAllContent?: () => Promise<void>
/**
* Disable only the incompatible addons identified in a content diff preview.
* Used when the user chooses "Disable conflicts" instead of "Auto-fix".
*/
disableIncompatibleContent?: (diffs: ContentDiffItem[]) => Promise<void>
/**
* Save the installation settings without auto-resolving content.
* Uses installContent with soft_override instead of applyGameVersionUpdate.
*/
saveWithoutAutoFix?: (
platform: string,
gameVersion: string,
loaderVersionId: string | null,
) => Promise<void>
previewSave?: ( previewSave?: (
platform: string, platform: string,
gameVersion: string, gameVersion: string,

View File

@@ -233,6 +233,9 @@
"content.confirm-bulk-update.header": { "content.confirm-bulk-update.header": {
"defaultMessage": "Update projects" "defaultMessage": "Update projects"
}, },
"content.confirm-bulk-update.shift-click-hint": {
"defaultMessage": "Hold Shift while clicking \"Update all\" to skip this confirmation in the future."
},
"content.confirm-bulk-update.update-button": { "content.confirm-bulk-update.update-button": {
"defaultMessage": "Update {count, plural, one {# project} other {# projects}}" "defaultMessage": "Update {count, plural, one {# project} other {# projects}}"
}, },
@@ -977,6 +980,30 @@
"installation-settings.edit.warning-server": { "installation-settings.edit.warning-server": {
"defaultMessage": "We don't recommend editing your installation settings after installing content. If you want to edit them reset your server." "defaultMessage": "We don't recommend editing your installation settings after installing content. If you want to edit them reset your server."
}, },
"installation-settings.incompatible-content.auto-fix-button": {
"defaultMessage": "Auto-fix"
},
"installation-settings.incompatible-content.change-loader-button": {
"defaultMessage": "Change loader"
},
"installation-settings.incompatible-content.disable-conflicts-button": {
"defaultMessage": "Disable conflicts"
},
"installation-settings.incompatible-content.game-version-warning-body": {
"defaultMessage": "When changing the game version, we can either disable incompatible installed content or attempt to resolve the incompatibilities."
},
"installation-settings.incompatible-content.game-version-warning-title": {
"defaultMessage": "Incompatibility warning"
},
"installation-settings.incompatible-content.header": {
"defaultMessage": "Incompatible content installed"
},
"installation-settings.incompatible-content.loader-change-body": {
"defaultMessage": "When changing the loader, all installed content will be disabled. We recommend resetting your server instead."
},
"installation-settings.incompatible-content.loader-change-title": {
"defaultMessage": "Changing loaders is destructive"
},
"installation-settings.linked-instance.title": { "installation-settings.linked-instance.title": {
"defaultMessage": "Linked {projectType, select, server {server project} other {modpack}}" "defaultMessage": "Linked {projectType, select, server {server project} other {modpack}}"
}, },

View File

@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import InstallingBanner from '../../components/servers/InstallingBanner.vue'
const meta = {
title: 'Servers/InstallingBanner',
component: InstallingBanner,
} satisfies Meta<typeof InstallingBanner>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
name: 'Default (no progress)',
}
export const WithProgress: Story = {
args: {
progress: {
phase: 'InstallingLoader',
percent: 45,
},
},
}
export const InstallingModpack: Story = {
args: {
progress: {
phase: 'InstallingPack',
percent: 72,
},
},
}
export const InstallingAddons: Story = {
args: {
progress: {
phase: 'Addons',
percent: 90,
},
},
}
export const ErrorInvalidVersion: Story = {
name: 'Error: Invalid Version',
args: {
contentError: {
step: 'modloader',
description: 'the specified version may be incorrect',
},
},
}
export const ErrorUnsupportedVersion: Story = {
name: 'Error: Unsupported Version',
args: {
contentError: {
step: 'modloader',
description: 'this version is not yet supported',
},
},
}
export const ErrorInternal: Story = {
name: 'Error: Internal',
args: {
contentError: {
step: 'modloader',
description: 'internal error',
},
},
}
export const ErrorModpackInstall: Story = {
name: 'Error: Modpack Install Failed',
args: {
contentError: {
step: 'modpack',
description: 'Failed to install modpack',
},
},
}
export const ErrorModpackNoFile: Story = {
name: 'Error: Modpack No Primary File',
args: {
contentError: {
step: 'modpack',
description: 'Modpack version has no primary file',
},
},
}
export const AllStates: Story = {
render: () => ({
components: { InstallingBanner },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<InstallingBanner />
<InstallingBanner :progress="{ phase: 'InstallingLoader', percent: 45 }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'the specified version may be incorrect' }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'this version is not yet supported' }" />
<InstallingBanner :content-error="{ step: 'modloader', description: 'internal error' }" />
<InstallingBanner :content-error="{ step: 'modpack', description: 'Failed to install modpack' }" />
</div>
`,
}),
}