feat: backups page cleanup before worlds (#5844)
* feat: card alignment + fix modals * feat: change admon title in restore alert modal * fix: lint * feat: backups queue api into api-client * feat: impl backup queue api endpoints into frontend * feat: ack fix * feat: bulk actions * feat: bulk delete impl * fix: lint * fix: align error states * fix: transition group * feat: ready for qa * fix: lint * feat: qa * feat: stacked admonitions component * fix: issues with stacking * feat: hook up admonition stacking + fix app csp for staging kyros nodes * fix: logs.vue * qa: close stack on admonitions click * fix: all problems with stacked admonitions * qa: admonition cleanup and copy overhaul draft * fix: qa issues padding * fix: padding bug * feat: qa * fix: intercom in app csp bug * fix: positioning intercom * feat: loading overlay on top of console + admon consistency changes * feat: scroll indicator fade in backup delete modal + admon timestamp fix * feat: move action bar behind modal * fix: lint + i18n * fix: server ping spam on filter (cache but clear on unmount) * fix: 1 admon fade in flicker issue * chore: temp staging undo * qa: changes * fix: lint * chore: revert staging to use staging * fix: scoping
This commit is contained in:
@@ -1,72 +1,95 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'relative flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
|
||||
'relative grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-2 rounded-2xl border border-solid p-4 text-contrast',
|
||||
progress != null ? 'overflow-hidden pb-5' : '',
|
||||
typeClasses[type],
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||
<component :is="getSeverityIcon(type)" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
|
||||
</slot>
|
||||
<div class="col-start-2 flex min-w-0 flex-1 flex-col gap-2">
|
||||
<div
|
||||
:class="[
|
||||
'flex flex-1 gap-2',
|
||||
header || $slots.header ? 'flex-col items-start' : 'items-center',
|
||||
(dismissible || $slots['top-right-actions']) && 'pr-8',
|
||||
]"
|
||||
v-if="header || $slots.header || normalizedTimestamp"
|
||||
class="flex flex-wrap items-center gap-2 text-lg font-bold leading-6"
|
||||
>
|
||||
<div
|
||||
class="flex gap-2 items-start"
|
||||
:class="header || $slots.header ? 'w-full' : 'contents'"
|
||||
<slot name="header">{{ header }}</slot>
|
||||
<span
|
||||
v-if="normalizedTimestamp"
|
||||
v-tooltip="timestampTooltip"
|
||||
class="flex items-center gap-1.5 text-base font-medium leading-normal text-secondary"
|
||||
>
|
||||
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
|
||||
<component
|
||||
:is="getSeverityIcon(type)"
|
||||
:class="['h-6 w-6 flex-none', iconClasses[type]]"
|
||||
/>
|
||||
</slot>
|
||||
<div v-if="header || $slots.header" class="font-semibold text-base">
|
||||
<slot name="header">{{ header }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-normal text-contrast/80" :class="!(header || $slots.header) && 'flex-1'">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
<ClockIcon class="size-4" />
|
||||
{{ relativeTimeLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="$slots['top-right-actions']" class="flex shrink-0 items-center gap-2">
|
||||
<slot name="top-right-actions" />
|
||||
<div class="font-normal text-contrast/85">
|
||||
<slot>{{ body }}</slot>
|
||||
</div>
|
||||
<div v-if="showActionsUnderneath || $slots.actions" class="mt-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots['top-right-actions'] || dismissible"
|
||||
class="col-start-3 row-start-1 flex shrink-0 items-center gap-2 self-start"
|
||||
>
|
||||
<slot name="top-right-actions" />
|
||||
<ButtonStyled
|
||||
v-else-if="dismissible"
|
||||
v-if="dismissible"
|
||||
circular
|
||||
type="highlight-colored-text"
|
||||
type="transparent"
|
||||
:color="buttonColors[type]"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
|
||||
<XIcon class="h-4 w-4" />
|
||||
<button type="button" aria-label="Dismiss" @click="$emit('dismiss')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="$slots.progress">
|
||||
<slot name="progress" />
|
||||
</div>
|
||||
<div v-if="showActionsUnderneath || $slots.actions">
|
||||
<slot name="actions" />
|
||||
<div
|
||||
v-if="progress != null"
|
||||
class="absolute inset-x-0 bottom-0 h-1 overflow-hidden"
|
||||
:class="progressTrackClasses[type]"
|
||||
role="progressbar"
|
||||
:aria-valuenow="waiting ? undefined : Math.round(normalizedProgress * 100)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
>
|
||||
<div
|
||||
class="h-full rounded-r-full transition-[width] duration-200 ease-in-out"
|
||||
:class="[
|
||||
progressFillClasses[progressColor ?? type],
|
||||
{ 'admonition-progress--waiting': waiting },
|
||||
]"
|
||||
:style="waiting ? undefined : { width: `${normalizedProgress * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ClockIcon, XIcon } from '@modrinth/assets'
|
||||
import { useNow } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useRelativeTime } from '../../composables'
|
||||
import { getSeverityIcon } from '../../utils'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
withDefaults(
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
type?: 'info' | 'warning' | 'critical' | 'success'
|
||||
header?: string
|
||||
body?: string
|
||||
showActionsUnderneath?: boolean
|
||||
dismissible?: boolean
|
||||
progress?: number
|
||||
progressColor?: 'info' | 'warning' | 'critical' | 'success' | 'blue' | 'green' | 'red'
|
||||
waiting?: boolean
|
||||
/** Accepts a Date, an ISO string, or a millisecond Unix timestamp. */
|
||||
timestamp?: Date | string | number
|
||||
}>(),
|
||||
{
|
||||
type: 'info',
|
||||
@@ -74,6 +97,10 @@ withDefaults(
|
||||
body: '',
|
||||
showActionsUnderneath: false,
|
||||
dismissible: false,
|
||||
progress: undefined,
|
||||
progressColor: undefined,
|
||||
waiting: false,
|
||||
timestamp: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -81,6 +108,34 @@ defineEmits<{
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const relativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
dateStyle: 'long',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
const now = useNow({ interval: 1000 })
|
||||
|
||||
const normalizedProgress = computed(() => Math.min(Math.max(props.progress ?? 0, 0), 1))
|
||||
|
||||
const normalizedTimestamp = computed(() => {
|
||||
const t = props.timestamp
|
||||
if (t == null) return null
|
||||
if (t instanceof Date) return t.toISOString()
|
||||
if (typeof t === 'number') return new Date(t).toISOString()
|
||||
return t
|
||||
})
|
||||
|
||||
const relativeTimeLabel = computed(() => {
|
||||
void now.value
|
||||
const t = normalizedTimestamp.value
|
||||
return t ? relativeTime(t) : ''
|
||||
})
|
||||
|
||||
const timestampTooltip = computed(() => {
|
||||
const t = normalizedTimestamp.value
|
||||
return t ? formatDateTime(t) : ''
|
||||
})
|
||||
|
||||
const typeClasses = {
|
||||
info: 'border-brand-blue bg-bg-blue',
|
||||
warning: 'border-brand-orange bg-bg-orange',
|
||||
@@ -95,10 +150,45 @@ const iconClasses = {
|
||||
success: 'text-brand-green',
|
||||
}
|
||||
|
||||
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
|
||||
const buttonColors = {
|
||||
info: 'blue',
|
||||
warning: 'orange',
|
||||
critical: 'red',
|
||||
success: 'green',
|
||||
} as const
|
||||
|
||||
const progressTrackClasses = {
|
||||
info: 'bg-brand-blue/20',
|
||||
warning: 'bg-brand-orange/20',
|
||||
critical: 'bg-brand-red/20',
|
||||
success: 'bg-brand-green/20',
|
||||
}
|
||||
|
||||
const progressFillClasses = {
|
||||
info: 'bg-brand-blue',
|
||||
warning: 'bg-brand-orange',
|
||||
critical: 'bg-brand-red',
|
||||
success: 'bg-brand-green',
|
||||
blue: 'bg-brand-blue',
|
||||
green: 'bg-brand-green',
|
||||
red: 'bg-brand-red',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admonition-progress--waiting {
|
||||
animation: admonition-progress-waiting 1s linear infinite;
|
||||
position: relative;
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
@keyframes admonition-progress-waiting {
|
||||
0% {
|
||||
left: -20%;
|
||||
}
|
||||
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
>
|
||||
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
|
||||
<div ref="containerRef" class="size-full" />
|
||||
<Transition name="terminal-loading-fade">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="pointer-events-none absolute inset-0 z-20 animate-bpulse bg-surface-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Transition>
|
||||
<div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10">
|
||||
<ButtonStyled circular type="highlight" size="large">
|
||||
<button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom">
|
||||
@@ -46,6 +53,7 @@ const props = withDefaults(
|
||||
disableInput?: boolean
|
||||
fullscreen?: boolean
|
||||
emptyStateType?: 'server' | 'instance'
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
scrollback: Infinity,
|
||||
@@ -53,6 +61,7 @@ const props = withDefaults(
|
||||
disableInput: false,
|
||||
fullscreen: false,
|
||||
emptyStateType: undefined,
|
||||
loading: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -230,6 +239,15 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@keyframes bpulse {
|
||||
50% {
|
||||
filter: brightness(75%);
|
||||
}
|
||||
}
|
||||
.animate-bpulse {
|
||||
animation: bpulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
height: 100% !important;
|
||||
}
|
||||
@@ -269,4 +287,14 @@ defineExpose({
|
||||
border-radius: 8px !important;
|
||||
contain: layout style !important;
|
||||
}
|
||||
|
||||
.terminal-loading-fade-enter-active,
|
||||
.terminal-loading-fade-leave-active {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.terminal-loading-fade-enter-from,
|
||||
.terminal-loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness]'
|
||||
"
|
||||
:aria-label="description || label"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="description || label || undefined"
|
||||
:aria-checked="indeterminate ? 'mixed' : modelValue"
|
||||
role="checkbox"
|
||||
@click="toggle"
|
||||
>
|
||||
@@ -25,7 +25,7 @@
|
||||
<CheckIcon v-else-if="modelValue" aria-hidden="true" stroke-width="3" />
|
||||
</span>
|
||||
<!-- aria-hidden is set so screenreaders only use the <button>'s aria-label -->
|
||||
<span v-if="label" aria-hidden="true">
|
||||
<span v-if="label" :class="labelClass" aria-hidden="true">
|
||||
{{ label }}
|
||||
</span>
|
||||
<slot v-else />
|
||||
@@ -33,6 +33,7 @@
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, MinusIcon } from '@modrinth/assets'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
@@ -41,6 +42,7 @@ const emit = defineEmits<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string
|
||||
labelClass?: HTMLAttributes['class']
|
||||
disabled?: boolean
|
||||
description?: string
|
||||
modelValue: boolean
|
||||
@@ -49,6 +51,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
label: '',
|
||||
labelClass: '',
|
||||
disabled: false,
|
||||
description: '',
|
||||
modelValue: false,
|
||||
|
||||
@@ -12,7 +12,7 @@ const toolbarEl = ref<HTMLElement | null>(null)
|
||||
const compact = ref(false)
|
||||
|
||||
const { stackCount } = useModalStack()
|
||||
const zIndex = computed(() => 100 + stackCount.value * 10 + 31)
|
||||
const zIndex = computed(() => 100 + stackCount.value * 10 + 8)
|
||||
|
||||
function checkCompact() {
|
||||
const el = toolbarEl.value
|
||||
|
||||
617
packages/ui/src/components/base/StackedAdmonitions.vue
Normal file
617
packages/ui/src/components/base/StackedAdmonitions.vue
Normal file
@@ -0,0 +1,617 @@
|
||||
<script lang="ts"></script>
|
||||
|
||||
<script setup lang="ts" generic="ItemType extends StackedAdmonitionItem">
|
||||
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
|
||||
import { AnimatePresence, Motion } from 'motion-v'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, useAttrs, useId, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
export type StackedAdmonitionType = 'info' | 'warning' | 'critical' | 'success'
|
||||
|
||||
/** Extend this interface to attach arbitrary per-item data consumed in the #item slot. */
|
||||
export interface StackedAdmonitionItem {
|
||||
id: string
|
||||
type: StackedAdmonitionType
|
||||
dismissible?: boolean
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
items: ItemType[]
|
||||
peek?: number
|
||||
hoverPeek?: number
|
||||
expandedGap?: number
|
||||
scaleStep?: number
|
||||
hoverScaleStep?: number
|
||||
maxVisibleBehind?: number
|
||||
dismissAllEnabled?: boolean
|
||||
expanded?: boolean
|
||||
}>(),
|
||||
{
|
||||
peek: 8,
|
||||
hoverPeek: 16,
|
||||
expandedGap: 12,
|
||||
scaleStep: 0.04,
|
||||
hoverScaleStep: 0.025,
|
||||
maxVisibleBehind: 2,
|
||||
dismissAllEnabled: true,
|
||||
expanded: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'dismiss-all': []
|
||||
'update:expanded': [value: boolean]
|
||||
expand: []
|
||||
collapse: []
|
||||
}>()
|
||||
|
||||
defineSlots<{
|
||||
item(props: {
|
||||
item: ItemType
|
||||
index: number
|
||||
isFront: boolean
|
||||
expanded: boolean
|
||||
/** Whether the consumer should render the Admonition's own dismiss button. */
|
||||
dismissible: boolean
|
||||
}): unknown
|
||||
'header-label'(props: { count: number; expanded: boolean }): unknown
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const stackId = useId()
|
||||
const attrs = useAttrs()
|
||||
|
||||
const internalExpanded = ref(false)
|
||||
const isHovered = ref(false)
|
||||
const prefersReducedMotion = ref(false)
|
||||
const initialMeasurementSettled = ref(false)
|
||||
const enteringItemIds = ref<Set<string>>(new Set())
|
||||
const actionBarHeight = ref(0)
|
||||
|
||||
const heights = ref<Record<string, number>>({})
|
||||
const cardEls = new Map<string, HTMLElement>()
|
||||
const observers = new Map<string, ResizeObserver>()
|
||||
const pendingHeights = new Map<string, number>()
|
||||
let flushHandle: number | null = null
|
||||
let initialMeasurementHandle: number | null = null
|
||||
let enteringHandle: number | null = null
|
||||
let actionBarObserver: ResizeObserver | null = null
|
||||
|
||||
// Slot content may run effects, so measure the one real tree instead of mounting
|
||||
// hidden duplicates just to discover natural card heights.
|
||||
function scheduleHeightFlush() {
|
||||
if (flushHandle != null) return
|
||||
flushHandle = requestAnimationFrame(() => {
|
||||
flushHandle = null
|
||||
if (pendingHeights.size === 0) return
|
||||
const next = { ...heights.value }
|
||||
let changed = false
|
||||
for (const [id, h] of pendingHeights) {
|
||||
if (next[id] !== h) {
|
||||
next[id] = h
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
pendingHeights.clear()
|
||||
if (changed) {
|
||||
heights.value = next
|
||||
if (!initialMeasurementSettled.value && initialMeasurementHandle == null) {
|
||||
initialMeasurementHandle = requestAnimationFrame(() => {
|
||||
initialMeasurementHandle = null
|
||||
initialMeasurementSettled.value = true
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const isExpanded = computed(() => {
|
||||
if (props.items.length <= 1) return false
|
||||
return props.expanded ?? internalExpanded.value
|
||||
})
|
||||
const hasActionBar = computed(() => props.items.length >= 2)
|
||||
function itemDismissible(item: ItemType) {
|
||||
return item.dismissible ?? true
|
||||
}
|
||||
|
||||
type StackPhase = 'collapsed' | 'expanding' | 'expanded' | 'collapsing'
|
||||
|
||||
const phase = ref<StackPhase>(isExpanded.value ? 'expanded' : 'collapsed')
|
||||
const isSettledCollapsed = computed(() => phase.value === 'collapsed')
|
||||
const containerHeightSettled = ref(true)
|
||||
const singleItemEntrance = ref(false)
|
||||
|
||||
// Behind cards morph between a collapsed placeholder and real content. The shell
|
||||
// height owns that morph so mixed-height cards do not swap DOM midway through motion.
|
||||
function measuredCardHeight(index: number) {
|
||||
const item = props.items[index]
|
||||
return item ? (heights.value[item.id] ?? 0) : 0
|
||||
}
|
||||
|
||||
function hasMeasuredCard(index: number) {
|
||||
const item = props.items[index]
|
||||
return !!item && heights.value[item.id] != null
|
||||
}
|
||||
|
||||
const frontCardHeight = computed(() => measuredCardHeight(0))
|
||||
|
||||
const hasBehind = computed(() => props.items.length > 1)
|
||||
|
||||
function currentPeek() {
|
||||
return isHovered.value ? props.hoverPeek : props.peek
|
||||
}
|
||||
|
||||
function currentScaleStep() {
|
||||
return isHovered.value ? props.hoverScaleStep : props.scaleStep
|
||||
}
|
||||
|
||||
function targetCardHeight(index: number) {
|
||||
if (index === 0) return measuredCardHeight(0)
|
||||
|
||||
const measured = measuredCardHeight(index) || frontCardHeight.value
|
||||
return isExpanded.value ? measured : frontCardHeight.value
|
||||
}
|
||||
|
||||
const containerHeight = computed(() => {
|
||||
if (isExpanded.value) {
|
||||
return props.items.reduce((acc, _, i) => {
|
||||
return acc + measuredCardHeight(i) + (i > 0 ? props.expandedGap : 0)
|
||||
}, 0)
|
||||
}
|
||||
if (!hasBehind.value) return frontCardHeight.value
|
||||
const behind = Math.min(props.items.length - 1, props.maxVisibleBehind)
|
||||
const pad = isHovered.value ? 6 : 0
|
||||
return frontCardHeight.value + currentPeek() * behind + pad
|
||||
})
|
||||
|
||||
const stackShellHeight = computed(() => {
|
||||
return containerHeight.value + (hasActionBar.value ? actionBarHeight.value : 0)
|
||||
})
|
||||
const containerOverflow = computed(() => {
|
||||
if (isExpanded.value) return 'visible'
|
||||
if (!containerHeightSettled.value) return 'hidden'
|
||||
if (!hasBehind.value && hasMeasuredCard(0)) return 'visible'
|
||||
return 'hidden'
|
||||
})
|
||||
|
||||
const springTransition = computed(() =>
|
||||
prefersReducedMotion.value || !initialMeasurementSettled.value
|
||||
? { duration: 0 }
|
||||
: { type: 'spring' as const, stiffness: 260, damping: 32 },
|
||||
)
|
||||
const heightTransition = computed(() =>
|
||||
singleItemEntrance.value ? { duration: 0.12, ease: 'easeOut' as const } : springTransition.value,
|
||||
)
|
||||
|
||||
const exitTransition = computed(() =>
|
||||
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.18 },
|
||||
)
|
||||
|
||||
const shellExitTransition = computed(() =>
|
||||
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.16 },
|
||||
)
|
||||
|
||||
function collapsedCardPosition(index: number) {
|
||||
const hidden = index > props.maxVisibleBehind
|
||||
return {
|
||||
y: index * currentPeek(),
|
||||
scale: Math.max(0.8, 1 - index * currentScaleStep()),
|
||||
opacity: hidden ? 0 : 1,
|
||||
}
|
||||
}
|
||||
|
||||
function expandedCardPosition(index: number) {
|
||||
let y = 0
|
||||
for (let i = 0; i < index; i++) {
|
||||
y += measuredCardHeight(i) + props.expandedGap
|
||||
}
|
||||
return { y, scale: 1, opacity: 1 }
|
||||
}
|
||||
|
||||
function cardPosition(index: number) {
|
||||
const position = isExpanded.value ? expandedCardPosition(index) : collapsedCardPosition(index)
|
||||
const item = props.items[index]
|
||||
if (index === 0 && singleItemEntrance.value) {
|
||||
return {
|
||||
...position,
|
||||
opacity: 0,
|
||||
}
|
||||
}
|
||||
if (!item || !enteringItemIds.value.has(item.id)) return position
|
||||
|
||||
return {
|
||||
...position,
|
||||
y: position.y + 8,
|
||||
opacity: 0,
|
||||
scale: Math.min(1, position.scale + 0.02),
|
||||
}
|
||||
}
|
||||
|
||||
function contentOpacity(index: number) {
|
||||
return isExpanded.value && hasMeasuredCard(index) ? 1 : 0
|
||||
}
|
||||
|
||||
// Newly inserted cards need an explicit two-frame enter target because Motion's
|
||||
// initial state is disabled to avoid animating from zero-height on first mount.
|
||||
function markEntering(ids: string[]) {
|
||||
if (!initialMeasurementSettled.value || prefersReducedMotion.value || ids.length === 0) return
|
||||
|
||||
const next = new Set(enteringItemIds.value)
|
||||
for (const id of ids) next.add(id)
|
||||
enteringItemIds.value = next
|
||||
|
||||
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
|
||||
enteringHandle = requestAnimationFrame(() => {
|
||||
enteringHandle = requestAnimationFrame(() => {
|
||||
enteringHandle = null
|
||||
enteringItemIds.value = new Set()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function onContainerAnimationComplete() {
|
||||
phase.value = isExpanded.value ? 'expanded' : 'collapsed'
|
||||
containerHeightSettled.value = true
|
||||
if (containerHeight.value > 0) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const containerMotionProps = computed(() => ({
|
||||
onAnimationComplete: onContainerAnimationComplete,
|
||||
}))
|
||||
|
||||
function resolveNode(el: unknown): HTMLElement | null {
|
||||
if (!el) return null
|
||||
if (el instanceof HTMLElement) return el
|
||||
if (typeof el === 'object' && '$el' in el) {
|
||||
const node = (el as { $el: unknown }).$el
|
||||
return node instanceof HTMLElement ? node : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function setCardRef(id: string, el: unknown) {
|
||||
const node = resolveNode(el)
|
||||
if (!node) return
|
||||
pendingHeights.set(id, node.offsetHeight)
|
||||
scheduleHeightFlush()
|
||||
if (cardEls.get(id) === node) return
|
||||
observers.get(id)?.disconnect()
|
||||
cardEls.set(id, node)
|
||||
const ro = new ResizeObserver(() => {
|
||||
pendingHeights.set(id, node.offsetHeight)
|
||||
scheduleHeightFlush()
|
||||
})
|
||||
ro.observe(node)
|
||||
observers.set(id, ro)
|
||||
}
|
||||
|
||||
function setActionBarRef(el: unknown) {
|
||||
const node = resolveNode(el)
|
||||
actionBarObserver?.disconnect()
|
||||
actionBarObserver = null
|
||||
if (!node) {
|
||||
actionBarHeight.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
actionBarHeight.value = node.offsetHeight
|
||||
const ro = new ResizeObserver(() => {
|
||||
actionBarHeight.value = node.offsetHeight
|
||||
})
|
||||
ro.observe(node)
|
||||
actionBarObserver = ro
|
||||
}
|
||||
|
||||
function setExpanded(v: boolean) {
|
||||
internalExpanded.value = v
|
||||
emit('update:expanded', v)
|
||||
if (v) emit('expand')
|
||||
else emit('collapse')
|
||||
}
|
||||
|
||||
function openStack() {
|
||||
if (props.items.length <= 1 || isExpanded.value) return
|
||||
phase.value = 'expanding'
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
function closeStack() {
|
||||
if (!isExpanded.value) return
|
||||
phase.value = 'collapsing'
|
||||
setExpanded(false)
|
||||
}
|
||||
|
||||
function toggleExpanded() {
|
||||
if (props.items.length <= 1) return
|
||||
if (isExpanded.value) closeStack()
|
||||
else openStack()
|
||||
}
|
||||
|
||||
function isInteractiveTarget(target: HTMLElement | null, currentTarget: EventTarget | null) {
|
||||
if (!target) return false
|
||||
const interactive = target.closest(
|
||||
'button, a, input, select, textarea, summary, [role="button"], [role="link"]',
|
||||
)
|
||||
return !!interactive && interactive !== currentTarget
|
||||
}
|
||||
|
||||
function onContainerClick(e: MouseEvent) {
|
||||
if (isExpanded.value || props.items.length <= 1) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (isInteractiveTarget(target, e.currentTarget)) return
|
||||
openStack()
|
||||
}
|
||||
|
||||
function onCardClick(e: MouseEvent) {
|
||||
if (!isExpanded.value) return
|
||||
const target = e.target as HTMLElement | null
|
||||
if (isInteractiveTarget(target, e.currentTarget)) return
|
||||
e.stopPropagation()
|
||||
closeStack()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.items.length,
|
||||
(n, previousLength) => {
|
||||
if (previousLength === 0 && n === 1 && !prefersReducedMotion.value) {
|
||||
singleItemEntrance.value = true
|
||||
} else if (n !== 1) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
|
||||
if (n <= 1 && (props.expanded ?? internalExpanded.value)) {
|
||||
phase.value = 'collapsed'
|
||||
setExpanded(false)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(isExpanded, (expanded, previousExpanded) => {
|
||||
if (previousExpanded === undefined) {
|
||||
phase.value = expanded ? 'expanded' : 'collapsed'
|
||||
return
|
||||
}
|
||||
if (expanded && phase.value !== 'expanding') phase.value = 'expanding'
|
||||
else if (!expanded && phase.value !== 'collapsing') phase.value = 'collapsing'
|
||||
})
|
||||
|
||||
watch(containerHeight, (height, previousHeight) => {
|
||||
if (height !== previousHeight) {
|
||||
const openingSingleItem =
|
||||
previousHeight === 0 && height > 0 && props.items.length === 1 && !prefersReducedMotion.value
|
||||
|
||||
if (openingSingleItem) {
|
||||
singleItemEntrance.value = true
|
||||
} else if (height === 0 || props.items.length !== 1) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
|
||||
containerHeightSettled.value =
|
||||
prefersReducedMotion.value || (!initialMeasurementSettled.value && !openingSingleItem)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.items.map((i) => i.id),
|
||||
(ids, previousIds = []) => {
|
||||
const idSet = new Set(ids)
|
||||
const previousIdSet = new Set(previousIds)
|
||||
markEntering(ids.filter((id) => !previousIdSet.has(id)))
|
||||
|
||||
for (const [id, ro] of observers) {
|
||||
if (!idSet.has(id)) {
|
||||
ro.disconnect()
|
||||
observers.delete(id)
|
||||
cardEls.delete(id)
|
||||
}
|
||||
}
|
||||
const next: Record<string, number> = {}
|
||||
for (const id of idSet) {
|
||||
if (heights.value[id] != null) next[id] = heights.value[id]
|
||||
}
|
||||
heights.value = next
|
||||
},
|
||||
)
|
||||
|
||||
let mql: MediaQueryList | null = null
|
||||
function syncRM(e: MediaQueryListEvent | MediaQueryList) {
|
||||
prefersReducedMotion.value = 'matches' in e ? e.matches : false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return
|
||||
mql = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
prefersReducedMotion.value = mql.matches
|
||||
mql.addEventListener('change', syncRM)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mql?.removeEventListener('change', syncRM)
|
||||
for (const ro of observers.values()) ro.disconnect()
|
||||
actionBarObserver?.disconnect()
|
||||
observers.clear()
|
||||
cardEls.clear()
|
||||
pendingHeights.clear()
|
||||
if (flushHandle != null) cancelAnimationFrame(flushHandle)
|
||||
if (initialMeasurementHandle != null) cancelAnimationFrame(initialMeasurementHandle)
|
||||
if (enteringHandle != null) cancelAnimationFrame(enteringHandle)
|
||||
})
|
||||
|
||||
const placeholderClasses: Record<StackedAdmonitionType, string> = {
|
||||
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 messages = defineMessages({
|
||||
alertCount: {
|
||||
id: 'ui.stacked-admonitions.alert-count',
|
||||
defaultMessage: '{count, plural, one {# alert} other {# alerts}}',
|
||||
},
|
||||
dismissAll: {
|
||||
id: 'ui.stacked-admonitions.dismiss-all',
|
||||
defaultMessage: 'Dismiss all',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnimatePresence :initial="false">
|
||||
<Motion
|
||||
v-if="items.length > 0"
|
||||
v-bind="attrs"
|
||||
as="div"
|
||||
class="relative"
|
||||
:initial="false"
|
||||
:animate="{ height: stackShellHeight, opacity: 1, y: 0 }"
|
||||
:exit="{
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
overflow: 'hidden',
|
||||
y: -4,
|
||||
transition: shellExitTransition,
|
||||
}"
|
||||
:transition="heightTransition"
|
||||
>
|
||||
<Transition
|
||||
enter-active-class="overflow-hidden transition-all duration-150 ease-out"
|
||||
enter-from-class="-translate-y-1 opacity-0 max-h-0"
|
||||
enter-to-class="translate-y-0 opacity-100 max-h-14"
|
||||
leave-active-class="overflow-hidden transition-all duration-100 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100 max-h-14"
|
||||
leave-to-class="-translate-y-1 opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="hasActionBar" :ref="(el: unknown) => setActionBarRef(el)">
|
||||
<div class="flex items-center justify-between pb-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
type="button"
|
||||
:aria-expanded="isExpanded"
|
||||
:aria-controls="stackId"
|
||||
@click="toggleExpanded"
|
||||
>
|
||||
<Motion
|
||||
as="span"
|
||||
class="inline-flex"
|
||||
:animate="{ rotate: isExpanded ? 0 : -90 }"
|
||||
:transition="{ type: 'spring', stiffness: 350, damping: 30 }"
|
||||
>
|
||||
<ChevronDownIcon class="h-4 w-4" />
|
||||
</Motion>
|
||||
<slot name="header-label" :count="items.length" :expanded="isExpanded">
|
||||
{{ formatMessage(messages.alertCount, { count: items.length }) }}
|
||||
</slot>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="dismissAllEnabled" type="transparent">
|
||||
<button type="button" @click="$emit('dismiss-all')">
|
||||
<XIcon class="h-4 w-4" />
|
||||
{{ formatMessage(messages.dismissAll) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Expanded-target overflow must become visible immediately so cards added
|
||||
during the tail of the expand spring do not inherit collapse clipping. -->
|
||||
<Motion
|
||||
:id="stackId"
|
||||
as="div"
|
||||
class="relative"
|
||||
:initial="false"
|
||||
:animate="{ height: containerHeight }"
|
||||
:transition="heightTransition"
|
||||
:style="{ overflow: containerOverflow }"
|
||||
v-bind="containerMotionProps"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
@click="onContainerClick"
|
||||
>
|
||||
<AnimatePresence :initial="false">
|
||||
<Motion
|
||||
v-for="(item, index) in items"
|
||||
:key="item.id"
|
||||
as="div"
|
||||
class="absolute inset-x-0 top-0 rounded-2xl bg-bg will-change-transform"
|
||||
:initial="false"
|
||||
:animate="cardPosition(index)"
|
||||
:exit="{ opacity: 0, scale: 0.9, transition: exitTransition }"
|
||||
:transition="springTransition"
|
||||
:style="{
|
||||
zIndex: items.length - index,
|
||||
transformOrigin: 'top center',
|
||||
}"
|
||||
:aria-hidden="isSettledCollapsed && index !== 0 ? 'true' : undefined"
|
||||
@click="onCardClick"
|
||||
>
|
||||
<template v-if="index === 0">
|
||||
<div :ref="(el: unknown) => setCardRef(item.id, el)">
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-front="true"
|
||||
:expanded="isExpanded"
|
||||
:dismissible="itemDismissible(item)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="relative">
|
||||
<Motion
|
||||
as="div"
|
||||
:class="[
|
||||
'absolute inset-0 rounded-2xl border border-solid',
|
||||
placeholderClasses[item.type],
|
||||
]"
|
||||
:initial="false"
|
||||
:animate="{ opacity: isExpanded ? 0 : 1 }"
|
||||
:transition="springTransition"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Motion
|
||||
as="div"
|
||||
:initial="false"
|
||||
:animate="{ height: targetCardHeight(index) }"
|
||||
:transition="springTransition"
|
||||
:style="{ overflow: isExpanded ? 'visible' : 'hidden' }"
|
||||
>
|
||||
<Motion
|
||||
as="div"
|
||||
:initial="false"
|
||||
:animate="{ opacity: contentOpacity(index) }"
|
||||
:transition="springTransition"
|
||||
>
|
||||
<div
|
||||
:ref="(el: unknown) => setCardRef(item.id, el)"
|
||||
:inert="!isExpanded ? true : undefined"
|
||||
>
|
||||
<slot
|
||||
name="item"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-front="false"
|
||||
:expanded="isExpanded"
|
||||
:dismissible="itemDismissible(item)"
|
||||
/>
|
||||
</div>
|
||||
</Motion>
|
||||
</Motion>
|
||||
</div>
|
||||
</template>
|
||||
</Motion>
|
||||
</AnimatePresence>
|
||||
</Motion>
|
||||
</Motion>
|
||||
</AnimatePresence>
|
||||
</template>
|
||||
@@ -70,6 +70,8 @@ export { default as SettingsLabel } from './SettingsLabel.vue'
|
||||
export { default as SimpleBadge } from './SimpleBadge.vue'
|
||||
export { default as Slider } from './Slider.vue'
|
||||
export { default as SmartClickable } from './SmartClickable.vue'
|
||||
export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmonitions.vue'
|
||||
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
|
||||
export { default as StatItem } from './StatItem.vue'
|
||||
export { default as StyledInput } from './StyledInput.vue'
|
||||
export type { TableColumn } from './Table.vue'
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
<template>
|
||||
<Admonition :type="contentError ? 'critical' : 'info'" :show-actions-underneath="!contentError">
|
||||
<Admonition
|
||||
:type="contentError ? 'critical' : 'info'"
|
||||
:dismissible="dismissible"
|
||||
:progress="progressValue"
|
||||
progress-color="blue"
|
||||
:waiting="isWaiting"
|
||||
@dismiss="emit('dismiss')"
|
||||
>
|
||||
<template #icon>
|
||||
<slot v-if="!contentError" name="icon">
|
||||
<SpinnerIcon class="h-6 w-6 flex-none animate-spin text-brand-blue" />
|
||||
</slot>
|
||||
</template>
|
||||
<template #header>
|
||||
{{ contentError ? 'Installation error' : "We're preparing your server!" }}
|
||||
{{ contentError ? 'Installation failed' : "We're preparing your server" }}
|
||||
</template>
|
||||
<template v-if="contentError">
|
||||
{{ errorLabel }}
|
||||
@@ -26,22 +33,12 @@
|
||||
</div>
|
||||
<template v-if="contentError" #top-right-actions>
|
||||
<ButtonStyled color="red" type="outlined">
|
||||
<button class="!border" @click="emit('retry')">
|
||||
<button class="!border" type="button" @click="emit('retry')">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
Retry
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-if="!contentError" #actions>
|
||||
<ProgressBar
|
||||
v-if="progress"
|
||||
:progress="progress.percent"
|
||||
:max="100"
|
||||
color="blue"
|
||||
full-width
|
||||
/>
|
||||
<ProgressBar v-else :progress="0" :max="1" color="blue" full-width waiting />
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
@@ -52,7 +49,6 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import Admonition from '../base/Admonition.vue'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import ProgressBar from '../base/ProgressBar.vue'
|
||||
|
||||
export interface SyncProgress {
|
||||
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
|
||||
@@ -67,10 +63,12 @@ export interface ContentError {
|
||||
const props = defineProps<{
|
||||
progress?: SyncProgress | null
|
||||
contentError?: ContentError | null
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
dismiss: []
|
||||
}>()
|
||||
|
||||
const errorLabel = computed(() => {
|
||||
@@ -91,10 +89,10 @@ const errorLabel = computed(() => {
|
||||
|
||||
if (step === 'modpack') {
|
||||
if (desc?.includes('no primary file')) {
|
||||
return 'The modpack version has no downloadable file. It may have been packaged incorrectly.'
|
||||
return 'This modpack version does not include a 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 'The modpack could not be installed. It may be corrupted or incompatible.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +112,16 @@ const phaseLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const progressValue = computed(() => {
|
||||
if (props.contentError) return undefined
|
||||
return props.progress ? props.progress.percent / 100 : 0
|
||||
})
|
||||
|
||||
const isWaiting = computed(() => {
|
||||
if (props.contentError) return false
|
||||
return !props.progress || props.progress.percent <= 0
|
||||
})
|
||||
|
||||
const tickerMessages = [
|
||||
'Organizing files...',
|
||||
'Downloading mods...',
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
InfoIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
TriangleAlertIcon,
|
||||
} from '@modrinth/assets'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import type { MessageDescriptor } from '#ui/composables/i18n'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
export type AdmonitionDisplayState = 'ongoing' | Archon.BackupsQueue.v1.BackupQueueState
|
||||
|
||||
export type BackupAdmonitionEntry = {
|
||||
key: string
|
||||
backupId: string
|
||||
type: 'create' | 'restore'
|
||||
state: AdmonitionDisplayState
|
||||
progress: number
|
||||
operationId: number | null
|
||||
syntheticLegacy: boolean
|
||||
name?: string
|
||||
timestamp?: string
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
item: BackupAdmonitionEntry
|
||||
dismissible: boolean
|
||||
cancelling: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
dismiss: []
|
||||
retry: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
type UiPhase = 'queued' | 'in_progress' | 'failed' | 'timed_out' | 'cancelled' | 'completed'
|
||||
|
||||
function resolveUiPhase(item: BackupAdmonitionEntry): UiPhase | null {
|
||||
switch (item.state) {
|
||||
case 'pending':
|
||||
return 'queued'
|
||||
case 'ongoing':
|
||||
return 'in_progress'
|
||||
case 'failed':
|
||||
case 'timed_out':
|
||||
case 'cancelled':
|
||||
case 'completed':
|
||||
return item.state
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getAdmonitionType(state: AdmonitionDisplayState): 'info' | 'critical' | 'success' {
|
||||
if (state === 'failed' || state === 'timed_out') return 'critical'
|
||||
if (state === 'completed') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function getIcon(state: AdmonitionDisplayState) {
|
||||
if (state === 'failed' || state === 'timed_out') return TriangleAlertIcon
|
||||
if (state === 'completed') return CheckCircleIcon
|
||||
return InfoIcon
|
||||
}
|
||||
|
||||
function isQueued(item: BackupAdmonitionEntry) {
|
||||
return resolveUiPhase(item) === 'queued'
|
||||
}
|
||||
|
||||
function isInProgress(item: BackupAdmonitionEntry) {
|
||||
return resolveUiPhase(item) === 'in_progress'
|
||||
}
|
||||
|
||||
function isTerminal(item: BackupAdmonitionEntry) {
|
||||
return item.state !== 'pending' && item.state !== 'ongoing'
|
||||
}
|
||||
|
||||
function canRetry(item: BackupAdmonitionEntry) {
|
||||
return item.state === 'failed' || item.state === 'timed_out'
|
||||
}
|
||||
|
||||
function canCancel(item: BackupAdmonitionEntry) {
|
||||
return isQueued(item) || isInProgress(item)
|
||||
}
|
||||
|
||||
function hasErrorDetail(item: BackupAdmonitionEntry) {
|
||||
return !!item.error && (item.state === 'failed' || item.state === 'timed_out')
|
||||
}
|
||||
|
||||
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.',
|
||||
},
|
||||
backupTimedOutTitle: {
|
||||
id: 'servers.backups.admonition.backup-timed-out.title',
|
||||
defaultMessage: 'Backup timed out',
|
||||
},
|
||||
backupTimedOutDescription: {
|
||||
id: 'servers.backups.admonition.backup-timed-out.description',
|
||||
defaultMessage:
|
||||
'Creating {backupName} timed out. You can try again or contact support if the issue continues.',
|
||||
},
|
||||
backupCancelledTitle: {
|
||||
id: 'servers.backups.admonition.backup-cancelled.title',
|
||||
defaultMessage: 'Backup cancelled',
|
||||
},
|
||||
backupCancelledDescription: {
|
||||
id: 'servers.backups.admonition.backup-cancelled.description',
|
||||
defaultMessage: 'Backup {backupName} was cancelled.',
|
||||
},
|
||||
backupCompletedTitle: {
|
||||
id: 'servers.backups.admonition.backup-completed.title',
|
||||
defaultMessage: 'Backup finished',
|
||||
},
|
||||
backupCompletedDescription: {
|
||||
id: 'servers.backups.admonition.backup-completed.description',
|
||||
defaultMessage: '{backupName} finished successfully.',
|
||||
},
|
||||
restoreQueuedTitle: {
|
||||
id: 'servers.backups.admonition.restore-queued.title',
|
||||
defaultMessage: 'Restore 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: 'Restore finished',
|
||||
},
|
||||
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: 'Restore 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.',
|
||||
},
|
||||
restoreTimedOutTitle: {
|
||||
id: 'servers.backups.admonition.restore-timed-out.title',
|
||||
defaultMessage: 'Restore timed out',
|
||||
},
|
||||
restoreTimedOutDescription: {
|
||||
id: 'servers.backups.admonition.restore-timed-out.description',
|
||||
defaultMessage:
|
||||
'Restoring from {backupName} timed out. You can try again or contact support if the issue continues.',
|
||||
},
|
||||
restoreCancelledTitle: {
|
||||
id: 'servers.backups.admonition.restore-cancelled.title',
|
||||
defaultMessage: 'Restore cancelled',
|
||||
},
|
||||
restoreCancelledDescription: {
|
||||
id: 'servers.backups.admonition.restore-cancelled.description',
|
||||
defaultMessage: 'Restoring from {backupName} was cancelled.',
|
||||
},
|
||||
})
|
||||
|
||||
const createTitles: Record<UiPhase, MessageDescriptor> = {
|
||||
queued: messages.backupQueuedTitle,
|
||||
in_progress: messages.creatingBackupTitle,
|
||||
failed: messages.backupFailedTitle,
|
||||
timed_out: messages.backupTimedOutTitle,
|
||||
cancelled: messages.backupCancelledTitle,
|
||||
completed: messages.backupCompletedTitle,
|
||||
}
|
||||
|
||||
const restoreTitles: Record<UiPhase, MessageDescriptor> = {
|
||||
queued: messages.restoreQueuedTitle,
|
||||
in_progress: messages.restoringBackupTitle,
|
||||
failed: messages.restoreFailedTitle,
|
||||
timed_out: messages.restoreTimedOutTitle,
|
||||
cancelled: messages.restoreCancelledTitle,
|
||||
completed: messages.restoreSuccessfulTitle,
|
||||
}
|
||||
|
||||
const createDescriptions: Record<UiPhase, MessageDescriptor> = {
|
||||
queued: messages.backupQueuedDescription,
|
||||
in_progress: messages.creatingBackupDescription,
|
||||
failed: messages.backupFailedDescription,
|
||||
timed_out: messages.backupTimedOutDescription,
|
||||
cancelled: messages.backupCancelledDescription,
|
||||
completed: messages.backupCompletedDescription,
|
||||
}
|
||||
|
||||
const restoreDescriptions: Record<UiPhase, MessageDescriptor> = {
|
||||
queued: messages.restoreQueuedDescription,
|
||||
in_progress: messages.restoringBackupDescription,
|
||||
failed: messages.restoreFailedDescription,
|
||||
timed_out: messages.restoreTimedOutDescription,
|
||||
cancelled: messages.restoreCancelledDescription,
|
||||
completed: messages.restoreSuccessfulDescription,
|
||||
}
|
||||
|
||||
function getTitle(item: BackupAdmonitionEntry): string {
|
||||
const phase = resolveUiPhase(item)
|
||||
if (phase == null) return ''
|
||||
const table = item.type === 'create' ? createTitles : restoreTitles
|
||||
return formatMessage(table[phase])
|
||||
}
|
||||
|
||||
function getDescription(item: BackupAdmonitionEntry): string {
|
||||
const phase = resolveUiPhase(item)
|
||||
if (phase == null) return ''
|
||||
const table = item.type === 'create' ? createDescriptions : restoreDescriptions
|
||||
const backupName = item.name ?? formatMessage(messages.fallbackName)
|
||||
return formatMessage(table[phase], { backupName })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Admonition
|
||||
:type="getAdmonitionType(item.state)"
|
||||
:header="getTitle(item)"
|
||||
:timestamp="item.timestamp"
|
||||
:dismissible="dismissible && isTerminal(item)"
|
||||
:progress="isInProgress(item) ? item.progress : undefined"
|
||||
progress-color="blue"
|
||||
:waiting="isInProgress(item) && item.progress === 0"
|
||||
@dismiss="$emit('dismiss')"
|
||||
>
|
||||
<template #icon="{ iconClass }">
|
||||
<component :is="getIcon(item.state)" :class="iconClass" />
|
||||
</template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span>{{ getDescription(item) }}</span>
|
||||
<span v-if="hasErrorDetail(item)" class="break-all font-mono text-sm text-secondary">
|
||||
{{ item.error }}
|
||||
</span>
|
||||
</div>
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled v-if="canCancel(item)" type="outlined" color="blue">
|
||||
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="canRetry(item)" color="red" type="outlined">
|
||||
<button class="!border" type="button" @click="$emit('retry')">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<Admonition
|
||||
:type="op.state === 'done' ? 'success' : op.state?.startsWith('fail') ? 'critical' : 'info'"
|
||||
:dismissible="dismissible && isTerminal"
|
||||
:progress="'progress' in op ? (op.progress ?? 0) : 0"
|
||||
:progress-color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
|
||||
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
|
||||
@dismiss="$emit('dismiss')"
|
||||
>
|
||||
<template #icon="{ iconClass }">
|
||||
<PackageOpenIcon :class="iconClass" />
|
||||
</template>
|
||||
<template #header>{{ title }}</template>
|
||||
<span class="text-secondary">
|
||||
<span>
|
||||
{{
|
||||
formatMessage(messages.extracted, {
|
||||
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="'current_file' in op && op.current_file">
|
||||
. {{ formatMessage(messages.currentFile, { file: op.current_file?.split('/')?.pop() }) }}
|
||||
</span>
|
||||
</span>
|
||||
<template v-if="op.id" #top-right-actions>
|
||||
<ButtonStyled v-if="!isTerminal" type="outlined" color="blue">
|
||||
<button class="!border" type="button" @click="ctx.dismissOperation(op.id!, 'cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageOpenIcon } from '@modrinth/assets'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
defineEmits<{ dismiss: [] }>()
|
||||
|
||||
const props = defineProps<{
|
||||
op: FileOperation
|
||||
dismissible: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const messages = defineMessages({
|
||||
extracting: {
|
||||
id: 'files.operations.extracting',
|
||||
defaultMessage: 'Extracting {source}',
|
||||
},
|
||||
extractingCompleted: {
|
||||
id: 'files.operations.extracting-completed',
|
||||
defaultMessage: 'Extracting {source} finished',
|
||||
},
|
||||
extractingFailed: {
|
||||
id: 'files.operations.extracting-failed',
|
||||
defaultMessage: 'Extracting {source} failed',
|
||||
},
|
||||
modpackFromUrl: {
|
||||
id: 'files.operations.modpack-from-url',
|
||||
defaultMessage: 'modpack from URL',
|
||||
},
|
||||
extracted: {
|
||||
id: 'files.operations.extracted',
|
||||
defaultMessage: '{size} extracted',
|
||||
},
|
||||
currentFile: {
|
||||
id: 'files.operations.current-file',
|
||||
defaultMessage: 'Current file: {file}',
|
||||
},
|
||||
})
|
||||
|
||||
const isTerminal = computed(() => props.op.state === 'done' || !!props.op.state?.startsWith('fail'))
|
||||
const sourceName = computed(() =>
|
||||
props.op.src.includes('https://') ? formatMessage(messages.modpackFromUrl) : props.op.src,
|
||||
)
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.op.state === 'done') {
|
||||
return formatMessage(messages.extractingCompleted, { source: sourceName.value })
|
||||
}
|
||||
if (props.op.state?.startsWith('fail')) {
|
||||
return formatMessage(messages.extractingFailed, { source: sourceName.value })
|
||||
}
|
||||
return formatMessage(messages.extracting, { source: sourceName.value })
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,410 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import StackedAdmonitions, {
|
||||
type StackedAdmonitionItem,
|
||||
} from '#ui/components/base/StackedAdmonitions.vue'
|
||||
import { ServerIcon } from '#ui/components/servers/icons'
|
||||
import InstallingBanner, {
|
||||
type ContentError,
|
||||
type SyncProgress,
|
||||
} from '#ui/components/servers/InstallingBanner.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
import BackupAdmonition, { type BackupAdmonitionEntry } from './BackupAdmonition.vue'
|
||||
import FileOperationAdmonition from './FileOperationAdmonition.vue'
|
||||
import UploadAdmonition from './UploadAdmonition.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
syncProgress?: SyncProgress | null
|
||||
contentError?: ContentError | null
|
||||
serverImage?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'content-retry': []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const ctx = injectModrinthServerContext()
|
||||
const route = useRoute()
|
||||
|
||||
const { activeOperations, backups, progressFor, invalidate } = useServerBackupsQueue(
|
||||
computed(() => ctx.serverId),
|
||||
ctx.worldId,
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
backgroundTaskRunning: {
|
||||
id: 'servers.admonitions.background-task-running',
|
||||
defaultMessage: 'Background task running',
|
||||
},
|
||||
contentBusyBody: {
|
||||
id: 'content.page-layout.busy-description',
|
||||
defaultMessage: 'Please wait for the operation to complete before editing content.',
|
||||
},
|
||||
filesBusyBody: {
|
||||
id: 'files.layout.busy-warning',
|
||||
defaultMessage: 'File operations are disabled while the operation is in progress.',
|
||||
},
|
||||
})
|
||||
|
||||
const isOnContentTab = computed(() => route.path.includes('/content'))
|
||||
const isOnFilesTab = computed(() => route.path.includes('/files'))
|
||||
|
||||
const bannerCoversInstalling = computed(
|
||||
() => ctx.server.value?.status === 'installing' || ctx.isSyncingContent.value,
|
||||
)
|
||||
|
||||
function isBackupReason(id: string) {
|
||||
return id === 'servers.busy.backup-creating' || id === 'servers.busy.backup-restoring'
|
||||
}
|
||||
|
||||
function isInstallingReason(id: string) {
|
||||
return id === 'servers.busy.installing' || id === 'servers.busy.syncing-content'
|
||||
}
|
||||
|
||||
const filteredBusyReasons = computed(() =>
|
||||
ctx.busyReasons.value.filter((r) => {
|
||||
if (isBackupReason(r.reason.id)) return false
|
||||
if (bannerCoversInstalling.value && isInstallingReason(r.reason.id)) return false
|
||||
return true
|
||||
}),
|
||||
)
|
||||
|
||||
const contentBusyHeader = computed(() =>
|
||||
filteredBusyReasons.value.length > 0 ? formatMessage(filteredBusyReasons.value[0].reason) : null,
|
||||
)
|
||||
|
||||
const filesBusyHeader = computed(() =>
|
||||
filteredBusyReasons.value.length > 0 ? formatMessage(filteredBusyReasons.value[0].reason) : null,
|
||||
)
|
||||
|
||||
const dismissedIds = reactive(new Set<string>())
|
||||
const cancellingIds = reactive(new Set<string>())
|
||||
const dismissedContentErrorKey = ref<string | null>(null)
|
||||
|
||||
const contentErrorKey = computed(() =>
|
||||
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
|
||||
)
|
||||
|
||||
watch(contentErrorKey, (key) => {
|
||||
if (!key) {
|
||||
dismissedContentErrorKey.value = null
|
||||
}
|
||||
})
|
||||
|
||||
const backupAdmonitionEntries = computed<BackupAdmonitionEntry[]>(() => {
|
||||
const result: BackupAdmonitionEntry[] = []
|
||||
const backupById = new Map(backups.value.map((b) => [b.id, b]))
|
||||
|
||||
for (const op of activeOperations.value) {
|
||||
const key = `${op.backup_id}:${op.operation_type}:${op.operation_id ?? 'legacy'}`
|
||||
if (dismissedIds.has(key)) continue
|
||||
const backup = backupById.get(op.backup_id)
|
||||
const history = backup?.history.find(
|
||||
(h) =>
|
||||
h.operation_type === op.operation_type &&
|
||||
(h.operation_id ?? null) === (op.operation_id ?? null),
|
||||
)
|
||||
const rawProgress = progressFor(op.backup_id, op.operation_type) ?? 0
|
||||
result.push({
|
||||
key,
|
||||
backupId: op.backup_id,
|
||||
type: op.operation_type,
|
||||
state: history?.state ?? 'ongoing',
|
||||
progress: rawProgress,
|
||||
operationId: op.operation_id ?? null,
|
||||
syntheticLegacy: op.synthetic_legacy,
|
||||
name: backup?.name,
|
||||
timestamp: history?.scheduled_for ?? op.scheduled_for,
|
||||
})
|
||||
}
|
||||
|
||||
for (const backup of backups.value) {
|
||||
const last = backup.history[0]
|
||||
if (!last || !last.should_prompt) continue
|
||||
if (last.state === 'pending' || last.state === 'ongoing') continue
|
||||
const key = `${backup.id}:${last.operation_type}:${last.operation_id ?? 'legacy'}`
|
||||
if (dismissedIds.has(key)) continue
|
||||
if (result.some((r) => r.key === key)) continue
|
||||
result.push({
|
||||
key,
|
||||
backupId: backup.id,
|
||||
type: last.operation_type,
|
||||
state: last.state,
|
||||
progress: 0,
|
||||
operationId: last.operation_id ?? null,
|
||||
syntheticLegacy: last.synthetic_legacy,
|
||||
name: backup.name,
|
||||
timestamp: last.completed_at ?? last.scheduled_for,
|
||||
error: last.error ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
type ServerAdmonitionItem = StackedAdmonitionItem & {
|
||||
priority: number
|
||||
sortIndex: number
|
||||
} & (
|
||||
| { kind: 'installing' }
|
||||
| { kind: 'upload' }
|
||||
| { kind: 'fs-op'; op: FileOperation }
|
||||
| { kind: 'backup'; entry: BackupAdmonitionEntry }
|
||||
| { kind: 'busy-content' }
|
||||
| { kind: 'busy-files' }
|
||||
)
|
||||
|
||||
const showInstallingBanner = computed(() => {
|
||||
if (!ctx.server.value) return false
|
||||
const installing =
|
||||
ctx.server.value.status === 'installing' || ctx.isSyncingContent.value || !!props.contentError
|
||||
if (!installing) return false
|
||||
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
|
||||
return false
|
||||
return props.syncProgress?.phase !== 'Analyzing'
|
||||
})
|
||||
|
||||
function fsOpType(op: FileOperation): StackedAdmonitionItem['type'] {
|
||||
if (op.state === 'done') return 'success'
|
||||
if (op.state?.startsWith('fail')) return 'critical'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function fsOpPriority(op: FileOperation): number {
|
||||
if (op.state?.startsWith('fail')) return 1
|
||||
if (op.state === 'done') return 4
|
||||
if (op.state === 'queued') return 3
|
||||
return 2
|
||||
}
|
||||
|
||||
function backupType(entry: BackupAdmonitionEntry): StackedAdmonitionItem['type'] {
|
||||
if (entry.state === 'failed' || entry.state === 'timed_out') return 'critical'
|
||||
if (entry.state === 'completed') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function backupPriority(entry: BackupAdmonitionEntry): number {
|
||||
if (entry.state === 'failed' || entry.state === 'timed_out') return 1
|
||||
if (entry.state === 'ongoing') return 2
|
||||
if (entry.state === 'pending') return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
const stackItems = computed<ServerAdmonitionItem[]>(() => {
|
||||
const out: ServerAdmonitionItem[] = []
|
||||
let sortIndex = 0
|
||||
|
||||
if (showInstallingBanner.value) {
|
||||
out.push({
|
||||
id: 'installing',
|
||||
type: props.contentError ? 'critical' : 'info',
|
||||
dismissible: !!props.contentError,
|
||||
kind: 'installing',
|
||||
priority: 0,
|
||||
sortIndex: sortIndex++,
|
||||
})
|
||||
}
|
||||
|
||||
if (ctx.uploadState.value.isUploading) {
|
||||
out.push({
|
||||
id: 'upload-active',
|
||||
type: 'info',
|
||||
dismissible: false,
|
||||
kind: 'upload',
|
||||
priority: 2,
|
||||
sortIndex: sortIndex++,
|
||||
})
|
||||
}
|
||||
|
||||
for (const op of ctx.activeOperations.value) {
|
||||
out.push({
|
||||
id: op.id ? `fs-op-${op.id}` : `fs-op-${op.op}-${op.src}`,
|
||||
type: fsOpType(op),
|
||||
dismissible: !!op.id && (op.state === 'done' || !!op.state?.startsWith('fail')),
|
||||
kind: 'fs-op',
|
||||
op,
|
||||
priority: fsOpPriority(op),
|
||||
sortIndex: sortIndex++,
|
||||
})
|
||||
}
|
||||
|
||||
for (const entry of backupAdmonitionEntries.value) {
|
||||
out.push({
|
||||
id: `backup-${entry.key}`,
|
||||
type: backupType(entry),
|
||||
dismissible: entry.state !== 'pending' && entry.state !== 'ongoing',
|
||||
kind: 'backup',
|
||||
entry,
|
||||
priority: backupPriority(entry),
|
||||
sortIndex: sortIndex++,
|
||||
})
|
||||
}
|
||||
|
||||
if (contentBusyHeader.value) {
|
||||
const p = isOnContentTab.value ? 0 : 5
|
||||
out.push({
|
||||
id: 'busy-content',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'busy-content',
|
||||
priority: p,
|
||||
sortIndex: sortIndex++,
|
||||
})
|
||||
}
|
||||
|
||||
if (filesBusyHeader.value) {
|
||||
const p = isOnFilesTab.value ? 0 : 5
|
||||
out.push({
|
||||
id: 'busy-files',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'busy-files',
|
||||
priority: p,
|
||||
sortIndex: sortIndex++,
|
||||
})
|
||||
}
|
||||
|
||||
return out.sort((a, b) => a.priority - b.priority || a.sortIndex - b.sortIndex)
|
||||
})
|
||||
|
||||
const hasBulkDismissableItems = computed(() => stackItems.value.some((it) => it.dismissible))
|
||||
|
||||
async function onBackupDismiss(item: BackupAdmonitionEntry) {
|
||||
dismissedIds.add(item.key)
|
||||
if (item.syntheticLegacy || item.operationId == null) {
|
||||
await invalidate()
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (item.type === 'create') {
|
||||
await client.archon.backups_queue_v1.ackCreate(
|
||||
ctx.serverId,
|
||||
ctx.worldId.value!,
|
||||
item.operationId,
|
||||
)
|
||||
} else {
|
||||
await client.archon.backups_queue_v1.ackRestore(
|
||||
ctx.serverId,
|
||||
ctx.worldId.value!,
|
||||
item.operationId,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
dismissedIds.delete(item.key)
|
||||
console.error('Failed to acknowledge backup operation', err)
|
||||
} finally {
|
||||
await invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
async function onBackupCancel(item: BackupAdmonitionEntry) {
|
||||
if (cancellingIds.has(item.key)) return
|
||||
cancellingIds.add(item.key)
|
||||
try {
|
||||
await client.archon.backups_v1.delete(ctx.serverId, ctx.worldId.value!, item.backupId)
|
||||
await invalidate()
|
||||
} catch (err) {
|
||||
cancellingIds.delete(item.key)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function onBackupRetry(item: BackupAdmonitionEntry) {
|
||||
await client.archon.backups_queue_v1.retry(ctx.serverId, ctx.worldId.value!, item.backupId)
|
||||
dismissedIds.add(item.key)
|
||||
await invalidate()
|
||||
}
|
||||
|
||||
async function onDismissAll() {
|
||||
const tasks: Promise<unknown>[] = []
|
||||
for (const it of stackItems.value) {
|
||||
if (!it.dismissible) continue
|
||||
if (it.kind === 'installing' && props.contentError) {
|
||||
onContentErrorDismiss()
|
||||
} else if (it.kind === 'fs-op' && it.op.id) {
|
||||
const { op } = it
|
||||
if (op.state === 'done' || op.state?.startsWith('fail')) {
|
||||
tasks.push(ctx.dismissOperation(it.op.id, 'dismiss'))
|
||||
}
|
||||
} else if (it.kind === 'backup') {
|
||||
tasks.push(onBackupDismiss(it.entry))
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
}
|
||||
|
||||
function onFileOpDismiss(item: ServerAdmonitionItem) {
|
||||
if (item.kind === 'fs-op' && item.op.id) {
|
||||
void ctx.dismissOperation(item.op.id, 'dismiss')
|
||||
}
|
||||
}
|
||||
|
||||
function onContentErrorDismiss() {
|
||||
if (contentErrorKey.value) {
|
||||
dismissedContentErrorKey.value = contentErrorKey.value
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StackedAdmonitions
|
||||
:items="stackItems"
|
||||
:dismiss-all-enabled="hasBulkDismissableItems"
|
||||
class="w-full"
|
||||
@dismiss-all="onDismissAll"
|
||||
>
|
||||
<template #item="{ item, dismissible }">
|
||||
<InstallingBanner
|
||||
v-if="item.kind === 'installing'"
|
||||
:progress="syncProgress"
|
||||
:content-error="contentError"
|
||||
:dismissible="dismissible && !!contentError"
|
||||
@dismiss="onContentErrorDismiss"
|
||||
@retry="emit('content-retry')"
|
||||
>
|
||||
<template #icon>
|
||||
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
|
||||
</template>
|
||||
</InstallingBanner>
|
||||
<UploadAdmonition v-else-if="item.kind === 'upload'" />
|
||||
<FileOperationAdmonition
|
||||
v-else-if="item.kind === 'fs-op'"
|
||||
:op="item.op"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="onFileOpDismiss(item)"
|
||||
/>
|
||||
<BackupAdmonition
|
||||
v-else-if="item.kind === 'backup'"
|
||||
:item="item.entry"
|
||||
:dismissible="dismissible"
|
||||
:cancelling="cancellingIds.has(item.entry.key)"
|
||||
@dismiss="onBackupDismiss(item.entry)"
|
||||
@cancel="onBackupCancel(item.entry)"
|
||||
@retry="onBackupRetry(item.entry)"
|
||||
/>
|
||||
<Admonition
|
||||
v-else-if="item.kind === 'busy-content'"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.backgroundTaskRunning)"
|
||||
>
|
||||
{{ formatMessage(messages.contentBusyBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else-if="item.kind === 'busy-files'"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.backgroundTaskRunning)"
|
||||
>
|
||||
{{ formatMessage(messages.filesBusyBody) }}
|
||||
</Admonition>
|
||||
</template>
|
||||
</StackedAdmonitions>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<Admonition type="info" :progress="overallProgress" progress-color="blue">
|
||||
<template #icon>
|
||||
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{
|
||||
state.currentFileName
|
||||
? `Uploading ${state.currentFileName} (${state.completedFiles}/${state.totalFiles})`
|
||||
: `Uploading files (${state.completedFiles}/${state.totalFiles})`
|
||||
}}
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{ formatBytes(state.uploadedBytes) }} / {{ formatBytes(state.totalBytes) }} ({{
|
||||
Math.round(overallProgress * 100)
|
||||
}}%)
|
||||
</span>
|
||||
<template v-if="cancelUpload" #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border" type="button" @click="cancelUpload()">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const state = computed(() => ctx.uploadState.value)
|
||||
const cancelUpload = computed(() => ctx.cancelUpload.value)
|
||||
|
||||
const overallProgress = computed(() => {
|
||||
const s = state.value
|
||||
if (!s.isUploading || s.totalFiles === 0) return 0
|
||||
return Math.min((s.completedFiles + s.currentFileProgress) / s.totalFiles, 1)
|
||||
})
|
||||
</script>
|
||||
5
packages/ui/src/components/servers/admonitions/index.ts
Normal file
5
packages/ui/src/components/servers/admonitions/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type { BackupAdmonitionEntry } from './BackupAdmonition.vue'
|
||||
export { default as BackupAdmonition } from './BackupAdmonition.vue'
|
||||
export { default as FileOperationAdmonition } from './FileOperationAdmonition.vue'
|
||||
export { default as ServerPanelAdmonitions } from './ServerPanelAdmonitions.vue'
|
||||
export { default as UploadAdmonition } from './UploadAdmonition.vue'
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Create backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px] -mb-2">
|
||||
<NewModal ref="modal" header="Create backup" width="500px" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 -mb-2">
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast">Name</span>
|
||||
</label>
|
||||
@@ -45,9 +45,9 @@
|
||||
</Transition>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="w-full flex flex-row gap-2 justify-end">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-[1px] !border-surface-4" @click="hideModal">
|
||||
<button class="!border !border-surface-4" @click="hideModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
@@ -84,14 +84,14 @@ const queryClient = useQueryClient()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const props = defineProps<{
|
||||
backups?: Archon.Backups.v1.Backup[]
|
||||
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
||||
}>()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
client.archon.backups_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
|
||||
client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,28 +1,75 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Delete backup" fade="danger">
|
||||
<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.
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { count })"
|
||||
fade="danger"
|
||||
width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody, { count }) }}
|
||||
</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 !shadow-none" />
|
||||
<div v-if="displayBackups.length" class="flex min-w-0 flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.backupsLabel, { count }) }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showTopFade"
|
||||
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-2 bg-gradient-to-b from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
<div
|
||||
ref="backupListRef"
|
||||
class="flex max-h-[240px] flex-col gap-2 overflow-y-auto"
|
||||
@scroll="checkScrollState"
|
||||
>
|
||||
<BackupItem
|
||||
v-for="backup in displayBackups"
|
||||
:key="backup.id"
|
||||
:backup="backup"
|
||||
preview
|
||||
class="!bg-surface-2 !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-2"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-2"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="showBottomFade"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-2 bg-gradient-to-t from-bg-raised to-transparent"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="deleteBackup">
|
||||
<button @click="confirmDelete">
|
||||
<TrashIcon />
|
||||
Delete backup
|
||||
{{ formatMessage(messages.confirm, { count }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -33,31 +80,86 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { useScrollIndicator } from '../../../composables/scroll-indicator'
|
||||
import { commonMessages } from '../../../utils'
|
||||
import Admonition from '../../base/Admonition.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import BackupItem from './BackupItem.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', backup: Archon.Backups.v1.Backup | undefined): void
|
||||
(e: 'delete', backup: Archon.BackupsQueue.v1.BackupQueueBackup | undefined): void
|
||||
(e: 'bulk-delete', backups: Archon.BackupsQueue.v1.BackupQueueBackup[]): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const currentBackup = ref<Archon.Backups.v1.Backup>()
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'servers.backups.delete-modal.header',
|
||||
defaultMessage: 'Delete {count, plural, one {backup} other {backups}}',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'servers.backups.delete-modal.admonition-header',
|
||||
defaultMessage: 'Deletion warning',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'servers.backups.delete-modal.admonition-body',
|
||||
defaultMessage:
|
||||
'Once deleted, {count, plural, one {this backup cannot} other {these backups cannot}} be recovered. Deletion is permanent.',
|
||||
},
|
||||
confirm: {
|
||||
id: 'servers.backups.delete-modal.confirm',
|
||||
defaultMessage: 'Delete {count, plural, one {backup} other {# backups}}',
|
||||
},
|
||||
backupsLabel: {
|
||||
id: 'servers.backups.delete-modal.backups-label',
|
||||
defaultMessage: '{count, plural, one {Backup} other {Backups ({count})}}',
|
||||
},
|
||||
})
|
||||
|
||||
function show(backup: Archon.Backups.v1.Backup) {
|
||||
currentBackup.value = backup
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupListRef = ref<HTMLElement | null>(null)
|
||||
const singleBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup>()
|
||||
const bulkBackups = ref<Archon.BackupsQueue.v1.BackupQueueBackup[]>([])
|
||||
const { showTopFade, showBottomFade, checkScrollState, forceCheck } =
|
||||
useScrollIndicator(backupListRef)
|
||||
|
||||
const isBulk = computed(() => bulkBackups.value.length > 0)
|
||||
const count = computed(() => (isBulk.value ? bulkBackups.value.length : 1))
|
||||
const displayBackups = computed(() =>
|
||||
isBulk.value ? bulkBackups.value : singleBackup.value ? [singleBackup.value] : [],
|
||||
)
|
||||
|
||||
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
singleBackup.value = backup
|
||||
bulkBackups.value = []
|
||||
modal.value?.show()
|
||||
nextTick(() => forceCheck())
|
||||
}
|
||||
|
||||
function deleteBackup() {
|
||||
function showBulk(backups: Archon.BackupsQueue.v1.BackupQueueBackup[]) {
|
||||
singleBackup.value = undefined
|
||||
bulkBackups.value = [...backups]
|
||||
modal.value?.show()
|
||||
nextTick(() => forceCheck())
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
modal.value?.hide()
|
||||
emit('delete', currentBackup.value)
|
||||
if (isBulk.value) {
|
||||
emit('bulk-delete', bulkBackups.value)
|
||||
bulkBackups.value = []
|
||||
} else {
|
||||
emit('delete', singleBackup.value)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
showBulk,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,20 +2,19 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
MoreVerticalIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
ShieldIcon,
|
||||
TrashIcon,
|
||||
UserRoundIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useFormatDateTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { commonMessages } from '../../../utils'
|
||||
import { commonMessages, truncatedTooltip } from '../../../utils'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
|
||||
|
||||
@@ -26,19 +25,20 @@ const formatDateTime = useFormatDateTime({
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'download' | 'rename' | 'restore' | 'retry'): void
|
||||
(e: 'download' | 'rename' | 'restore'): void
|
||||
(e: 'delete', skipConfirmation?: boolean): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
backup: Archon.Backups.v1.Backup
|
||||
backup: Archon.BackupsQueue.v1.BackupQueueBackup
|
||||
preview?: boolean
|
||||
kyrosUrl?: string
|
||||
jwt?: string
|
||||
showCopyIdAction?: boolean
|
||||
showDebugInfo?: boolean
|
||||
restoreDisabled?: string
|
||||
selected?: boolean
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
@@ -47,45 +47,15 @@ const props = withDefaults(
|
||||
showCopyIdAction: false,
|
||||
showDebugInfo: false,
|
||||
restoreDisabled: undefined,
|
||||
selected: false,
|
||||
},
|
||||
)
|
||||
|
||||
const failedToCreate = computed(
|
||||
() => props.backup.status === 'error' || props.backup.status === 'timed_out',
|
||||
)
|
||||
|
||||
const inactiveStates = ['failed', 'cancelled', 'done']
|
||||
|
||||
const creating = computed(() => {
|
||||
const task = props.backup.task?.create
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
(props.backup.status === 'in_progress' || props.backup.status === 'pending') &&
|
||||
!props.backup.task?.restore
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const restoring = computed(() => {
|
||||
const task = props.backup.task?.restore
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
|
||||
|
||||
const activeOperation = computed(() => creating.value || restoring.value)
|
||||
const nameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const backupIcon = computed(() => {
|
||||
if (props.backup.automated) {
|
||||
return ClockIcon
|
||||
return ShieldIcon
|
||||
}
|
||||
return UserRoundIcon
|
||||
})
|
||||
@@ -100,29 +70,25 @@ const overflowMenuOptions = computed<OverflowOption[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (!activeOperation.value) {
|
||||
if (options.length > 0) {
|
||||
options.push({ divider: true })
|
||||
}
|
||||
|
||||
options.push({
|
||||
id: 'download',
|
||||
action: () => emit('download'),
|
||||
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
|
||||
disabled: !props.kyrosUrl || !props.jwt,
|
||||
})
|
||||
if (options.length > 0) {
|
||||
options.push({ divider: true })
|
||||
}
|
||||
|
||||
options.push({
|
||||
id: 'download',
|
||||
action: () => emit('download'),
|
||||
link: `https://${props.kyrosUrl}/modrinth/v0/backups/${props.backup.id}/download?auth=${props.jwt}`,
|
||||
disabled: !props.kyrosUrl || !props.jwt,
|
||||
})
|
||||
|
||||
options.push({ id: 'rename', action: () => emit('rename') })
|
||||
|
||||
if (!activeOperation.value) {
|
||||
options.push({ divider: true })
|
||||
options.push({
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
action: () => emit('delete'),
|
||||
})
|
||||
}
|
||||
options.push({ divider: true })
|
||||
options.push({
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
action: () => emit('delete'),
|
||||
})
|
||||
|
||||
return options
|
||||
})
|
||||
@@ -131,13 +97,6 @@ async function copyId() {
|
||||
await navigator.clipboard.writeText(props.backup.id)
|
||||
}
|
||||
|
||||
// TODO: Uncomment when API supports size field
|
||||
// const formatBytes = (bytes?: number) => {
|
||||
// if (!bytes) return ''
|
||||
// const mb = bytes / (1024 * 1024)
|
||||
// return `${mb.toFixed(0)} MiB`
|
||||
// }
|
||||
|
||||
const messages = defineMessages({
|
||||
restore: {
|
||||
id: 'servers.backups.item.restore',
|
||||
@@ -147,14 +106,6 @@ const messages = defineMessages({
|
||||
id: 'servers.backups.item.rename',
|
||||
defaultMessage: 'Rename',
|
||||
},
|
||||
failedToCreateBackup: {
|
||||
id: 'servers.backups.item.failed-to-create-backup',
|
||||
defaultMessage: 'Failed to create backup',
|
||||
},
|
||||
failedToRestoreBackup: {
|
||||
id: 'servers.backups.item.failed-to-restore-backup',
|
||||
defaultMessage: 'Failed to restore from backup',
|
||||
},
|
||||
auto: {
|
||||
id: 'servers.backups.item.auto',
|
||||
defaultMessage: 'Auto',
|
||||
@@ -171,78 +122,67 @@ const messages = defineMessages({
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
|
||||
:class="
|
||||
preview
|
||||
? 'grid-cols-1'
|
||||
: 'grid-cols-[auto_1fr_auto] md:grid-cols-[minmax(0,1fr)_400px_minmax(0,1fr)]'
|
||||
"
|
||||
class="flex items-center gap-4 rounded-[20px] border border-solid bg-surface-3 p-4 shadow-[0px_1px_2px_0px_rgba(0,0,0,0.3),0px_1px_3px_0px_rgba(0,0,0,0.15)]"
|
||||
:class="props.selected ? 'border-brand-green' : 'border-transparent'"
|
||||
>
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-4">
|
||||
<!-- Icon tile -->
|
||||
<div
|
||||
class="flex size-12 shrink-0 items-center justify-center rounded-2xl border-solid border-[1px] border-surface-5 bg-surface-4 md:size-16"
|
||||
class="flex shrink-0 items-center justify-center rounded-2xl border border-solid border-surface-5 bg-surface-4"
|
||||
:class="preview ? 'size-10' : 'size-14'"
|
||||
>
|
||||
<component :is="backupIcon" class="size-7 text-secondary md:size-10" />
|
||||
<component
|
||||
:is="backupIcon"
|
||||
class="text-secondary"
|
||||
:class="preview ? 'size-6' : 'size-10'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Name + badge + subtitle -->
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="truncate font-semibold text-contrast max-w-[400px]">{{ backup.name }}</span>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
ref="nameRef"
|
||||
v-tooltip="truncatedTooltip(nameRef, backup.name)"
|
||||
class="min-w-0 truncate font-semibold text-contrast"
|
||||
>
|
||||
{{ backup.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="backup.automated"
|
||||
class="rounded-full border-solid border-[1px] border-surface-5 bg-surface-4 px-2.5 py-1 text-sm text-secondary"
|
||||
class="shrink-0 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1 text-sm font-medium text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.auto) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<div class="flex items-center gap-1.5 text-sm font-medium text-secondary">
|
||||
<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">
|
||||
{{
|
||||
formatMessage(
|
||||
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- TODO: Uncomment when API supports creator_id field -->
|
||||
<!-- <template v-if="backup.creator_id && backup.creator_id !== 'auto'">
|
||||
<Avatar ... class="size-6 rounded-full" />
|
||||
<span>{{ creatorName }}</span>
|
||||
</template>
|
||||
<template v-else> -->
|
||||
<span>
|
||||
{{
|
||||
formatMessage(backup.automated ? messages.backupSchedule : messages.manualBackup)
|
||||
}}
|
||||
</span>
|
||||
<!-- </template> -->
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</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"
|
||||
>
|
||||
<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> -->
|
||||
<!-- Date (middle column) -->
|
||||
<div v-if="!preview" class="flex shrink-0 items-center">
|
||||
<span class="whitespace-nowrap font-medium text-contrast">{{
|
||||
formatDateTime(backup.created_at)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end">
|
||||
<ButtonStyled v-if="!activeOperation" color="brand" type="outlined">
|
||||
<!-- Right side actions -->
|
||||
<div v-if="!preview" class="flex min-w-0 flex-1 items-center justify-end gap-2">
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-tooltip="props.restoreDisabled"
|
||||
class="!border-[1px]"
|
||||
class="!border"
|
||||
:disabled="!!props.restoreDisabled"
|
||||
@click="() => emit('restore')"
|
||||
>
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
InfoIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
TriangleAlertIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import { useRelativeTime } from '../../../composables'
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
|
||||
import type { BackupProgressEntry } from '../../../providers/server-context'
|
||||
import { commonMessages } from '../../../utils'
|
||||
import Admonition from '../../base/Admonition.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import ProgressBar from '../../base/ProgressBar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const relativeTime = useRelativeTime()
|
||||
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>()
|
||||
|
||||
for (const [id, entry] of backupsState.entries()) {
|
||||
const backup = findBackup(id)
|
||||
seenIds.add(id)
|
||||
if (entry.create && entry.create.state === 'ongoing') {
|
||||
const key = `${id}:create`
|
||||
if (!dismissedIds.has(key)) {
|
||||
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)) {
|
||||
result.push({
|
||||
key,
|
||||
backupId: id,
|
||||
type: 'restore',
|
||||
state: entry.restore.state,
|
||||
progress: entry.restore.progress,
|
||||
name: backup?.name,
|
||||
createdAt: backup?.created_at,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
function getAdmonitionType(state: Archon.Backups.v1.BackupState): 'info' | 'critical' | 'success' {
|
||||
if (state === 'failed') return 'critical'
|
||||
if (state === 'done') return 'success'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function getIcon(state: Archon.Backups.v1.BackupState) {
|
||||
if (state === 'failed') return TriangleAlertIcon
|
||||
if (state === 'done') return CheckCircleIcon
|
||||
return InfoIcon
|
||||
}
|
||||
|
||||
function getButtonColor(state: Archon.Backups.v1.BackupState): 'red' | 'green' | 'blue' {
|
||||
if (state === 'failed') return 'red'
|
||||
if (state === 'done') return 'green'
|
||||
return 'blue'
|
||||
}
|
||||
|
||||
function isQueued(item: AdmonitionEntry) {
|
||||
return item.state === 'ongoing' && item.progress === 0
|
||||
}
|
||||
|
||||
function isInProgress(item: AdmonitionEntry) {
|
||||
return item.state === 'ongoing' && item.progress > 0
|
||||
}
|
||||
|
||||
function getTitle(item: AdmonitionEntry) {
|
||||
if (item.type === 'create') {
|
||||
if (isQueued(item)) return formatMessage(messages.backupQueuedTitle)
|
||||
if (isInProgress(item)) return formatMessage(messages.creatingBackupTitle)
|
||||
if (item.state === 'failed') return formatMessage(messages.backupFailedTitle)
|
||||
}
|
||||
if (isQueued(item)) return formatMessage(messages.restoreQueuedTitle)
|
||||
if (isInProgress(item)) return formatMessage(messages.restoringBackupTitle)
|
||||
if (item.state === 'done') return formatMessage(messages.restoreSuccessfulTitle)
|
||||
if (item.state === 'failed') return formatMessage(messages.restoreFailedTitle)
|
||||
return ''
|
||||
}
|
||||
|
||||
function getDescription(item: AdmonitionEntry) {
|
||||
const backupName = item.name ?? formatMessage(messages.fallbackName)
|
||||
if (item.type === 'create') {
|
||||
if (isQueued(item)) return formatMessage(messages.backupQueuedDescription, { backupName })
|
||||
if (isInProgress(item)) return formatMessage(messages.creatingBackupDescription, { backupName })
|
||||
if (item.state === 'failed')
|
||||
return formatMessage(messages.backupFailedDescription, { backupName })
|
||||
}
|
||||
if (isQueued(item)) return formatMessage(messages.restoreQueuedDescription, { backupName })
|
||||
if (isInProgress(item)) return formatMessage(messages.restoringBackupDescription, { backupName })
|
||||
if (item.state === 'done')
|
||||
return formatMessage(messages.restoreSuccessfulDescription, { backupName })
|
||||
if (item.state === 'failed')
|
||||
return formatMessage(messages.restoreFailedDescription, { backupName })
|
||||
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>
|
||||
<TransitionGroup
|
||||
v-if="admonitions.length > 0"
|
||||
name="backup-admonition"
|
||||
tag="div"
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<Admonition v-for="item in admonitions" :key="item.key" :type="getAdmonitionType(item.state)">
|
||||
<template #icon="{ iconClass }">
|
||||
<component :is="getIcon(item.state)" :class="iconClass" />
|
||||
</template>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ getTitle(item) }}</span>
|
||||
<div v-if="item.createdAt" class="flex items-center gap-1.5 text-secondary">
|
||||
<ClockIcon class="size-4" />
|
||||
<span class="font-medium">{{ relativeTime(item.createdAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
{{ getDescription(item) }}
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled v-if="isQueued(item) || isInProgress(item)" type="outlined" color="blue">
|
||||
<button class="!border" @click="handleCancel(item.backupId)">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="item.state === 'failed'" color="red">
|
||||
<button @click="handleRetry(item.backupId, item.key)">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.retryButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="item.state === 'failed' || item.state === 'done'"
|
||||
circular
|
||||
type="transparent"
|
||||
hover-color-fill="background"
|
||||
:color="getButtonColor(item.state)"
|
||||
>
|
||||
<button @click="handleDismiss(item.key)">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-if="isInProgress(item)" #progress>
|
||||
<div class="pl-9">
|
||||
<ProgressBar
|
||||
:progress="item.progress"
|
||||
color="blue"
|
||||
:waiting="item.progress === 0"
|
||||
full-width
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Admonition>
|
||||
</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>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<NewModal ref="modal" header="Renaming backup" width="500px" @show="focusInput">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast"> Name </span>
|
||||
</label>
|
||||
@@ -20,26 +20,28 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
|
||||
<template v-if="renameMutation.isPending.value">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Renaming...
|
||||
</template>
|
||||
<template v-else>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
|
||||
<template v-if="renameMutation.isPending.value">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
Renaming...
|
||||
</template>
|
||||
<template v-else>
|
||||
<SaveIcon />
|
||||
Save changes
|
||||
</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
@@ -64,10 +66,10 @@ const queryClient = useQueryClient()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const props = defineProps<{
|
||||
backups?: Archon.Backups.v1.Backup[]
|
||||
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
||||
}>()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||
@@ -80,7 +82,7 @@ const input = ref<HTMLInputElement>()
|
||||
const backupName = ref('')
|
||||
const originalName = ref('')
|
||||
|
||||
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
|
||||
const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim())
|
||||
|
||||
@@ -110,7 +112,7 @@ const focusInput = () => {
|
||||
})
|
||||
}
|
||||
|
||||
function show(backup: Archon.Backups.v1.Backup) {
|
||||
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
currentBackup.value = backup
|
||||
backupName.value = backup.name
|
||||
originalName.value = backup.name
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Restore backup" fade="danger">
|
||||
<div class="flex flex-col gap-6 max-w-[400px]">
|
||||
<NewModal ref="modal" header="Restore backup" fade="danger" width="500px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
|
||||
Stop the server before restoring a backup.
|
||||
</Admonition>
|
||||
<Admonition v-else type="critical" header="Restore warning">
|
||||
<Admonition v-else type="critical" header="Your server files will be replaced">
|
||||
Restoring your server will replace the current world and server files. Any changes made
|
||||
since that backup will be permanently lost.
|
||||
</Admonition>
|
||||
@@ -17,8 +17,8 @@
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
@@ -56,18 +56,24 @@ const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
||||
|
||||
function safetyBackupName(backupName: string) {
|
||||
const base = `Before restoring "${backupName}"`
|
||||
return base.slice(0, 92)
|
||||
}
|
||||
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (backupId: string) =>
|
||||
client.archon.backups_v1.restore(ctx.serverId, ctx.worldId.value!, backupId),
|
||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||
client.archon.backups_queue_v1.restore(ctx.serverId, ctx.worldId.value!, backupId, { name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
|
||||
const currentBackup = ref<Archon.BackupsQueue.v1.BackupQueueBackup | null>(null)
|
||||
const isRestoring = ref(false)
|
||||
|
||||
function show(backup: Archon.Backups.v1.Backup) {
|
||||
function show(backup: Archon.BackupsQueue.v1.BackupQueueBackup) {
|
||||
currentBackup.value = backup
|
||||
modal.value?.show()
|
||||
}
|
||||
@@ -85,22 +91,24 @@ const restoreBackup = () => {
|
||||
}
|
||||
|
||||
isRestoring.value = true
|
||||
restoreMutation.mutate(currentBackup.value.id, {
|
||||
onSuccess: () => {
|
||||
// Optimistically update backupsState to show restore in progress immediately
|
||||
ctx.backupsState.set(currentBackup.value!.id, {
|
||||
restore: { progress: 0, state: 'ongoing' },
|
||||
})
|
||||
modal.value?.hide()
|
||||
restoreMutation.mutate(
|
||||
{
|
||||
backupId: currentBackup.value.id,
|
||||
name: safetyBackupName(currentBackup.value.name),
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
|
||||
{
|
||||
onSuccess: () => {
|
||||
modal.value?.hide()
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
|
||||
},
|
||||
onSettled: () => {
|
||||
isRestoring.value = false
|
||||
},
|
||||
},
|
||||
onSettled: () => {
|
||||
isRestoring.value = false
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { default as BackupCreateModal } from './BackupCreateModal.vue'
|
||||
export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
|
||||
export { default as BackupItem } from './BackupItem.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'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './admonitions'
|
||||
export * from './backups'
|
||||
export * from './flows'
|
||||
export * from './icons'
|
||||
|
||||
Reference in New Issue
Block a user