Files
AstralRinth/packages/ui/src/components/servers/backups/BackupItem.vue
T
François-Xavier Talbot b68aeddedc hosting: Copy ID button for backups when developer mode is on (#5681)
* Copy ID button in backups tab

* Remove codex slop
2026-03-27 00:18:33 +00:00

278 lines
7.4 KiB
Vue

<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
ClipboardCopyIcon,
ClockIcon,
DownloadIcon,
EditIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
TrashIcon,
UserRoundIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { useFormatDateTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
const { formatMessage } = useVIntl()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'retry'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Archon.Backups.v1.Backup
preview?: boolean
kyrosUrl?: string
jwt?: string
showCopyIdAction?: boolean
showDebugInfo?: boolean
restoreDisabled?: string
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
},
)
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 backupIcon = computed(() => {
if (props.backup.automated) {
return ClockIcon
}
return UserRoundIcon
})
const overflowMenuOptions = computed<OverflowOption[]>(() => {
const options: OverflowOption[] = []
if (props.showCopyIdAction) {
options.push({
id: 'copy-id',
action: () => copyId(),
})
}
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,
})
}
options.push({ id: 'rename', action: () => emit('rename') })
if (!activeOperation.value) {
options.push({ divider: true })
options.push({
id: 'delete',
color: 'red',
action: () => emit('delete'),
})
}
return options
})
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',
defaultMessage: 'Restore',
},
rename: {
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',
},
backupSchedule: {
id: 'servers.backups.item.backup-schedule',
defaultMessage: 'Backup schedule',
},
manualBackup: {
id: 'servers.backups.item.manual-backup',
defaultMessage: 'Manual backup',
},
})
</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)]'
"
>
<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>
</div>
<div class="flex items-center gap-1.5 text-sm 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> -->
</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">
<button
v-tooltip="props.restoreDisabled"
class="!border-[1px]"
:disabled="!!props.restoreDisabled"
@click="() => emit('restore')"
>
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(messages.restore) }}
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu :options="overflowMenuOptions">
<MoreVerticalIcon class="size-5" />
<template #copy-id>
<ClipboardCopyIcon class="size-5" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #download>
<DownloadIcon class="size-5" /> {{ formatMessage(commonMessages.downloadButton) }}
</template>
<template #rename>
<EditIcon class="size-5" /> {{ formatMessage(messages.rename) }}
</template>
<template #delete>
<TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</div>
<pre v-if="!preview && showDebugInfo" class="w-full rounded-xl bg-surface-4 p-2 text-xs">{{
backup
}}</pre>
</div>
</template>