You've already forked AstralRinth
forked from didirus/AstralRinth
feat: ws client & new backups frontend (#4813)
* feat: ws client * feat: v1 backups endpoints * feat: migrate backups page to api-client and new DI ctx * feat: switch to ws client via api-client * fix: disgust * fix: stats * fix: console * feat: v0 backups api * feat: migrate backups.vue to page system w/ components to ui pkgs * feat: polish backups frontend * feat: pending refactor for ws handling of backups * fix: vue shit * fix: cancel logic fix * fix: qa issues * fix: alignment issues for backups page * fix: bar positioning * feat: finish QA * fix: icons * fix: lint & i18n * fix: clear comment * lint --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -43,6 +43,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const accentedButton = computed(() =>
|
||||
@@ -69,6 +73,7 @@ const classes = computed(() => {
|
||||
'btn-hover-filled-only': props.hoverFilledOnly,
|
||||
'btn-outline': props.outline,
|
||||
'color-accent-contrast': accentedButton,
|
||||
disabled: props.disabled,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -78,10 +83,14 @@ const classes = computed(() => {
|
||||
v-if="link && link.startsWith('/')"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:to="link"
|
||||
:to="disabled ? '' : link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
@@ -96,10 +105,14 @@ const classes = computed(() => {
|
||||
v-else-if="link"
|
||||
class="btn"
|
||||
:class="classes"
|
||||
:href="link"
|
||||
:href="disabled ? undefined : link"
|
||||
:target="external ? '_blank' : '_self'"
|
||||
@click="
|
||||
(event) => {
|
||||
if (disabled) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
if (action) {
|
||||
action(event)
|
||||
}
|
||||
@@ -110,7 +123,7 @@ const classes = computed(() => {
|
||||
<ExternalIcon v-if="external && !iconOnly" class="external-icon" />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</a>
|
||||
<button v-else class="btn" :class="classes" @click="action">
|
||||
<button v-else class="btn" :class="classes" :disabled="disabled" @click="action">
|
||||
<slot />
|
||||
<UnknownIcon v-if="!$slots.default" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import SpinnerIcon from '@modrinth/assets/icons/spinner.svg'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -7,11 +8,21 @@ const props = withDefaults(
|
||||
max?: number
|
||||
color?: 'brand' | 'green' | 'red' | 'orange' | 'blue' | 'purple' | 'gray'
|
||||
waiting?: boolean
|
||||
fullWidth?: boolean
|
||||
striped?: boolean
|
||||
gradientBorder?: boolean
|
||||
label?: string
|
||||
labelClass?: string
|
||||
showProgress?: boolean
|
||||
}>(),
|
||||
{
|
||||
max: 1,
|
||||
color: 'brand',
|
||||
waiting: false,
|
||||
fullWidth: false,
|
||||
striped: false,
|
||||
gradientBorder: true,
|
||||
showProgress: false,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -49,15 +60,28 @@ const colors = {
|
||||
const percent = computed(() => props.progress / props.max)
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="flex w-full max-w-[15rem] h-1 rounded-full overflow-hidden"
|
||||
:class="colors[props.color].bg"
|
||||
>
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[colors[props.color].fg, { 'progress-bar--waiting': waiting }]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
<div class="flex w-full flex-col gap-2" :class="fullWidth ? '' : 'max-w-[15rem]'">
|
||||
<div v-if="label || showProgress" class="flex items-center justify-between">
|
||||
<span v-if="label" :class="labelClass">{{ label }}</span>
|
||||
<div v-if="showProgress" class="flex items-center gap-1 text-sm text-secondary">
|
||||
<span>{{ Math.round(percent * 100) }}%</span>
|
||||
<slot name="progress-icon">
|
||||
<SpinnerIcon class="size-5 animate-spin" />
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-2 w-full overflow-hidden rounded-full" :class="[colors[props.color].bg]">
|
||||
<div
|
||||
class="rounded-full progress-bar"
|
||||
:class="[
|
||||
colors[props.color].fg,
|
||||
{ 'progress-bar--waiting': waiting },
|
||||
{ 'progress-bar--gradient-border': gradientBorder },
|
||||
striped ? `progress-bar--striped--${color}` : '',
|
||||
]"
|
||||
:style="!waiting ? { width: `${percent * 100}%` } : {}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
@@ -83,4 +107,76 @@ const percent = computed(() => props.progress / props.max)
|
||||
width: 20%;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar--gradient-border {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), transparent);
|
||||
border-radius: inherit;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: xor;
|
||||
padding: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
%progress-bar--striped-common {
|
||||
background-attachment: scroll;
|
||||
background-position: 0 0;
|
||||
background-size: 9.38px 9.38px;
|
||||
}
|
||||
|
||||
@mixin striped-background($color-variable) {
|
||||
background-image: linear-gradient(
|
||||
135deg,
|
||||
$color-variable 11.54%,
|
||||
transparent 11.54%,
|
||||
transparent 50%,
|
||||
$color-variable 50%,
|
||||
$color-variable 61.54%,
|
||||
transparent 61.54%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.progress-bar--striped--brand {
|
||||
@include striped-background(var(--color-brand));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--green {
|
||||
@include striped-background(var(--color-green));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--red {
|
||||
@include striped-background(var(--color-red));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--orange {
|
||||
@include striped-background(var(--color-orange));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--blue {
|
||||
@include striped-background(var(--color-blue));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--purple {
|
||||
@include striped-background(var(--color-purple));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
|
||||
.progress-bar--striped--gray {
|
||||
@include striped-background(var(--color-divider-dark));
|
||||
@extend %progress-bar--striped-common;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -127,6 +127,11 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
|
||||
|
||||
// Servers
|
||||
export { default as ServersSpecs } from './billing/ServersSpecs.vue'
|
||||
export { default as BackupCreateModal } from './servers/backups/BackupCreateModal.vue'
|
||||
export { default as BackupDeleteModal } from './servers/backups/BackupDeleteModal.vue'
|
||||
export { default as BackupItem } from './servers/backups/BackupItem.vue'
|
||||
export { default as BackupRenameModal } from './servers/backups/BackupRenameModal.vue'
|
||||
export { default as BackupRestoreModal } from './servers/backups/BackupRestoreModal.vue'
|
||||
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
|
||||
export { default as LoaderIcon } from './servers/icons/LoaderIcon.vue'
|
||||
export { default as ServerIcon } from './servers/icons/ServerIcon.vue'
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div
|
||||
:class="{
|
||||
shown: visible,
|
||||
noblur: props.noblur,
|
||||
danger: danger,
|
||||
}"
|
||||
class="modal-overlay"
|
||||
:class="[
|
||||
'modal-overlay',
|
||||
{
|
||||
shown: visible,
|
||||
noblur: props.noblur,
|
||||
},
|
||||
computedFade,
|
||||
]"
|
||||
@click="() => (closeOnClickOutside && closable ? hide() : {})"
|
||||
/>
|
||||
<div class="modal-container experimental-styles-within" :class="{ shown: visible }">
|
||||
@@ -106,7 +108,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useScrollIndicator } from '../../composables/scroll-indicator'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
@@ -115,7 +117,9 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
noblur?: boolean
|
||||
closable?: boolean
|
||||
/** @deprecated Use `fade="danger"` instead */
|
||||
danger?: boolean
|
||||
fade?: 'standard' | 'warning' | 'danger'
|
||||
closeOnEsc?: boolean
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
@@ -131,6 +135,7 @@ const props = withDefaults(
|
||||
type: true,
|
||||
closable: true,
|
||||
danger: false,
|
||||
fade: undefined,
|
||||
closeOnClickOutside: true,
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
@@ -145,6 +150,12 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const computedFade = computed(() => {
|
||||
if (props.fade) return props.fade
|
||||
if (props.danger) return 'danger'
|
||||
return 'standard'
|
||||
})
|
||||
|
||||
const open = ref(false)
|
||||
const visible = ref(false)
|
||||
|
||||
@@ -225,7 +236,6 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
z-index: 19;
|
||||
opacity: 0;
|
||||
transition: all 0.2s ease-out;
|
||||
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
|
||||
//transform: translate(
|
||||
// calc((-50vw + var(--_mouse-x, 50vw) * 1px) / 2),
|
||||
// calc((-50vh + var(--_mouse-y, 50vh) * 1px) / 2)
|
||||
@@ -234,6 +244,19 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
border-radius: 180px;
|
||||
//filter: blur(5px);
|
||||
|
||||
// Fade variants
|
||||
&.standard {
|
||||
background: linear-gradient(to bottom, rgba(29, 48, 43, 0.52) 0%, rgba(14, 21, 26, 0.95) 100%);
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: linear-gradient(to bottom, rgba(48, 38, 29, 0.52) 0%, rgba(26, 20, 14, 0.95) 100%);
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -248,10 +271,6 @@ function handleKeyDown(event: KeyboardEvent) {
|
||||
backdrop-filter: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: linear-gradient(to bottom, rgba(43, 18, 26, 0.52) 0%, rgba(49, 10, 15, 0.95) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.modrinth-parent__no-modal-blurs {
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, ProjectV2 } from '@modrinth/api-client'
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LoaderCircleIcon,
|
||||
@@ -144,7 +144,22 @@ export type PendingChange = {
|
||||
verb: string
|
||||
}
|
||||
|
||||
const props = defineProps<Partial<Archon.Servers.v0.Server> & { pendingChange?: PendingChange }>()
|
||||
type ServerListingProps = {
|
||||
server_id: string
|
||||
name: string
|
||||
status: Archon.Servers.v0.Status
|
||||
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
|
||||
game?: Archon.Servers.v0.Game
|
||||
mc_version?: string | null
|
||||
loader?: Archon.Servers.v0.Loader | null
|
||||
loader_version?: string | null
|
||||
net?: Archon.Servers.v0.Net
|
||||
upstream?: Archon.Servers.v0.Upstream | null
|
||||
flows?: Archon.Servers.v0.Flows
|
||||
pendingChange?: PendingChange
|
||||
}
|
||||
|
||||
const props = defineProps<ServerListingProps>()
|
||||
|
||||
const { archon, kyros, labrinth } = injectModrinthClient()
|
||||
|
||||
@@ -153,7 +168,7 @@ const showLoaderLabel = computed(() => !!props.loader)
|
||||
|
||||
const { data: projectData } = useQuery({
|
||||
queryKey: ['project', props.upstream?.project_id] as const,
|
||||
queryFn: async (): Promise<ProjectV2 | null> => {
|
||||
queryFn: async () => {
|
||||
if (!props.upstream?.project_id) return null
|
||||
return await labrinth.projects_v2.get(props.upstream.project_id)
|
||||
},
|
||||
|
||||
158
packages/ui/src/components/servers/backups/BackupCreateModal.vue
Normal file
158
packages/ui/src/components/servers/backups/BackupCreateModal.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Create backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px] -mb-2">
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast">Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="backup-name-input"
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="w-full rounded-lg bg-bg-input p-4"
|
||||
:placeholder="`Backup #${newBackupAmount}`"
|
||||
maxlength="48"
|
||||
/>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="nameExists && !createMutation.isPending.value"
|
||||
class="flex items-center gap-1 mt-2 overflow-hidden"
|
||||
>
|
||||
<IssuesIcon class="hidden text-orange sm:block" />
|
||||
<span class="text-sm text-orange">
|
||||
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
|
||||
>'
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="isRateLimited" class="overflow-hidden text-sm text-red">
|
||||
You're creating backups too fast. Please wait a moment before trying again.
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="w-full flex flex-row gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-[1px] !border-surface-4" @click="hideModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="createMutation.isPending.value || nameExists" @click="createBackup">
|
||||
<PlusIcon />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '../../../providers'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const props = defineProps<{
|
||||
backups?: Archon.Backups.v1.Backup[]
|
||||
}>()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) => client.archon.backups_v0.create(ctx.serverId, { name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const input = ref<HTMLInputElement>()
|
||||
const isRateLimited = ref(false)
|
||||
const backupName = ref('')
|
||||
const newBackupAmount = computed(() => (props.backups?.length ?? 0) + 1)
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim())
|
||||
|
||||
const nameExists = computed(() => {
|
||||
if (!props.backups) return false
|
||||
return props.backups.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
)
|
||||
})
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
input.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
function show() {
|
||||
backupName.value = ''
|
||||
isRateLimited.value = false
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const createBackup = () => {
|
||||
const name = trimmedName.value || `Backup #${newBackupAmount.value}`
|
||||
isRateLimited.value = false
|
||||
|
||||
createMutation.mutate(name, {
|
||||
onSuccess: () => {
|
||||
hideModal()
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof Error && error.message.includes('429')) {
|
||||
isRateLimited.value = true
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error creating backup',
|
||||
text: "You're creating backups too fast.",
|
||||
})
|
||||
} else {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
addNotification({ type: 'error', title: 'Error creating backup', text: message })
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide: hideModal,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Delete backup" fade="danger">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" header="Delete warning">
|
||||
This backup will be permanently deleted. This action cannot be undone.
|
||||
</Admonition>
|
||||
|
||||
<div v-if="currentBackup" class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">Backup</span>
|
||||
<BackupItem
|
||||
:backup="currentBackup"
|
||||
preview
|
||||
class="!bg-surface-2 border-solid border-[1px] border-surface-5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="deleteBackup">
|
||||
<TrashIcon />
|
||||
Delete backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import Admonition from '../../base/Admonition.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import BackupItem from './BackupItem.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'delete', backup: Archon.Backups.v1.Backup | undefined): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const currentBackup = ref<Archon.Backups.v1.Backup>()
|
||||
|
||||
function show(backup: Archon.Backups.v1.Backup) {
|
||||
currentBackup.value = backup
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function deleteBackup() {
|
||||
modal.value?.hide()
|
||||
emit('delete', currentBackup.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
363
packages/ui/src/components/servers/backups/BackupItem.vue
Normal file
363
packages/ui/src/components/servers/backups/BackupItem.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
ClockIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
MoreVerticalIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
ShieldIcon,
|
||||
TrashIcon,
|
||||
UserRoundIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { commonMessages } from '../../../utils'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
|
||||
import ProgressBar from '../../base/ProgressBar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
|
||||
(e: 'delete', skipConfirmation?: boolean): void
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
backup: Archon.Backups.v1.Backup
|
||||
preview?: boolean
|
||||
kyrosUrl?: string
|
||||
jwt?: string
|
||||
showDebugInfo?: boolean
|
||||
disabled?: string
|
||||
}>(),
|
||||
{
|
||||
preview: false,
|
||||
kyrosUrl: undefined,
|
||||
jwt: undefined,
|
||||
showDebugInfo: false,
|
||||
disabled: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const backupQueued = computed(
|
||||
() =>
|
||||
props.backup.task?.create?.progress === 0 ||
|
||||
(props.backup.ongoing && !props.backup.task?.create),
|
||||
)
|
||||
// const automated = computed(() => props.backup.automated)
|
||||
const failedToCreate = computed(() => props.backup.interrupted)
|
||||
|
||||
const inactiveStates = ['failed', 'cancelled', 'done']
|
||||
|
||||
const creating = computed(() => {
|
||||
const task = props.backup.task?.create
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
return task
|
||||
}
|
||||
|
||||
if (props.backup.ongoing && !props.backup.task?.restore) {
|
||||
return {
|
||||
progress: 0,
|
||||
state: 'ongoing',
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const restoring = computed(() => {
|
||||
const task = props.backup.task?.restore
|
||||
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
|
||||
return task
|
||||
}
|
||||
|
||||
if (props.backup.ongoing && props.backup.task?.restore) {
|
||||
return {
|
||||
progress: 0,
|
||||
state: 'ongoing',
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const restoreQueued = computed(() => restoring.value?.progress === 0)
|
||||
|
||||
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
|
||||
|
||||
const backupIcon = computed(() => {
|
||||
if (props.backup.automated) {
|
||||
return props.backup.locked ? ShieldIcon : ClockIcon
|
||||
}
|
||||
return UserRoundIcon
|
||||
})
|
||||
|
||||
const overflowMenuOptions = computed<OverflowOption[]>(() => {
|
||||
const options: OverflowOption[] = []
|
||||
|
||||
// Download only available when not creating
|
||||
if (!creating.value) {
|
||||
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') })
|
||||
options.push({ id: 'lock', action: () => emit('lock') })
|
||||
|
||||
// Delete only available when not creating (has separate Cancel button)
|
||||
if (!creating.value) {
|
||||
options.push({ divider: true })
|
||||
options.push({
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
action: () => emit('delete'),
|
||||
disabled: !!props.disabled,
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// 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({
|
||||
locked: {
|
||||
id: 'servers.backups.item.locked',
|
||||
defaultMessage: 'Locked',
|
||||
},
|
||||
lock: {
|
||||
id: 'servers.backups.item.lock',
|
||||
defaultMessage: 'Lock',
|
||||
},
|
||||
unlock: {
|
||||
id: 'servers.backups.item.unlock',
|
||||
defaultMessage: 'Unlock',
|
||||
},
|
||||
restore: {
|
||||
id: 'servers.backups.item.restore',
|
||||
defaultMessage: 'Restore',
|
||||
},
|
||||
rename: {
|
||||
id: 'servers.backups.item.rename',
|
||||
defaultMessage: 'Rename',
|
||||
},
|
||||
queuedForBackup: {
|
||||
id: 'servers.backups.item.queued-for-backup',
|
||||
defaultMessage: 'Backup queued',
|
||||
},
|
||||
queuedForRestore: {
|
||||
id: 'servers.backups.item.queued-for-restore',
|
||||
defaultMessage: 'Restore queued',
|
||||
},
|
||||
creatingBackup: {
|
||||
id: 'servers.backups.item.creating-backup',
|
||||
defaultMessage: 'Creating backup...',
|
||||
},
|
||||
restoringBackup: {
|
||||
id: 'servers.backups.item.restoring-backup',
|
||||
defaultMessage: 'Restoring from backup...',
|
||||
},
|
||||
failedToCreateBackup: {
|
||||
id: 'servers.backups.item.failed-to-create-backup',
|
||||
defaultMessage: 'Failed to create backup',
|
||||
},
|
||||
failedToRestoreBackup: {
|
||||
id: 'servers.backups.item.failed-to-restore-backup',
|
||||
defaultMessage: 'Failed to restore from backup',
|
||||
},
|
||||
auto: {
|
||||
id: 'servers.backups.item.auto',
|
||||
defaultMessage: 'Auto',
|
||||
},
|
||||
backupSchedule: {
|
||||
id: 'servers.backups.item.backup-schedule',
|
||||
defaultMessage: 'Backup schedule',
|
||||
},
|
||||
manualBackup: {
|
||||
id: 'servers.backups.item.manual-backup',
|
||||
defaultMessage: 'Manual backup',
|
||||
},
|
||||
retry: {
|
||||
id: 'servers.backups.item.retry',
|
||||
defaultMessage: 'Retry',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
|
||||
:class="preview ? 'grid-cols-2' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
|
||||
>
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
<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"
|
||||
>
|
||||
<component :is="backupIcon" class="size-7 text-secondary md:size-10" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
{{ formatMessage(messages.auto) }}
|
||||
</span>
|
||||
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
|
||||
<LockIcon class="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 text-sm text-secondary">
|
||||
<template v-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
|
||||
class="col-span-full row-start-2 flex flex-col gap-2 md:col-span-1 md:row-start-auto md:items-center"
|
||||
>
|
||||
<template v-if="creating || restoring">
|
||||
<ProgressBar
|
||||
:progress="(creating || restoring)!.progress"
|
||||
:color="creating ? 'brand' : 'purple'"
|
||||
:waiting="(creating || restoring)!.progress === 0"
|
||||
:label="
|
||||
formatMessage(
|
||||
creating
|
||||
? backupQueued
|
||||
? messages.queuedForBackup
|
||||
: messages.creatingBackup
|
||||
: restoreQueued
|
||||
? messages.queuedForRestore
|
||||
: messages.restoringBackup,
|
||||
)
|
||||
"
|
||||
:label-class="creating ? 'text-contrast' : 'text-purple'"
|
||||
show-progress
|
||||
full-width
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="w-full font-medium text-contrast md:text-center">
|
||||
{{ dayjs(backup.created_at).format('MMMM Do YYYY, h:mm A') }}
|
||||
</span>
|
||||
<!-- TODO: Uncomment when API supports size field -->
|
||||
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!preview" class="flex shrink-0 items-center gap-2 md:justify-self-end">
|
||||
<template v-if="failedToCreate">
|
||||
<ButtonStyled>
|
||||
<button @click="() => emit('retry')">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(messages.retry) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="() => emit('delete', true)">
|
||||
<TrashIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="creating">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border-[1px] !border-surface-5" @click="() => emit('delete')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu :options="overflowMenuOptions">
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
<template #rename>
|
||||
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
|
||||
</template>
|
||||
<template v-if="backup.locked" #lock>
|
||||
<LockOpenIcon class="size-5" /> {{ formatMessage(messages.unlock) }}
|
||||
</template>
|
||||
<template v-else #lock>
|
||||
<LockIcon class="size-5" /> {{ formatMessage(messages.lock) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled color="brand" type="outlined">
|
||||
<button
|
||||
v-tooltip="props.disabled"
|
||||
class="!border-[1px]"
|
||||
:disabled="!!props.disabled"
|
||||
@click="() => emit('restore')"
|
||||
>
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(messages.restore) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<OverflowMenu :options="overflowMenuOptions">
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
<template #download>
|
||||
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
|
||||
</template>
|
||||
<template #rename>
|
||||
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
|
||||
</template>
|
||||
<template v-if="backup.locked" #lock>
|
||||
<LockOpenIcon class="size-5" /> {{ formatMessage(messages.unlock) }}
|
||||
</template>
|
||||
<template v-else #lock>
|
||||
<LockIcon class="size-5" /> {{ formatMessage(messages.lock) }}
|
||||
</template>
|
||||
<template #delete>
|
||||
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{
|
||||
backup
|
||||
}}</pre>
|
||||
</div>
|
||||
</template>
|
||||
163
packages/ui/src/components/servers/backups/BackupRenameModal.vue
Normal file
163
packages/ui/src/components/servers/backups/BackupRenameModal.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<label for="backup-name-input">
|
||||
<span class="text-lg font-semibold text-contrast"> Name </span>
|
||||
</label>
|
||||
<input
|
||||
id="backup-name-input"
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
:placeholder="`Backup #${backupNumber}`"
|
||||
maxlength="48"
|
||||
/>
|
||||
<div v-if="nameExists" class="flex items-center gap-1">
|
||||
<IssuesIcon class="hidden text-orange sm:block" />
|
||||
<span class="text-sm text-orange">
|
||||
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
|
||||
>'
|
||||
</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>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '../../../providers'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const props = defineProps<{
|
||||
backups?: Archon.Backups.v1.Backup[]
|
||||
}>()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||
client.archon.backups_v0.rename(ctx.serverId, backupId, { name }),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const input = ref<HTMLInputElement>()
|
||||
const backupName = ref('')
|
||||
const originalName = ref('')
|
||||
|
||||
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
|
||||
|
||||
const trimmedName = computed(() => backupName.value.trim())
|
||||
|
||||
const nameExists = computed(() => {
|
||||
if (
|
||||
!props.backups ||
|
||||
trimmedName.value === originalName.value ||
|
||||
renameMutation.isPending.value
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return props.backups.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
)
|
||||
})
|
||||
|
||||
const backupNumber = computed(
|
||||
() => (props.backups?.findIndex((b) => b.id === currentBackup.value?.id) ?? 0) + 1,
|
||||
)
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
input.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
function show(backup: Archon.Backups.v1.Backup) {
|
||||
currentBackup.value = backup
|
||||
backupName.value = backup.name
|
||||
originalName.value = backup.name
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const renameBackup = () => {
|
||||
if (!currentBackup.value) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error renaming backup',
|
||||
text: 'Current backup is null',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedName.value === originalName.value) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
|
||||
let newName = trimmedName.value
|
||||
if (newName.length === 0) {
|
||||
newName = `Backup #${backupNumber.value}`
|
||||
}
|
||||
|
||||
renameMutation.mutate(
|
||||
{ backupId: currentBackup.value.id, name: newName },
|
||||
{
|
||||
onSuccess: () => {
|
||||
hide()
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
addNotification({ type: 'error', title: 'Error renaming backup', text: message })
|
||||
hide()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Restore backup" fade="warning">
|
||||
<div class="flex flex-col gap-6 max-w-[600px]">
|
||||
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
|
||||
Stop the server before restoring a backup.
|
||||
</Admonition>
|
||||
<!-- TODO: Worlds: Replace "server" with "world" -->
|
||||
<Admonition v-else type="warning" header="Restore warning">
|
||||
This will overwrite all files in the server and replace them with the files from the backup.
|
||||
</Admonition>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isRestoring || ctx.isServerRunning.value" @click="restoreBackup">
|
||||
<SpinnerIcon v-if="isRestoring" class="animate-spin" />
|
||||
<RotateCounterClockwiseIcon v-else />
|
||||
{{ isRestoring ? 'Restoring...' : 'Restore backup' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '../../../providers'
|
||||
import Admonition from '../../base/Admonition.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import NewModal from '../../modal/NewModal.vue'
|
||||
import BackupItem from './BackupItem.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const backupsQueryKey = ['backups', 'list', ctx.serverId]
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: (backupId: string) => client.archon.backups_v0.restore(ctx.serverId, backupId),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const currentBackup = ref<Archon.Backups.v1.Backup | null>(null)
|
||||
const isRestoring = ref(false)
|
||||
|
||||
function show(backup: Archon.Backups.v1.Backup) {
|
||||
currentBackup.value = backup
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const restoreBackup = () => {
|
||||
if (!currentBackup.value || isRestoring.value) {
|
||||
if (!currentBackup.value) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to restore backup',
|
||||
text: 'Current backup is null',
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
},
|
||||
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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
6
packages/ui/src/components/servers/backups/index.ts
Normal file
6
packages/ui/src/components/servers/backups/index.ts
Normal file
@@ -0,0 +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 BackupRenameModal } from './BackupRenameModal.vue'
|
||||
export { default as BackupRestoreModal } from './BackupRestoreModal.vue'
|
||||
export { default as BackupWarning } from './BackupWarning.vue'
|
||||
@@ -128,7 +128,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Archon, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LoaderCircleIcon,
|
||||
@@ -152,7 +153,23 @@ import MedalBackgroundImage from './MedalBackgroundImage.vue'
|
||||
|
||||
dayjs.extend(dayjsDuration)
|
||||
|
||||
const props = defineProps<Partial<Archon.Servers.v0.Server>>()
|
||||
type MedalServerListingProps = {
|
||||
server_id: string
|
||||
name: string
|
||||
status: Archon.Servers.v0.Status
|
||||
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
|
||||
game?: Archon.Servers.v0.Game
|
||||
mc_version?: string | null
|
||||
loader?: Archon.Servers.v0.Loader | null
|
||||
loader_version?: string | null
|
||||
net?: Archon.Servers.v0.Net
|
||||
upstream?: Archon.Servers.v0.Upstream | null
|
||||
flows?: Archon.Servers.v0.Flows
|
||||
medal_expires?: string
|
||||
}
|
||||
|
||||
const props = defineProps<MedalServerListingProps>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'upgrade'): void }>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
Reference in New Issue
Block a user