feat: backups alignment with Figma (#5559)

* feat: backup admonitions

* feat: align modals + fix backupitem

* fix: body needs opac 80

* fix: lint
This commit is contained in:
Calum H.
2026-03-13 22:27:06 +00:00
committed by GitHub
parent 31b541007d
commit 8a2125ef16
16 changed files with 715 additions and 157 deletions

View File

@@ -32,7 +32,7 @@
:options="languageOptions"
searchable
include-select-all-option
:maxTagRows="2"
:max-tag-rows="2"
placeholder="Select languages"
:disabled="!hasPermission"
/>

View File

@@ -311,6 +311,7 @@
</template>
</InstallingBanner>
</Transition>
<BackupProgressAdmonitions class="mb-4" />
<NuxtPage
:route="route"
:is-connected="isConnected"
@@ -357,6 +358,7 @@ import {
} from '@modrinth/assets'
import type { BusyReason } from '@modrinth/ui'
import {
BackupProgressAdmonitions,
ButtonStyled,
defineMessage,
ErrorInformationCard,

View File

@@ -52,7 +52,7 @@ import ButtonStyled from './ButtonStyled.vue'
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical'
type?: 'info' | 'warning' | 'critical' | 'success'
header?: string
body?: string
showActionsUnderneath?: boolean
@@ -75,17 +75,20 @@ const typeClasses = {
info: 'border-brand-blue bg-bg-blue',
warning: 'border-brand-orange bg-bg-orange',
critical: 'border-brand-red bg-bg-red',
success: 'border-brand-green bg-bg-green',
}
const iconClasses = {
info: 'text-brand-blue',
warning: 'text-brand-orange',
critical: 'text-brand-red',
success: 'text-brand-green',
}
const buttonColors: Record<string, 'blue' | 'orange' | 'red'> = {
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
info: 'blue',
warning: 'orange',
critical: 'red',
success: 'green',
}
</script>

View File

@@ -1,17 +1,13 @@
<template>
<NewModal ref="modal" header="Delete backup" fade="danger">
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-6 max-w-[400px]">
<Admonition type="critical" header="Delete warning">
This backup will be permanently deleted. This action cannot be undone.
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<BackupItem
:backup="currentBackup"
preview
class="!bg-surface-2 border-solid border-[1px] border-surface-5"
/>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
</div>
</div>

View File

@@ -17,7 +17,6 @@ import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const formatDateTime = useFormatDateTime({
@@ -48,12 +47,6 @@ const props = withDefaults(
},
)
const backupQueued = computed(
() =>
props.backup.status === 'pending' ||
props.backup.task?.create?.progress === 0 ||
(props.backup.status === 'in_progress' && !props.backup.task?.create),
)
const failedToCreate = computed(
() => props.backup.status === 'error' || props.backup.status === 'timed_out',
)
@@ -63,30 +56,30 @@ const inactiveStates = ['failed', 'cancelled', 'done']
const creating = computed(() => {
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
return true
}
if (
(props.backup.status === 'in_progress' || props.backup.status === 'pending') &&
!props.backup.task?.restore
) {
return { progress: 0, state: 'ongoing' as const }
return true
}
return undefined
return false
})
const restoring = computed(() => {
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
return true
}
return undefined
return false
})
const restoreQueued = computed(() => restoring.value?.progress === 0)
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const activeOperation = computed(() => creating.value || restoring.value)
const backupIcon = computed(() => {
if (props.backup.automated) {
return ClockIcon
@@ -97,8 +90,7 @@ const backupIcon = computed(() => {
const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = []
// Download only available when not creating
if (!creating.value) {
if (!activeOperation.value) {
options.push({
id: 'download',
action: () => emit('download'),
@@ -109,8 +101,7 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
options.push({ id: 'rename', action: () => emit('rename') })
// Delete only available when not creating (has separate Cancel button)
if (!creating.value) {
if (!activeOperation.value) {
options.push({ divider: true })
options.push({
id: 'delete',
@@ -138,22 +129,6 @@ const messages = defineMessages({
id: 'servers.backups.item.rename',
defaultMessage: 'Rename',
},
queuedForBackup: {
id: 'servers.backups.item.queued-for-backup',
defaultMessage: 'Backup queued',
},
queuedForRestore: {
id: 'servers.backups.item.queued-for-restore',
defaultMessage: 'Restore queued',
},
creatingBackup: {
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup...',
},
restoringBackup: {
id: 'servers.backups.item.restoring-backup',
defaultMessage: 'Restoring from backup...',
},
failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
@@ -179,7 +154,7 @@ const messages = defineMessages({
<template>
<div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
:class="preview ? 'grid-cols-2' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
:class="preview ? 'grid-cols-1' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
>
<div class="flex flex-row gap-4 items-center">
<div
@@ -199,7 +174,10 @@ const messages = defineMessages({
</span>
</div>
<div class="flex items-center gap-1.5 text-sm text-secondary">
<template v-if="failedToCreate || failedToRestore">
<template v-if="preview">
<span>{{ formatDateTime(backup.created_at) }}</span>
</template>
<template v-else-if="failedToCreate || failedToRestore">
<XIcon class="size-4 text-red" />
<span class="text-red">
{{
@@ -228,95 +206,42 @@ const messages = defineMessages({
</div>
<div
v-if="!preview"
class="col-span-full row-start-2 flex flex-col gap-2 md:col-span-1 md:row-start-auto md:items-center"
>
<template v-if="creating || restoring">
<ProgressBar
:progress="(creating || restoring)!.progress"
:color="creating ? 'brand' : 'purple'"
:waiting="(creating || restoring)!.progress === 0"
:label="
formatMessage(
creating
? backupQueued
? messages.queuedForBackup
: messages.creatingBackup
: restoreQueued
? messages.queuedForRestore
: messages.restoringBackup,
)
"
:label-class="creating ? 'text-contrast' : 'text-purple'"
show-progress
full-width
/>
</template>
<template v-else>
<span class="w-full font-medium text-contrast md:text-center">
{{ formatDateTime(backup.created_at) }}
</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
</template>
<span class="w-full font-medium text-contrast md:text-center">
{{ formatDateTime(backup.created_at) }}
</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
</div>
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end">
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon class="size-5" />
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</template>
<template v-else-if="creating">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-5" @click="() => emit('delete')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled color="brand" type="outlined">
<button
v-tooltip="props.restoreDisabled"
class="!border-[1px]"
:disabled="!!props.restoreDisabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
<ButtonStyled v-if="!activeOperation" color="brand" type="outlined">
<button
v-tooltip="props.restoreDisabled"
class="!border-[1px]"
:disabled="!!props.restoreDisabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{

View File

@@ -0,0 +1,221 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const props = withDefaults(
defineProps<{
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
progress: number
backupName?: string
createdAt?: string
}>(),
{
backupName: undefined,
createdAt: undefined,
},
)
const emit = defineEmits<{
(e: 'cancel' | 'retry' | 'dismiss'): void
}>()
const isQueued = computed(() => props.state === 'ongoing' && props.progress === 0)
const isInProgress = computed(() => props.state === 'ongoing' && props.progress > 0)
const isFailed = computed(() => props.state === 'failed')
const isSuccess = computed(() => props.state === 'done')
const showCancel = computed(() => isQueued.value || isInProgress.value)
const showRetry = computed(() => isFailed.value)
const showDismiss = computed(() => isFailed.value || isSuccess.value)
const showProgress = computed(() => isInProgress.value)
const colorClasses = computed(() => {
if (isFailed.value) return 'border-brand-red bg-bg-red'
if (isSuccess.value) return 'border-brand-green bg-bg-green'
return 'border-brand-blue bg-bg-blue'
})
const icon = computed(() => {
if (isFailed.value) return TriangleAlertIcon
if (isSuccess.value) return CheckCircleIcon
return InfoIcon
})
const iconClass = computed(() => {
if (isFailed.value) return 'text-brand-red'
if (isSuccess.value) return 'text-brand-green'
return 'text-brand-blue'
})
const buttonColor = computed<'red' | 'green' | 'blue'>(() => {
if (isFailed.value) return 'red'
if (isSuccess.value) return 'green'
return 'blue'
})
const name = computed(() => props.backupName ?? formatMessage(messages.fallbackName))
const title = computed(() => {
if (props.type === 'create') {
if (isQueued.value) return formatMessage(messages.backupQueuedTitle)
if (isInProgress.value) return formatMessage(messages.creatingBackupTitle)
if (isFailed.value) return formatMessage(messages.backupFailedTitle)
}
if (isQueued.value) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress.value) return formatMessage(messages.restoringBackupTitle)
if (isSuccess.value) return formatMessage(messages.restoreSuccessfulTitle)
if (isFailed.value) return formatMessage(messages.restoreFailedTitle)
return ''
})
const description = computed(() => {
if (props.type === 'create') {
if (isQueued.value)
return formatMessage(messages.backupQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.creatingBackupDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.backupFailedDescription, { backupName: name.value })
}
if (isQueued.value)
return formatMessage(messages.restoreQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.restoringBackupDescription, { backupName: name.value })
if (isSuccess.value)
return formatMessage(messages.restoreSuccessfulDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.restoreFailedDescription, { backupName: name.value })
return ''
})
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restoring from backup queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restoring from backup successful',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restoring from backup failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
})
</script>
<template>
<div :class="['flex flex-col rounded-2xl border border-solid p-4', colorClasses]">
<div class="flex items-start gap-2">
<div class="flex flex-1 gap-3 items-start">
<component :is="icon" :class="['size-6 shrink-0', iconClass]" />
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast">{{ title }}</span>
<div v-if="createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(createdAt) }}</span>
</div>
</div>
<span class="text-contrast opacity-80">{{ description }}</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<ButtonStyled v-if="showCancel" type="outlined" color="blue">
<button class="!border" @click="emit('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="showRetry" color="red">
<button @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="showDismiss"
circular
type="transparent"
hover-color-fill="background"
:color="buttonColor"
>
<button @click="emit('dismiss')">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="showProgress" class="mt-4 pl-9">
<ProgressBar :progress="progress" color="blue" :waiting="progress === 0" full-width />
</div>
</div>
</template>

View File

@@ -0,0 +1,226 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, watch } from 'vue'
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
import type { BackupProgressEntry } from '../../../providers/server-context'
import BackupProgressAdmonition from './BackupProgressAdmonition.vue'
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { serverId, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext()
const backupsQueryKey = ['backups', 'list', serverId]
const { data: backupsList } = useQuery({
queryKey: backupsQueryKey,
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
enabled: computed(() => !!worldId.value),
})
interface TerminalEntry {
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
backupName?: string
createdAt?: string
}
interface AdmonitionEntry {
key: string
backupId: string
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
progress: number
name?: string
createdAt?: string
}
const terminalEntries = reactive(new Map<string, TerminalEntry>())
const dismissedIds = reactive(new Set<string>())
function findBackup(backupId: string) {
return backupsList.value?.find((b) => b.id === backupId)
}
watch(
() => [...backupsState.entries()] as [string, BackupProgressEntry][],
(entries) => {
for (const [id, entry] of entries) {
const backup = findBackup(id)
if (entry.create?.state === 'failed') {
terminalEntries.set(`${id}:create`, {
type: 'create',
state: 'failed',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
if (entry.restore?.state === 'done') {
terminalEntries.set(`${id}:restore`, {
type: 'restore',
state: 'done',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
if (entry.restore?.state === 'failed') {
terminalEntries.set(`${id}:restore`, {
type: 'restore',
state: 'failed',
backupName: backup?.name,
createdAt: backup?.created_at,
})
}
}
},
{ deep: true },
)
const admonitions = computed<AdmonitionEntry[]>(() => {
const result: AdmonitionEntry[] = []
const seenIds = new Set<string>()
// 1. Active WS entries (real-time progress from backupsState)
for (const [id, entry] of backupsState.entries()) {
const backup = findBackup(id)
if (entry.create && entry.create.state === 'ongoing') {
const key = `${id}:create`
if (!dismissedIds.has(key)) {
seenIds.add(id)
result.push({
key,
backupId: id,
type: 'create',
state: entry.create.state,
progress: entry.create.progress,
name: backup?.name,
createdAt: backup?.created_at,
})
}
}
if (entry.restore && entry.restore.state === 'ongoing') {
const key = `${id}:restore`
if (!dismissedIds.has(key)) {
seenIds.add(id)
result.push({
key,
backupId: id,
type: 'restore',
state: entry.restore.state,
progress: entry.restore.progress,
name: backup?.name,
createdAt: backup?.created_at,
})
}
}
}
// 2. REST-based entries for pending/in_progress backups without WS data yet
if (backupsList.value) {
for (const backup of backupsList.value) {
if (seenIds.has(backup.id)) continue
if (backup.status === 'pending' || backup.status === 'in_progress') {
const key = `${backup.id}:create`
if (!dismissedIds.has(key)) {
result.push({
key,
backupId: backup.id,
type: 'create',
state: 'ongoing',
progress: 0,
name: backup.name,
createdAt: backup.created_at,
})
}
}
}
}
// 3. Terminal entries (snapshotted before cleanup)
for (const [key, entry] of terminalEntries.entries()) {
if (dismissedIds.has(key)) continue
if (result.some((r) => r.key === key)) continue
const backupId = key.split(':')[0]
const backup = findBackup(backupId)
result.push({
key,
backupId,
type: entry.type,
state: entry.state,
progress: entry.state === 'done' ? 1 : 0,
name: backup?.name ?? entry.backupName,
createdAt: backup?.created_at ?? entry.createdAt,
})
}
return result
})
function handleCancel(backupId: string) {
client.archon.backups_v1.delete(serverId, worldId.value!, backupId).then(() => {
markBackupCancelled(backupId)
backupsState.delete(backupId)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
})
}
function handleRetry(backupId: string, key: string) {
client.archon.backups_v1.retry(serverId, worldId.value!, backupId).then(() => {
terminalEntries.delete(key)
dismissedIds.delete(key)
queryClient.invalidateQueries({ queryKey: backupsQueryKey })
})
}
function handleDismiss(key: string) {
dismissedIds.add(key)
terminalEntries.delete(key)
}
</script>
<template>
<TransitionGroup
v-if="admonitions.length > 0"
name="backup-admonition"
tag="div"
class="flex flex-col gap-3"
>
<BackupProgressAdmonition
v-for="item in admonitions"
:key="item.key"
:type="item.type"
:state="item.state"
:progress="item.progress"
:backup-name="item.name"
:created-at="item.createdAt"
@cancel="handleCancel(item.backupId)"
@retry="handleRetry(item.backupId, item.key)"
@dismiss="handleDismiss(item.key)"
/>
</TransitionGroup>
</template>
<style scoped>
.backup-admonition-enter-active,
.backup-admonition-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.backup-admonition-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.backup-admonition-leave-to {
opacity: 0;
transform: translateY(-10px);
}
.backup-admonition-move {
transition: transform 300ms ease-in-out;
}
</style>

View File

@@ -1,17 +1,17 @@
<template>
<NewModal ref="modal" header="Restore backup" fade="danger">
<div class="flex flex-col gap-6 max-w-[600px]">
<div class="flex flex-col gap-6 max-w-[400px]">
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
Stop the server before restoring a backup.
</Admonition>
<!-- TODO: Worlds: Replace "server" with "world" -->
<Admonition v-else type="warning" header="Restore warning">
This will overwrite all files in the server and replace them with the files from the backup.
<Admonition v-else type="critical" header="Restore warning">
Restoring your server will replace the current world and server files. Any changes made
since that backup will be permanently lost.
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2" />
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
</div>
</div>

View File

@@ -1,6 +1,8 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue'
export { default as BackupProgressAdmonition } from './BackupProgressAdmonition.vue'
export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
export { default as BackupWarning } from './BackupWarning.vue'

View File

@@ -144,6 +144,10 @@ const messages = defineMessages({
id: 'content.page-layout.busy-description',
defaultMessage: 'Please wait for the operation to complete before editing content.',
},
pleaseWait: {
id: 'content.page-layout.please-wait',
defaultMessage: 'Please wait',
},
})
const ctx = injectContentManager()
@@ -493,7 +497,11 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled || ctx.isBusy.value"
:disabled-text="ctx.modpack.value.disabledText"
:disabled-text="
ctx.modpack.value.disabledText ??
ctx.busyMessage?.value ??
(ctx.isBusy.value ? formatMessage(messages.pleaseWait) : undefined)
"
:show-content-hint="
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
"

View File

@@ -789,14 +789,21 @@ provideContentManager({
isBusy: computed(() => busyReasons.value.length > 0),
busyMessage: computed(() => {
const bannerCoversInstalling = server.value?.status === 'installing' || isSyncingContent.value
const nonBannerReasons = bannerCoversInstalling
? busyReasons.value.filter(
(r) =>
r.reason.id !== 'servers.busy.installing' &&
r.reason.id !== 'servers.busy.syncing-content',
)
: busyReasons.value
return nonBannerReasons.length > 0 ? formatMessage(nonBannerReasons[0].reason) : null
const filteredReasons = busyReasons.value.filter((r) => {
if (
bannerCoversInstalling &&
(r.reason.id === 'servers.busy.installing' ||
r.reason.id === 'servers.busy.syncing-content')
)
return false
if (
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring'
)
return false
return true
})
return filteredReasons.length > 0 ? formatMessage(filteredReasons[0].reason) : null
}),
getItemId: (item) => item.file_name,
contentTypeLabel: type,

View File

@@ -21,8 +21,8 @@
</div>
<div v-else key="content" class="contents">
<Admonition v-if="serverBusy" type="warning" class="mb-5">
<template #header>{{ busyTooltip }}</template>
<Admonition v-if="nonBackupBusyReasons.length > 0" type="warning" class="mb-5">
<template #header>{{ formatMessage(nonBackupBusyReasons[0].reason) }}</template>
File operations are disabled while the operation is in progress.
</Admonition>
<div class="relative flex w-full flex-col">
@@ -327,6 +327,13 @@ const serverBusy = computed(() => busyReasons.value.length > 0)
const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
)
const nonBackupBusyReasons = computed(() =>
busyReasons.value.filter(
(r) =>
r.reason.id !== 'servers.busy.backup-creating' &&
r.reason.id !== 'servers.busy.backup-restoring',
),
)
const queryClient = useQueryClient()
interface BaseOperation {

View File

@@ -335,6 +335,9 @@
"content.page-layout.no-content-found": {
"defaultMessage": "No content found."
},
"content.page-layout.please-wait": {
"defaultMessage": "Please wait"
},
"content.page-layout.search-placeholder": {
"defaultMessage": "Search {count} {contentType}..."
},
@@ -1814,15 +1817,57 @@
"search.filter_type.shader_loader": {
"defaultMessage": "Loader"
},
"servers.backups.admonition.backup-failed.description": {
"defaultMessage": "Something went wrong while creating {backupName}. Please try again or contact support if the issue continues."
},
"servers.backups.admonition.backup-failed.title": {
"defaultMessage": "Backup failed"
},
"servers.backups.admonition.backup-queued.description": {
"defaultMessage": "{backupName} is queued and will start shortly."
},
"servers.backups.admonition.backup-queued.title": {
"defaultMessage": "Backup queued"
},
"servers.backups.admonition.creating-backup.description": {
"defaultMessage": "Saving world data and server configuration for {backupName}. This can take a few minutes."
},
"servers.backups.admonition.creating-backup.title": {
"defaultMessage": "Creating backup"
},
"servers.backups.admonition.fallback-name": {
"defaultMessage": "your backup"
},
"servers.backups.admonition.restore-failed.description": {
"defaultMessage": "Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues."
},
"servers.backups.admonition.restore-failed.title": {
"defaultMessage": "Restoring from backup failed"
},
"servers.backups.admonition.restore-queued.description": {
"defaultMessage": "Restoring from {backupName} is queued and will start shortly."
},
"servers.backups.admonition.restore-queued.title": {
"defaultMessage": "Restoring from backup queued"
},
"servers.backups.admonition.restore-successful.description": {
"defaultMessage": "Your server has been restored to {backupName} and is ready to start."
},
"servers.backups.admonition.restore-successful.title": {
"defaultMessage": "Restoring from backup successful"
},
"servers.backups.admonition.restoring-backup.description": {
"defaultMessage": "Restoring your server from {backupName}. This may take a couple of minutes."
},
"servers.backups.admonition.restoring-backup.title": {
"defaultMessage": "Restoring from backup"
},
"servers.backups.item.auto": {
"defaultMessage": "Auto"
},
"servers.backups.item.backup-schedule": {
"defaultMessage": "Backup schedule"
},
"servers.backups.item.creating-backup": {
"defaultMessage": "Creating backup..."
},
"servers.backups.item.failed-to-create-backup": {
"defaultMessage": "Failed to create backup"
},
@@ -1832,21 +1877,12 @@
"servers.backups.item.manual-backup": {
"defaultMessage": "Manual backup"
},
"servers.backups.item.queued-for-backup": {
"defaultMessage": "Backup queued"
},
"servers.backups.item.queued-for-restore": {
"defaultMessage": "Restore queued"
},
"servers.backups.item.rename": {
"defaultMessage": "Rename"
},
"servers.backups.item.restore": {
"defaultMessage": "Restore"
},
"servers.backups.item.restoring-backup": {
"defaultMessage": "Restoring from backup..."
},
"servers.notice.dismiss": {
"defaultMessage": "Dismiss"
},

View File

@@ -24,6 +24,7 @@ export const AllTypes: Story = {
<Admonition type="info" header="Info" body="This is an informational message." />
<Admonition type="warning" header="Warning" body="This is a warning message." />
<Admonition type="critical" header="Critical" body="This is a critical message." />
<Admonition type="success" header="Success" body="This operation completed successfully." />
</div>
`,
}),
@@ -36,3 +37,11 @@ export const WithHeader: Story = {
body: 'Please read this carefully before proceeding.',
},
}
export const Success: Story = {
args: {
type: 'success',
header: 'Operation Complete',
body: 'Everything went smoothly.',
},
}

View File

@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BackupProgressAdmonition from '../../components/servers/backups/BackupProgressAdmonition.vue'
const meta = {
title: 'Servers/BackupProgressAdmonition',
component: BackupProgressAdmonition,
parameters: {
layout: 'padded',
},
} satisfies Meta<typeof BackupProgressAdmonition>
export default meta
type Story = StoryObj<typeof meta>
const justNow = new Date().toISOString()
const eightMinsAgo = new Date(Date.now() - 8 * 60 * 1000).toISOString()
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
export const AllStates: Story = {
render: () => ({
components: { BackupProgressAdmonition },
setup() {
const now = new Date().toISOString()
const mins8 = new Date(Date.now() - 8 * 60 * 1000).toISOString()
const hours5 = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
return { now, mins8, hours5 }
},
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 1020px;">
<h3 style="margin: 0; color: var(--color-contrast);">Backup Creation</h3>
<BackupProgressAdmonition type="create" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
<BackupProgressAdmonition type="create" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
<BackupProgressAdmonition type="create" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
<h3 style="margin: 1rem 0 0; color: var(--color-contrast);">Backup Restoration</h3>
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
<BackupProgressAdmonition type="restore" state="done" :progress="1" backup-name="World Backup 1" :created-at="hours5" />
<BackupProgressAdmonition type="restore" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
</div>
`,
}),
}
export const BackupQueued: Story = {
args: {
type: 'create',
state: 'ongoing',
progress: 0,
backupName: 'World Backup 1',
createdAt: justNow,
},
}
export const CreatingBackup: Story = {
args: {
type: 'create',
state: 'ongoing',
progress: 0.33,
backupName: 'World Backup 1',
createdAt: eightMinsAgo,
},
}
export const BackupFailed: Story = {
args: {
type: 'create',
state: 'failed',
progress: 0,
backupName: 'World Backup 1',
createdAt: fiveHoursAgo,
},
}
export const RestoreQueued: Story = {
args: {
type: 'restore',
state: 'ongoing',
progress: 0,
backupName: 'World Backup 1',
createdAt: justNow,
},
}
export const RestoringBackup: Story = {
args: {
type: 'restore',
state: 'ongoing',
progress: 0.33,
backupName: 'World Backup 1',
createdAt: eightMinsAgo,
},
}
export const RestoreSuccessful: Story = {
args: {
type: 'restore',
state: 'done',
progress: 1,
backupName: 'World Backup 1',
createdAt: fiveHoursAgo,
},
}
export const RestoreFailed: Story = {
args: {
type: 'restore',
state: 'failed',
progress: 0,
backupName: 'World Backup 1',
createdAt: fiveHoursAgo,
},
}

View File

@@ -4,6 +4,7 @@ import {
BracesIcon,
CalendarIcon,
CardIcon,
CheckCircleIcon,
CurrencyIcon,
DiscordIcon,
FileArchiveIcon,
@@ -66,6 +67,7 @@ export const SEVERITY_ICONS: Record<string, Component> = {
warning: IssuesIcon,
error: XCircleIcon,
critical: XCircleIcon,
success: CheckCircleIcon,
}
export const PROJECT_STATUS_ICONS: Record<ProjectStatus, Component> = {