Compare commits

...
33 changed files with 3355 additions and 559 deletions
@@ -6,7 +6,7 @@ import { computed, nextTick, ref } from 'vue'
import type { TabbedModalTab } from '#ui/components'
import { TabbedModal } from '#ui/components'
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
ServerSettingsAdvancedPage,
ServerSettingsGeneralPage,
@@ -22,7 +22,7 @@ import {
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { commonMessages, serverSettingsTabMessages } from '#ui/utils/common-messages'
type ShowOptions = {
serverId: string
@@ -89,10 +89,7 @@ const tabs = computed<TabbedModalTab[]>(() =>
isOwner: isOwner.value,
isAdmin: isAdmin.value,
}
const name = defineMessage({
id: `server.settings.tabs.${tab.id}`,
defaultMessage: tab.label,
})
const name = serverSettingsTabMessages[tab.id]
const shown = tab.shown ? tab.shown(ctx) : true
if (tab.external) {
@@ -222,7 +219,7 @@ defineExpose({ show, hide })
>
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
{{ server.name || 'Server' }} <ChevronRightIcon />
{{ server.name || formatMessage(commonMessages.serverLabel) }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
formatMessage(commonMessages.settingsLabel)
}}</span>
@@ -1,14 +1,16 @@
<template>
<NewModal ref="modal" header="Create backup" @show="focusInput">
<NewModal ref="modal" :header="formatMessage(messages.modalTitle)" @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>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.nameLabel)
}}</span>
</label>
<StyledInput
id="backup-name-input"
ref="input"
v-model="backupName"
:placeholder="`Backup #${newBackupAmount}`"
:placeholder="formatMessage(messages.namePlaceholder, { number: newBackupAmount })"
:maxlength="48"
wrapper-class="w-full"
/>
@@ -26,8 +28,11 @@
>
<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
>'
<IntlFormatted :message-id="messages.duplicateName" :values="{ name: trimmedName }">
<template #name-highlight="{ children }">
<span class="font-semibold"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</span>
</div>
</Transition>
@@ -40,22 +45,22 @@
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.
{{ formatMessage(messages.rateLimitedInline) }}
</div>
</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
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="createMutation.isPending.value || nameExists" @click="createBackup">
<PlusIcon />
Create backup
{{ formatMessage(messages.createBackup) }}
</button>
</ButtonStyled>
</div>
@@ -69,20 +74,60 @@ import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const messages = defineMessages({
modalTitle: {
id: 'servers.backups.create-modal.title',
defaultMessage: 'Create backup',
},
nameLabel: {
id: 'servers.backups.create-modal.name-label',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'servers.backups.create-modal.name-placeholder',
defaultMessage: 'Backup #{number}',
},
duplicateName: {
id: 'servers.backups.create-modal.duplicate-name',
defaultMessage: "You already have a backup named '<name-highlight>{name}</name-highlight>'.",
},
rateLimitedInline: {
id: 'servers.backups.create-modal.rate-limited-inline',
defaultMessage: "You're creating backups too fast. Please wait a moment before trying again.",
},
createBackup: {
id: 'servers.backups.create-modal.create-button',
defaultMessage: 'Create backup',
},
errorTitle: {
id: 'servers.backups.create-modal.notification.error.title',
defaultMessage: 'Error creating backup',
},
rateLimitedNotification: {
id: 'servers.backups.create-modal.notification.rate-limited',
defaultMessage: "You're creating backups too fast.",
},
})
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
}>()
@@ -129,7 +174,8 @@ const hideModal = () => {
}
const createBackup = () => {
const name = trimmedName.value || `Backup #${newBackupAmount.value}`
const name =
trimmedName.value || formatMessage(messages.namePlaceholder, { number: newBackupAmount.value })
isRateLimited.value = false
createMutation.mutate(name, {
@@ -141,12 +187,16 @@ const createBackup = () => {
isRateLimited.value = true
addNotification({
type: 'error',
title: 'Error creating backup',
text: "You're creating backups too fast.",
title: formatMessage(messages.errorTitle),
text: formatMessage(messages.rateLimitedNotification),
})
} else {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error creating backup', text: message })
addNotification({
type: 'error',
title: formatMessage(messages.errorTitle),
text: message,
})
}
},
})
@@ -1,28 +1,28 @@
<template>
<NewModal ref="modal" header="Delete backup" fade="danger">
<NewModal ref="modal" :header="formatMessage(messages.modalTitle)" 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.
<Admonition type="critical" :header="formatMessage(messages.warningHeader)">
{{ formatMessage(messages.warningBody) }}
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<span class="font-semibold text-contrast">{{ formatMessage(messages.backupLabel) }}</span>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
</div>
</div>
<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
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="deleteBackup">
<TrashIcon />
Delete backup
{{ formatMessage(messages.deleteButton) }}
</button>
</ButtonStyled>
</div>
@@ -35,11 +35,39 @@ import type { Archon } from '@modrinth/api-client'
import { TrashIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
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 messages = defineMessages({
modalTitle: {
id: 'servers.backups.delete-modal.title',
defaultMessage: 'Delete backup',
},
warningHeader: {
id: 'servers.backups.delete-modal.warning.header',
defaultMessage: 'Delete warning',
},
warningBody: {
id: 'servers.backups.delete-modal.warning.body',
defaultMessage: 'This backup will be permanently deleted. This action cannot be undone.',
},
backupLabel: {
id: 'servers.backups.delete-modal.backup-label',
defaultMessage: 'Backup',
},
deleteButton: {
id: 'servers.backups.delete-modal.delete-button',
defaultMessage: 'Delete backup',
},
})
const emit = defineEmits<{
(e: 'delete', backup: Archon.Backups.v1.Backup | undefined): void
}>()
@@ -2,20 +2,22 @@
import type { Archon } from '@modrinth/api-client'
import {
ClipboardCopyIcon,
ClockIcon,
DownloadIcon,
EditIcon,
LoaderCircleIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
ShieldIcon,
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 { useFormatDateTime } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import ButtonStyled from '../../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowOption } from '../../base/OverflowMenu.vue'
@@ -85,7 +87,7 @@ const activeOperation = computed(() => creating.value || restoring.value)
const backupIcon = computed(() => {
if (props.backup.automated) {
return ClockIcon
return ShieldIcon
}
return UserRoundIcon
})
@@ -143,10 +145,6 @@ const messages = defineMessages({
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',
@@ -167,35 +165,54 @@ const messages = defineMessages({
id: 'servers.backups.item.manual-backup',
defaultMessage: 'Manual backup',
},
creatingBackup: {
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup\u2026',
},
restoring: {
id: 'servers.backups.item.restoring',
defaultMessage: 'Restoring',
},
})
</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] 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)]"
>
<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" />
<LoaderCircleIcon
v-if="activeOperation"
v-tooltip="restoring ? formatMessage(messages.restoring) : undefined"
class="animate-spin text-secondary"
:class="preview ? 'size-6' : 'size-10'"
/>
<component
:is="backupIcon"
v-else
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 class="min-w-0 truncate font-semibold text-contrast">
{{ creating ? formatMessage(messages.creatingBackup) : 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>
@@ -227,47 +244,53 @@ const messages = defineMessages({
</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>
<!-- Date + size (middle column) -->
<div v-if="!preview" class="flex w-[240px] shrink-0 flex-col gap-1.5">
<span class="whitespace-nowrap font-medium text-contrast">{{
formatDateTime(backup.created_at)
}}</span>
<!-- TODO: Uncomment when API supports size field -->
<!-- <span class="text-secondary">{{ formatBytes(backup.size) }}</span> -->
<!-- <span class="font-normal 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) }}
<!-- Right side actions -->
<div v-if="!preview" class="flex w-[180px] shrink-0 items-center justify-end gap-2">
<ButtonStyled v-if="creating" type="outlined">
<button class="!border !border-surface-4" @click="() => emit('delete', true)">
{{ formatMessage(commonMessages.cancelButton) }}
</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>
<template v-else>
<ButtonStyled v-if="!activeOperation" color="brand" type="outlined">
<button
v-tooltip="props.restoreDisabled"
class="!border"
: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(commonMessages.renameButton) }}
</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">{{
@@ -11,11 +11,12 @@ import {
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 { useRelativeTime } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
import type { BackupProgressEntry } from '#ui/providers/server-context'
import { commonMessages } from '#ui/utils/common-messages'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
@@ -1,45 +1,52 @@
<template>
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
<NewModal ref="modal" :header="formatMessage(messages.modalTitle)" @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>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.nameLabel)
}}</span>
</label>
<StyledInput
id="backup-name-input"
ref="input"
v-model="backupName"
:placeholder="`Backup #${backupNumber}`"
:placeholder="formatMessage(messages.namePlaceholder, { number: backupNumber })"
:maxlength="48"
wrapper-class="w-full"
/>
<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
>'
<IntlFormatted :message-id="messages.duplicateName" :values="{ name: trimmedName }">
<template #name-highlight="{ children }">
<span class="font-semibold"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</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 />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="renameMutation.isPending.value || nameExists" @click="renameBackup">
<template v-if="renameMutation.isPending.value">
<SpinnerIcon class="animate-spin" />
{{ formatMessage(messages.renaming) }}
</template>
<template v-else>
<SaveIcon />
{{ formatMessage(commonMessages.saveChangesButton) }}
</template>
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
@@ -49,20 +56,52 @@ import { IssuesIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import ButtonStyled from '../../base/ButtonStyled.vue'
import StyledInput from '../../base/StyledInput.vue'
import NewModal from '../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const messages = defineMessages({
modalTitle: {
id: 'servers.backups.rename-modal.title',
defaultMessage: 'Renaming backup',
},
nameLabel: {
id: 'servers.backups.rename-modal.name-label',
defaultMessage: 'Name',
},
namePlaceholder: {
id: 'servers.backups.rename-modal.name-placeholder',
defaultMessage: 'Backup #{number}',
},
duplicateName: {
id: 'servers.backups.rename-modal.duplicate-name',
defaultMessage: "You already have a backup named '<name-highlight>{name}</name-highlight>'.",
},
renaming: {
id: 'servers.backups.rename-modal.renaming',
defaultMessage: 'Renaming...',
},
errorTitle: {
id: 'servers.backups.rename-modal.notification.error.title',
defaultMessage: 'Error renaming backup',
},
})
const props = defineProps<{
backups?: Archon.Backups.v1.Backup[]
}>()
@@ -125,7 +164,7 @@ const renameBackup = () => {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Error renaming backup',
title: formatMessage(messages.errorTitle),
text: 'Current backup is null',
})
return
@@ -138,7 +177,7 @@ const renameBackup = () => {
let newName = trimmedName.value
if (newName.length === 0) {
newName = `Backup #${backupNumber.value}`
newName = formatMessage(messages.namePlaceholder, { number: backupNumber.value })
}
renameMutation.mutate(
@@ -149,7 +188,11 @@ const renameBackup = () => {
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error renaming backup', text: message })
addNotification({
type: 'error',
title: formatMessage(messages.errorTitle),
text: message,
})
hide()
},
},
@@ -1,33 +1,40 @@
<template>
<NewModal ref="modal" header="Restore backup" fade="danger">
<NewModal ref="modal" :header="formatMessage(messages.modalTitle)" fade="danger">
<div class="flex flex-col gap-6 max-w-[400px]">
<Admonition v-if="ctx.isServerRunning.value" type="critical" header="Server is running">
Stop the server before restoring a backup.
<Admonition
v-if="ctx.isServerRunning.value"
type="critical"
:header="formatMessage(messages.runningHeader)"
>
{{ formatMessage(messages.runningBody) }}
</Admonition>
<Admonition v-else type="critical" header="Restore warning">
Restoring your server will replace the current world and server files. Any changes made
since that backup will be permanently lost.
<Admonition v-else type="critical" :header="formatMessage(messages.replaceFilesHeader)">
{{ formatMessage(messages.replaceFilesBody) }}
</Admonition>
<div v-if="currentBackup" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">Backup</span>
<span class="font-semibold text-contrast">{{ formatMessage(messages.backupLabel) }}</span>
<BackupItem :backup="currentBackup" preview class="!bg-surface-2 !shadow-none" />
</div>
</div>
<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
{{ formatMessage(commonMessages.cancelButton) }}
</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' }}
{{
isRestoring
? formatMessage(messages.restoringButton)
: formatMessage(messages.restoreButton)
}}
</button>
</ButtonStyled>
</div>
@@ -41,21 +48,65 @@ import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { ref } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../providers'
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
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 { formatMessage } = useVIntl()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const ctx = injectModrinthServerContext()
const messages = defineMessages({
modalTitle: {
id: 'servers.backups.restore-modal.title',
defaultMessage: 'Restore backup',
},
runningHeader: {
id: 'servers.backups.restore-modal.running.header',
defaultMessage: 'Server is running',
},
runningBody: {
id: 'servers.backups.restore-modal.running.body',
defaultMessage: 'Stop the server before restoring a backup.',
},
replaceFilesHeader: {
id: 'servers.backups.restore-modal.replace-files.header',
defaultMessage: 'Your server files will be replaced',
},
replaceFilesBody: {
id: 'servers.backups.restore-modal.replace-files.body',
defaultMessage:
'Restoring your server will replace the current world and server files. Any changes made since that backup will be permanently lost.',
},
backupLabel: {
id: 'servers.backups.restore-modal.backup-label',
defaultMessage: 'Backup',
},
restoringButton: {
id: 'servers.backups.restore-modal.restoring-button',
defaultMessage: 'Restoring...',
},
restoreButton: {
id: 'servers.backups.restore-modal.restore-button',
defaultMessage: 'Restore backup',
},
errorTitle: {
id: 'servers.backups.restore-modal.notification.error.title',
defaultMessage: 'Failed to restore backup',
},
})
const backupsQueryKey = ['backups', 'list', ctx.serverId]
const restoreMutation = useMutation({
mutationFn: (backupId: string) =>
@@ -77,7 +128,7 @@ const restoreBackup = () => {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Failed to restore backup',
title: formatMessage(messages.errorTitle),
text: 'Current backup is null',
})
}
@@ -95,7 +146,11 @@ const restoreBackup = () => {
},
onError: (error) => {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
addNotification({
type: 'error',
title: formatMessage(messages.errorTitle),
text: message,
})
},
onSettled: () => {
isRestoring.value = false
@@ -1,11 +1,22 @@
<script setup lang="ts">
import { IssuesIcon } from '@modrinth/assets'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import { defineMessages } from '#ui/composables/i18n'
import AutoLink from '../../base/AutoLink.vue'
defineProps<{
backupLink: string
}>()
const messages = defineMessages({
body: {
id: 'servers.backups.warning.body',
defaultMessage:
'You may want to <create-link>create a backup</create-link> before proceeding, as this process is irreversible and may permanently alter your world or the files on your server.',
},
})
</script>
<template>
<div
@@ -13,14 +24,16 @@ defineProps<{
>
<IssuesIcon class="mt-1 h-5 w-5 shrink-0 text-orange" />
<span class="leading-normal">
You may want to
<AutoLink
:to="backupLink"
class="font-semibold text-orange hover:underline active:brightness-125"
>create a backup</AutoLink
>
before proceeding, as this process is irreversible and may permanently alter your world or the
files on your server.
<IntlFormatted :message-id="messages.body">
<template #create-link="{ children }">
<AutoLink
:to="backupLink"
class="font-semibold text-orange hover:underline active:brightness-125"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</span>
</div>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -17,9 +18,51 @@ import type { SortType } from '#ui/utils/search'
import BrowseInstallHeader from './header.vue'
import { injectBrowseManager } from './providers/browse-manager'
const { formatMessage } = useVIntl()
const messages = defineMessages({
searchPlaceholder: {
id: 'browse-tab.search-placeholder',
defaultMessage:
'{projectType, select, mod {Search mods…} modpack {Search modpacks…} resourcepack {Search resource packs…} shader {Search shaders…} datapack {Search datapacks…} plugin {Search plugins…} server {Search servers…} project {Search projects…} other {Search…}}',
},
sortByPlaceholder: {
id: 'browse-tab.sort-by-placeholder',
defaultMessage: 'Sort by',
},
sortByPrefix: {
id: 'browse-tab.sort-by-prefix',
defaultMessage: 'Sort by:',
},
viewPlaceholder: {
id: 'browse-tab.view-placeholder',
defaultMessage: 'View',
},
viewPrefix: {
id: 'browse-tab.view-prefix',
defaultMessage: 'View:',
},
filterResultsButton: {
id: 'browse-tab.filter-results',
defaultMessage: 'Filter results…',
},
offlineMessage: {
id: 'browse-tab.offline',
defaultMessage: 'You are currently offline. Connect to the internet to browse Modrinth!',
},
noResultsMessage: {
id: 'browse-tab.no-results',
defaultMessage: 'No results found for your query!',
},
})
const ctx = injectBrowseManager()
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const searchPlaceholderText = computed(() =>
formatMessage(messages.searchPlaceholder, { projectType: ctx.projectType.value }),
)
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
ctx.effectiveSortTypes.value.map((st) => ({
value: st,
@@ -47,7 +90,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="`Search ${ctx.projectType.value}s...`"
:placeholder="searchPlaceholderText"
clearable
wrapper-class="w-full"
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
@@ -59,11 +102,11 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:model-value="ctx.effectiveCurrentSortType.value"
:options="sortOptions"
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[16rem]'"
placeholder="Sort by"
:placeholder="formatMessage(messages.sortByPlaceholder)"
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">Sort by:</span>
<span class="font-semibold text-primary">{{ formatMessage(messages.sortByPrefix) }}</span>
</template>
</Combobox>
@@ -71,17 +114,19 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
:model-value="ctx.maxResults.value"
:options="maxResultsOptions"
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[9rem]'"
placeholder="View"
:placeholder="formatMessage(messages.viewPlaceholder)"
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">View:</span>
<span class="font-semibold text-primary">{{ formatMessage(messages.viewPrefix) }}</span>
</template>
</Combobox>
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
<ButtonStyled>
<button @click="ctx.filtersMenuOpen.value = true">Filter results...</button>
<button @click="ctx.filtersMenuOpen.value = true">
{{ formatMessage(messages.filterResultsButton) }}
</button>
</ButtonStyled>
</div>
@@ -120,7 +165,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
</section>
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
{{ formatMessage(messages.offlineMessage) }}
</section>
<section
v-else-if="
@@ -130,7 +175,7 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
"
class="offline"
>
<p>No results found for your query!</p>
<p>{{ formatMessage(messages.noResultsMessage) }}</p>
</section>
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { InfoIcon, XIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -8,6 +9,19 @@ import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
import { injectBrowseManager } from './providers/browse-manager'
const { formatMessage } = useVIntl()
const messages = defineMessages({
filtersHeading: {
id: 'browse-tab.filters-heading',
defaultMessage: 'Filters',
},
hideInstalledDefault: {
id: 'browse-tab.hide-installed-default',
defaultMessage: 'Hide installed content',
},
})
const ctx = injectBrowseManager()
const isApp = computed(() => ctx.variant === 'app')
@@ -80,7 +94,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
v-if="ctx.filtersMenuOpen?.value"
class="sticky top-0 z-10 mx-1 flex items-center justify-between gap-3 border-0 border-b-[1px] border-solid border-divider bg-bg-raised px-6 py-4"
>
<h3 class="m-0 text-lg text-contrast">Filters</h3>
<h3 class="m-0 text-lg text-contrast">{{ formatMessage(messages.filtersHeading) }}</h3>
<ButtonStyled circular>
<button @click="closeFiltersMenu">
<XIcon />
@@ -98,7 +112,7 @@ function getFilterOpenByDefault(filterId: string): boolean {
>
<Checkbox
v-model="ctx.hideInstalled!.value"
:label="ctx.hideInstalledLabel?.value ?? 'Hide installed content'"
:label="ctx.hideInstalledLabel?.value ?? formatMessage(messages.hideInstalledDefault)"
class="filter-checkbox"
@update:model-value="ctx.onFilterChange()"
@click.prevent.stop
@@ -3,7 +3,7 @@
<ButtonStyled v-if="showClear && hasLogs" type="transparent">
<button @click="emit('clear')">
<XIcon />
Clear
{{ formatMessage(commonMessages.clearButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="showDelete" type="transparent" hover-color-fill="background" color="red">
@@ -13,7 +13,7 @@
@click="emit('delete')"
>
<TrashIcon />
Delete
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="hasLogs" type="transparent">
@@ -24,14 +24,14 @@
>
<SpinnerIcon v-if="sharing" class="animate-spin" />
<ShareIcon v-else />
Share
{{ formatMessage(messages.share) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button @click="emit('toggle-fullscreen')">
<ContractIcon v-if="fullscreen" />
<ExpandIcon v-else />
{{ fullscreen ? 'Collapse' : 'Expand' }}
{{ fullscreen ? formatMessage(messages.collapse) : formatMessage(messages.expand) }}
</button>
</ButtonStyled>
</div>
@@ -48,6 +48,25 @@ import {
} from '@modrinth/assets'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const messages = defineMessages({
share: {
id: 'servers.console.action.share',
defaultMessage: 'Share',
},
expand: {
id: 'servers.console.action.expand',
defaultMessage: 'Expand',
},
collapse: {
id: 'servers.console.action.collapse',
defaultMessage: 'Collapse',
},
})
defineProps<{
showClear?: boolean
@@ -1,6 +1,6 @@
<template>
<FilterPills v-model="selectedFilters" :options="visibleOptions">
<template #all> All </template>
<template #all>{{ formatMessage(commonMessages.consoleFilterAllLevels) }}</template>
</FilterPills>
</template>
@@ -8,22 +8,42 @@
import { computed } from 'vue'
import FilterPills from '#ui/components/base/FilterPills.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type { ConditionalLevel } from '../composables/console-filtering'
import type { LogLevel } from '../types'
type FilterValue = LogLevel | 'all'
const ALWAYS_VISIBLE: Array<{ id: LogLevel; label: string }> = [
{ id: 'error', label: 'Error' },
{ id: 'warn', label: 'Warn' },
{ id: 'info', label: 'Info' },
]
const { formatMessage } = useVIntl()
const CONDITIONAL_OPTIONS: Array<{ id: ConditionalLevel; label: string }> = [
{ id: 'debug', label: 'Debug' },
{ id: 'trace', label: 'Trace' },
]
const logLevelLabels = defineMessages({
error: {
id: 'servers.console.filter.log-level.error',
defaultMessage: 'Error',
},
warn: {
id: 'servers.console.filter.log-level.warn',
defaultMessage: 'Warn',
},
info: {
id: 'servers.console.filter.log-level.info',
defaultMessage: 'Info',
},
debug: {
id: 'servers.console.filter.log-level.debug',
defaultMessage: 'Debug',
},
trace: {
id: 'servers.console.filter.log-level.trace',
defaultMessage: 'Trace',
},
})
const ALWAYS_VISIBLE: LogLevel[] = ['error', 'warn', 'info']
const CONDITIONAL_LEVELS: ConditionalLevel[] = ['debug', 'trace']
const props = defineProps<{
presentLevels: Set<ConditionalLevel>
@@ -36,8 +56,11 @@ const emit = defineEmits<{
}>()
const visibleOptions = computed(() => [
...ALWAYS_VISIBLE,
...CONDITIONAL_OPTIONS.filter((opt) => props.presentLevels.has(opt.id)),
...ALWAYS_VISIBLE.map((id) => ({ id, label: formatMessage(logLevelLabels[id]) })),
...CONDITIONAL_LEVELS.filter((id) => props.presentLevels.has(id)).map((id) => ({
id,
label: formatMessage(logLevelLabels[id]),
})),
])
const selectedFilters = computed({
@@ -18,7 +18,7 @@
<StyledInput
v-model="searchQuery"
:icon="SearchIcon"
placeholder="Search logs"
:placeholder="formatMessage(messages.searchLogsPlaceholder)"
wrapper-class="flex-1"
input-class="!h-10"
clearable
@@ -65,11 +65,21 @@
@ready="handleTerminalReady"
/>
</div>
<ShareModal ref="shareModal" header="Share Logs" link :social-buttons="false" />
<NewModal ref="deleteModal" header="Delete log file" :fade="'danger'" max-width="500px">
<ShareModal
ref="shareModal"
:header="formatMessage(messages.shareLogsHeader)"
link
:social-buttons="false"
/>
<NewModal
ref="deleteModal"
:header="formatMessage(messages.deleteLogFileHeader)"
:fade="'danger'"
max-width="500px"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" header="This is irreversible">
Deleting this log file cannot be undone. Are you sure you want to continue?
<Admonition type="critical" :header="formatMessage(messages.deleteLogIrreversibleHeader)">
{{ formatMessage(messages.deleteLogIrreversibleBody) }}
</Admonition>
</div>
<template #actions>
@@ -77,13 +87,13 @@
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="deleteModal?.hide()">
<XIcon />
Cancel
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button :disabled="isDeleting" @click="confirmDelete">
<TrashIcon />
Delete
{{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled>
</div>
@@ -105,9 +115,11 @@ import Combobox from '#ui/components/base/Combobox.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import ShareModal from '#ui/components/modal/ShareModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthClient } from '#ui/providers'
import { injectModalBehavior } from '#ui/providers/modal-behavior'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { commonMessages } from '#ui/utils/common-messages'
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
import ConsoleFilterPills from './components/ConsoleFilterPills.vue'
@@ -128,10 +140,51 @@ const client = injectModrinthClient()
const modalBehavior = injectModalBehavior()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
searchLogsPlaceholder: {
id: 'servers.console.search.logs.placeholder',
defaultMessage: 'Search logs',
},
crashProblemsDetected: {
id: 'servers.console.crash.problems-detected',
defaultMessage: '{count, plural, one {# problem detected} other {# problems detected}}',
},
shareLogsHeader: {
id: 'servers.console.share.logs.header',
defaultMessage: 'Share Logs',
},
deleteLogFileHeader: {
id: 'servers.console.delete-log-file.header',
defaultMessage: 'Delete log file',
},
deleteLogIrreversibleHeader: {
id: 'servers.console.delete-log-file.irreversible.header',
defaultMessage: 'This is irreversible',
},
deleteLogIrreversibleBody: {
id: 'servers.console.delete-log-file.irreversible.body',
defaultMessage: 'Deleting this log file cannot be undone. Are you sure you want to continue?',
},
failedDeleteLogTitle: {
id: 'servers.console.delete-log-file.error.title',
defaultMessage: 'Failed to delete log file',
},
failedShareLogsTitle: {
id: 'servers.console.share.logs.error.title',
defaultMessage: 'Failed to share logs',
},
unknownErrorDetail: {
id: 'servers.console.error.unknown-detail',
defaultMessage: 'Unknown error.',
},
})
const crashHeader = computed(() => {
const problems = ctx.crashAnalysis?.value?.analysis.problems ?? []
const count = problems.length
return `${count} problem${count !== 1 ? 's' : ''} detected`
return formatMessage(messages.crashProblemsDetected, { count })
})
const crashItems = computed<CollapsibleAdmonitionItem[]>(() => {
@@ -338,8 +391,8 @@ async function confirmDelete() {
console.error('Failed to delete log file:', err)
addNotification({
type: 'error',
title: 'Failed to delete log file',
text: typeof err === 'string' ? err : 'Unknown error.',
title: formatMessage(messages.failedDeleteLogTitle),
text: typeof err === 'string' ? err : formatMessage(messages.unknownErrorDetail),
})
} finally {
isDeleting.value = false
@@ -361,8 +414,8 @@ async function handleShare() {
console.error('Failed to share logs:', err)
addNotification({
type: 'error',
title: 'Failed to share logs',
text: typeof err === 'string' ? err : 'Unknown error.',
title: formatMessage(messages.failedShareLogsTitle),
text: typeof err === 'string' ? err : formatMessage(messages.unknownErrorDetail),
})
} finally {
isSharing.value = false
@@ -33,7 +33,10 @@
<span class="text-secondary">
{{
formatMessage(messages.extracted, {
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
size:
'bytes_processed' in op
? formatBinaryIecSize(op.bytes_processed ?? 0)
: formatBinaryIecSize(0),
})
}}
<template v-if="'current_file' in op && op.current_file">
@@ -77,7 +80,6 @@
<script setup lang="ts">
import { PackageOpenIcon, XIcon } from '@modrinth/assets'
import { formatBytes } from '@modrinth/utils'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
@@ -86,7 +88,10 @@ import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { useFormatFileSizeI18n } from '../composables/format-file-size-i18n'
const { formatMessage } = useVIntl()
const { formatBinaryIecSize } = useFormatFileSizeI18n()
const messages = defineMessages({
extracting: {
@@ -122,10 +122,12 @@ import {
startFileDrag,
wasRecentDrag,
} from '../composables/file-drag-state'
import { useFormatFileSizeI18n } from '../composables/format-file-size-i18n'
import { injectFileManager } from '../providers/file-manager'
import type { FileItem } from '../types'
const { formatMessage } = useVIntl()
const { formatTableRowSize } = useFormatFileSizeI18n()
const { addNotification } = injectNotificationManager()
const ctx = injectFileManager()
@@ -164,8 +166,6 @@ const isDropTarget = computed(
)
const isDragSource = computed(() => fileDragActive.value && fileDragData.value?.path === props.path)
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
const formatDateTime = useFormatDateTime({
year: '2-digit',
month: '2-digit',
@@ -307,12 +307,7 @@ const formattedSize = computed(() => {
}
if (props.size === undefined) return ''
const bytes = props.size
if (bytes === 0) return '0 B'
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
return `${size} ${units[exponent]}`
return formatTableRowSize(props.size)
})
function openContextMenu(event: MouseEvent) {
@@ -39,11 +39,7 @@
v-model="url"
:icon="LinkIcon"
type="url"
:placeholder="
cf
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
:placeholder="cf ? CF_URL_PLACEHOLDER : ZIP_URL_PLACEHOLDER"
:disabled="submitted"
:error="touched && !!error"
autocomplete="off"
@@ -114,6 +110,10 @@ import { commonMessages } from '#ui/utils/common-messages'
import InlineBackupCreator from '../../../content-tab/components/modals/InlineBackupCreator.vue'
// Language-invariant example URLs for the input placeholder.
const CF_URL_PLACEHOLDER = 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
const ZIP_URL_PLACEHOLDER = 'https://www.example.com/.../modpack-name-1.0.2.zip'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { formatMessage } = useVIntl()
@@ -120,7 +120,10 @@ import { injectModrinthClient } from '#ui/providers/api-client'
import { injectNotificationManager } from '#ui/providers/web-notifications'
import { commonMessages } from '#ui/utils/common-messages'
import { useFormatFileSizeI18n } from '../../composables/format-file-size-i18n'
const { formatMessage } = useVIntl()
const { formatUploadQueueSize } = useFormatFileSizeI18n()
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
@@ -161,10 +164,6 @@ const messages = defineMessages({
id: 'files.upload-dropdown.incorrect-file-type',
defaultMessage: 'Upload had incorrect file type',
},
failedToUpload: {
id: 'files.upload-dropdown.failed-to-upload',
defaultMessage: 'Failed to upload {fileName}',
},
})
interface UploadItem {
@@ -240,13 +239,6 @@ watch(
{ deep: true },
)
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
}
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === 'uploading') {
item.uploader.cancel()
@@ -269,7 +261,7 @@ const uploadFile = async (file: File) => {
file,
progress: 0,
status: 'pending',
size: formatFileSize(file.size),
size: formatUploadQueueSize(file.size),
}
uploadQueue.value.push(uploadItem)
@@ -343,7 +335,7 @@ const uploadFile = async (file: File) => {
if (error instanceof Error && error.message !== 'Upload cancelled') {
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: formatMessage(messages.failedToUpload, { fileName: file.name }),
text: formatMessage(commonMessages.uploadFailedFileDetail, { fileName: file.name }),
type: 'error',
})
}
@@ -0,0 +1,63 @@
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
const TABLE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'] as const
/** Localized file sizes for the shared files tab (table, upload queue, extraction progress). */
export function useFormatFileSizeI18n() {
const { formatMessage } = useVIntl()
/** Match FileTableRow byte formatting. */
function formatTableRowSize(bytes: number): string {
if (bytes === 0) {
return formatMessage(commonMessages.fileSizeFormatted, { value: '0', unit: 'B' })
}
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), TABLE_UNITS.length - 1)
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
return formatMessage(commonMessages.fileSizeFormatted, {
value: size,
unit: TABLE_UNITS[exponent],
})
}
/** Match FileUploadDropdown queue item sizes. */
function formatUploadQueueSize(bytes: number): string {
if (bytes < 1024) {
return formatMessage(commonMessages.fileSizeFormatted, { value: String(bytes), unit: 'B' })
}
if (bytes < 1024 ** 2) {
return formatMessage(commonMessages.fileSizeFormatted, {
value: (bytes / 1024).toFixed(1),
unit: 'KB',
})
}
if (bytes < 1024 ** 3) {
return formatMessage(commonMessages.fileSizeFormatted, {
value: (bytes / 1024 ** 2).toFixed(1),
unit: 'MB',
})
}
return formatMessage(commonMessages.fileSizeFormatted, {
value: (bytes / 1024 ** 3).toFixed(1),
unit: 'GB',
})
}
/** Match @modrinth/utils formatBytes (KiB / MiB / GiB). */
function formatBinaryIecSize(bytes: number, decimals = 2): string {
if (bytes === 0) {
return formatMessage(commonMessages.fileSizeFormatted, { value: '0', unit: 'Bytes' })
}
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const units = ['Bytes', 'KiB', 'MiB', 'GiB'] as const
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1)
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm))
return formatMessage(commonMessages.fileSizeFormatted, {
value: String(value),
unit: units[i],
})
}
return { formatTableRowSize, formatUploadQueueSize, formatBinaryIecSize }
}
@@ -5,26 +5,32 @@
<!-- SFTP section -->
<div class="flex flex-col gap-2">
<div class="flex flex-col items-center justify-between gap-0.5 sm:flex-row">
<span class="text-lg font-semibold text-contrast">SFTP</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.sftpSectionTitle)
}}</span>
<ButtonStyled>
<a
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
v-tooltip="formatMessage(messages.sftpLaunchTooltip)"
class="!w-full sm:!w-auto"
:href="sftpUrl"
target="_blank"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
{{ formatMessage(messages.launchSftpButton) }}
</a>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
<span class="text-lg font-semibold text-contrast">Server Address</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.serverAddressLabel)
}}</span>
<div
v-tooltip="'Copy SFTP server address'"
v-tooltip="formatMessage(messages.copySftpAddressTooltip)"
class="copy-field hover:bg-button-bg-hover"
@click="copyToClipboard('Server address', server?.sftp_host)"
@click="
copyToClipboard(formatMessage(messages.serverAddressLabel), server?.sftp_host)
"
>
<span class="cursor-pointer font-semibold text-primary">
{{ server?.sftp_host }}
@@ -35,11 +41,18 @@
</div>
<div class="flex flex-col gap-2 sm:mt-0 sm:flex-row">
<div class="flex w-full flex-col justify-center gap-2">
<span class="text-lg font-semibold text-contrast">Username</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(commonMessages.usernameLabel)
}}</span>
<div
v-tooltip="'Copy SFTP username'"
v-tooltip="formatMessage(messages.copySftpUsernameTooltip)"
class="copy-field hover:bg-button-bg-hover"
@click="copyToClipboard('Username', server?.sftp_username)"
@click="
copyToClipboard(
formatMessage(commonMessages.usernameLabel),
server?.sftp_username,
)
"
>
<div class="truncate font-semibold">
{{ server?.sftp_username }}
@@ -50,14 +63,21 @@
</div>
</div>
<div class="flex w-full flex-col justify-center gap-2">
<span class="text-lg font-semibold text-contrast">Password</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(commonMessages.passwordLabel)
}}</span>
<div
class="copy-field-has-button [&:hover:not(:has(button:hover))]:bg-button-bg-hover"
@click="copyToClipboard('Password', server?.sftp_password)"
@click="
copyToClipboard(
formatMessage(commonMessages.passwordLabel),
server?.sftp_password,
)
"
>
<div class="flex items-center gap-1.5 h-full w-full">
<div
v-tooltip="'Copy SFTP Password'"
v-tooltip="formatMessage(messages.copySftpPasswordTooltip)"
class="h-full flex justify-between grow items-center"
>
<div class="truncate font-semibold">
@@ -72,7 +92,11 @@
<ButtonStyled type="transparent" circular>
<button
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
v-tooltip="
showPassword
? formatMessage(messages.hidePasswordTooltip)
: formatMessage(messages.showPasswordTooltip)
"
class="hover:bg-button-bg-hover grid h-10 w-10 place-content-center rounded-lg"
@click.stop="showPassword = !showPassword"
>
@@ -92,7 +116,9 @@
<div class="flex flex-col gap-2.5">
<div class="flex h-10 flex-col items-end justify-between gap-4 sm:flex-row">
<label for="startup-command-field" class="mb-0.5 flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Startup command</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.startupCommandLabel)
}}</span>
</label>
<ButtonStyled v-if="startupCommand !== defaultStartupCommand" type="transparent">
<button
@@ -101,7 +127,7 @@
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Default
{{ formatMessage(messages.defaultStartupButton) }}
</button>
</ButtonStyled>
</div>
@@ -121,13 +147,15 @@
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
</div>
</div>
<span> The command that runs when your server is started. </span>
<span>{{ formatMessage(messages.startupCommandDescription) }}</span>
</div>
<!-- Java version section -->
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Java version</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.javaVersionLabel)
}}</span>
</div>
<div class="relative max-w-xs">
<Combobox
@@ -135,7 +163,9 @@
v-model="javaVersion"
name="java-version"
:options="displayedJavaVersions"
:display-value="javaVersionLabel ?? 'Java Version'"
:display-value="
javaVersionLabel ?? formatMessage(messages.javaVersionComboboxFallback)
"
:disabled="isStartupLoading"
>
<template #dropdown-footer>
@@ -146,7 +176,11 @@
>
<EyeOffIcon v-if="showAllVersions" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
{{
showAllVersions
? formatMessage(messages.hideExtraJavaVersions)
: formatMessage(messages.showAllJavaVersions)
}}
</button>
</template>
</Combobox>
@@ -157,21 +191,23 @@
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
<span> The Java version your server runs on. </span>
<span>{{ formatMessage(messages.javaVersionDescription) }}</span>
</div>
<!-- Java runtime section -->
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Java runtime</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.javaRuntimeLabel)
}}</span>
</div>
<div class="relative max-w-xs">
<Combobox
:id="'runtime-field'"
v-model="jreVendor"
name="runtime"
:options="JRE_VENDORS"
:display-value="jreVendorLabel ?? 'Runtime'"
:options="JRE_VENDOR_OPTIONS"
:display-value="jreVendorLabel ?? formatMessage(messages.javaRuntimeComboboxFallback)"
:disabled="isStartupLoading"
/>
<div
@@ -181,7 +217,7 @@
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
<span> The Java runtime your server will use. </span>
<span>{{ formatMessage(messages.javaRuntimeDescription) }}</span>
</div>
</div>
</div>
@@ -210,13 +246,111 @@ import { computed, ref, watch } from 'vue'
import { ButtonStyled, Combobox, StyledInput } from '#ui/components'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
sftpSectionTitle: {
id: 'server.settings.advanced.sftp.title',
defaultMessage: 'SFTP',
},
sftpLaunchTooltip: {
id: 'server.settings.advanced.sftp.launch-tooltip',
defaultMessage: 'This button only works with compatible SFTP clients (e.g. WinSCP)',
},
launchSftpButton: {
id: 'server.settings.advanced.sftp.launch',
defaultMessage: 'Launch SFTP',
},
serverAddressLabel: {
id: 'server.settings.advanced.sftp.server-address',
defaultMessage: 'Server Address',
},
copySftpAddressTooltip: {
id: 'server.settings.advanced.sftp.copy-address-tooltip',
defaultMessage: 'Copy SFTP server address',
},
copySftpUsernameTooltip: {
id: 'server.settings.advanced.sftp.copy-username-tooltip',
defaultMessage: 'Copy SFTP username',
},
copySftpPasswordTooltip: {
id: 'server.settings.advanced.sftp.copy-password-tooltip',
defaultMessage: 'Copy SFTP password',
},
showPasswordTooltip: {
id: 'server.settings.advanced.sftp.show-password-tooltip',
defaultMessage: 'Show password',
},
hidePasswordTooltip: {
id: 'server.settings.advanced.sftp.hide-password-tooltip',
defaultMessage: 'Hide password',
},
startupCommandLabel: {
id: 'server.settings.advanced.startup-command.title',
defaultMessage: 'Startup command',
},
defaultStartupButton: {
id: 'server.settings.advanced.startup-command.default',
defaultMessage: 'Default',
},
startupCommandDescription: {
id: 'server.settings.advanced.startup-command.description',
defaultMessage: 'The command that runs when your server is started.',
},
javaVersionLabel: {
id: 'server.settings.advanced.java-version.title',
defaultMessage: 'Java version',
},
javaVersionComboboxFallback: {
id: 'server.settings.advanced.java-version.fallback',
defaultMessage: 'Java version',
},
javaVersionDescription: {
id: 'server.settings.advanced.java-version.description',
defaultMessage: 'The Java version your server runs on.',
},
showAllJavaVersions: {
id: 'server.settings.advanced.java-version.show-all',
defaultMessage: 'Show all versions',
},
hideExtraJavaVersions: {
id: 'server.settings.advanced.java-version.hide-extra',
defaultMessage: 'Hide extra versions',
},
javaRuntimeLabel: {
id: 'server.settings.advanced.java-runtime.title',
defaultMessage: 'Java runtime',
},
javaRuntimeComboboxFallback: {
id: 'server.settings.advanced.java-runtime.fallback',
defaultMessage: 'Runtime',
},
javaRuntimeDescription: {
id: 'server.settings.advanced.java-runtime.description',
defaultMessage: 'The Java runtime your server will use.',
},
clipboardCopiedTitle: {
id: 'server.settings.advanced.clipboard.copied.title',
defaultMessage: '{label} copied to clipboard!',
},
startupUpdateFailedTitle: {
id: 'server.settings.advanced.error.startup.title',
defaultMessage: 'Failed to update server arguments',
},
startupUpdateFailedText: {
id: 'server.settings.advanced.error.startup.text',
defaultMessage: 'Please try again later.',
},
})
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -225,11 +359,11 @@ const queryClient = useQueryClient()
const showPassword = ref(false)
const sftpUrl = computed(() => `sftp://${server.value?.sftp_username}@${server.value?.sftp_host}`)
const copyToClipboard = (name: string, textToCopy?: string) => {
const copyToClipboard = (label: string, textToCopy?: string) => {
navigator.clipboard.writeText(textToCopy || '')
addNotification({
type: 'success',
title: `${name} copied to clipboard!`,
title: formatMessage(messages.clipboardCopiedTitle, { label }),
})
}
@@ -242,7 +376,7 @@ const { data: startupData, isLoading: isStartupLoading } = useQuery({
enabled: computed(() => worldId.value !== null),
})
const JAVA_VERSIONS = [
const JAVA_VERSION_OPTIONS: { value: number; label: string }[] = [
{ value: 8, label: 'Java 8' },
{ value: 11, label: 'Java 11' },
{ value: 17, label: 'Java 17' },
@@ -271,24 +405,24 @@ function parseMinecraftReleaseVersion(version: string): MinecraftReleaseVersion
}
function filterJavaVersions(compatibleVersions: number[]) {
return JAVA_VERSIONS.filter((version) => compatibleVersions.includes(version.value))
return JAVA_VERSION_OPTIONS.filter((version) => compatibleVersions.includes(version.value))
}
const displayedJavaVersions = computed(() => {
if (showAllVersions.value) return JAVA_VERSIONS
if (showAllVersions.value) return JAVA_VERSION_OPTIONS
const mcVersion = server.value?.mc_version ?? ''
if (!mcVersion) return JAVA_VERSIONS
if (!mcVersion) return JAVA_VERSION_OPTIONS
const releaseVersion = parseMinecraftReleaseVersion(mcVersion)
if (!releaseVersion) return JAVA_VERSIONS
if (!releaseVersion) return JAVA_VERSION_OPTIONS
if (releaseVersion.major > 1) {
if (releaseVersion.major >= 26) {
return filterJavaVersions([25])
}
return JAVA_VERSIONS
return JAVA_VERSION_OPTIONS
}
if (releaseVersion.minor >= 20) return filterJavaVersions([21])
@@ -298,7 +432,7 @@ const displayedJavaVersions = computed(() => {
return filterJavaVersions([8])
})
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
const JRE_VENDOR_OPTIONS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
{ value: 'corretto', label: 'Corretto' },
{ value: 'temurin', label: 'Temurin' },
{ value: 'graal', label: 'GraalVM' },
@@ -316,9 +450,11 @@ const javaVersion = ref<number>()
const jreVendor = ref<Archon.Content.v1.JreVendor>()
const javaVersionLabel = computed(
() => JAVA_VERSIONS.find((v) => v.value === javaVersion.value)?.label,
() => JAVA_VERSION_OPTIONS.find((v) => v.value === javaVersion.value)?.label,
)
const jreVendorLabel = computed(
() => JRE_VENDOR_OPTIONS.find((v) => v.value === jreVendor.value)?.label,
)
const jreVendorLabel = computed(() => JRE_VENDORS.find((v) => v.value === jreVendor.value)?.label)
function syncFormFromData() {
startupCommand.value = savedStartupCommand.value
@@ -355,16 +491,16 @@ const { mutate: saveStartup, isPending } = useMutation({
syncFormFromData()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
title: formatMessage(commonMessages.serverSettingsUpdatedTitle),
text: formatMessage(commonMessages.serverSettingsUpdatedText),
})
},
onError: (error) => {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server arguments',
text: 'Please try again later.',
title: formatMessage(messages.startupUpdateFailedTitle),
text: formatMessage(messages.startupUpdateFailedText),
})
},
})
@@ -7,7 +7,9 @@
<!-- Server name -->
<div class="flex flex-col gap-2.5">
<label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">Server name</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.serverNameLabel)
}}</span>
</label>
<div class="flex flex-col gap-2.5">
<StyledInput
@@ -17,9 +19,11 @@
:maxlength="48"
@keyup.enter="!serverName && saveGeneral"
/>
<span>This name is only visible on Modrinth.</span>
<span>{{ formatMessage(messages.serverNameDescription) }}</span>
<div class="text-red font-medium">
<span v-if="!isValidServerName"> Server name cannot be empty. </span>
<span v-if="!isValidServerName">
{{ formatMessage(messages.serverNameEmptyError) }}
</span>
</div>
</div>
</div>
@@ -27,7 +31,9 @@
<!-- Hostname -->
<div class="flex flex-col gap-2.5">
<label for="server-subdomain" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">Hostname</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.hostnameLabel)
}}</span>
<div
class="flex w-full overflow-hidden rounded-xl bg-button-bg px-3 [box-shadow:var(--shadow-inset-sm)] transition-[box-shadow] duration-100 ease-in-out focus-within:[box-shadow:0_0_0_0.25rem_var(--color-brand-shadow)]"
>
@@ -35,12 +41,12 @@
<span
class="pointer-events-none invisible whitespace-pre px-px text-base font-medium"
aria-hidden="true"
>{{ serverSubdomain || 'Enter subdomain...' }}</span
>{{ serverSubdomain || formatMessage(messages.subdomainPlaceholder) }}</span
>
<input
id="server-subdomain"
:value="serverSubdomain"
placeholder="Enter subdomain..."
:placeholder="formatMessage(messages.subdomainPlaceholder)"
:maxlength="32"
class="absolute left-px inset-0 bg-transparent !p-0 text-base font-medium text-primary !shadow-none transition-colors placeholder:text-secondary focus:text-contrast"
autocomplete="off"
@@ -56,13 +62,13 @@
</div>
</div>
</label>
<span>Your friends can connect to your server using this address.</span>
<span>{{ formatMessage(messages.hostnameDescription) }}</span>
<div v-if="!isValidSubdomain" class="text-red font-medium">
<span v-if="!isValidLengthSubdomain">
Subdomain must be at least 5 characters long.
{{ formatMessage(messages.subdomainLengthError) }}
</span>
<span v-if="!isValidCharsSubdomain">
Subdomain can only contain alphanumeric characters and dashes.
{{ formatMessage(messages.subdomainCharsError) }}
</span>
</div>
</div>
@@ -79,15 +85,17 @@
>
<label :for="`pref-${key}`" class="flex flex-col gap-1">
<div class="flex flex-row items-center gap-2">
<span class="text-lg font-semibold text-contrast">{{ prefConfig.displayName }}</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(prefConfig.title)
}}</span>
<div
v-if="!prefConfig.implemented"
class="hidden items-center gap-1 rounded-full bg-surface-2 p-1 px-1.5 text-xs font-semibold sm:flex"
>
Coming Soon
{{ formatMessage(messages.comingSoonBadge) }}
</div>
</div>
<span>{{ prefConfig.description }}</span>
<span>{{ formatMessage(prefConfig.description) }}</span>
</label>
<div v-tooltip="getPreferenceTooltip(key)">
<Toggle
@@ -102,14 +110,16 @@
<!-- Info -->
<div class="flex flex-col gap-2.5 pb-10">
<div class="text-lg m-0 font-semibold text-contrast">Info</div>
<div class="text-lg m-0 font-semibold text-contrast">
{{ formatMessage(messages.infoSectionTitle) }}
</div>
<div class="flex flex-col gap-2.5 rounded-xl bg-surface-2 p-4">
<div
v-for="property in infoProperties"
:key="property.name"
class="flex items-start justify-between gap-4"
>
<template v-if="property.value !== 'Unknown'">
<template v-if="property.value !== unknownLabelResolved">
<span class="mt-1">{{ property.name }}</span>
<CopyCode v-if="property.type === 'copy'" :text="property.value" />
<div
@@ -145,14 +155,142 @@ import { computed, ref, watch } from 'vue'
import { CopyCode, StyledInput, Toggle } from '#ui/components'
import EditServerIcon from '#ui/components/servers/edit-server-icon/EditServerIcon.vue'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
injectPageContext,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
serverNameLabel: {
id: 'server.settings.general.server-name',
defaultMessage: 'Server name',
},
serverNameDescription: {
id: 'server.settings.general.server-name-description',
defaultMessage: 'This name is only visible on Modrinth.',
},
serverNameEmptyError: {
id: 'server.settings.general.server-name-empty',
defaultMessage: 'Server name cannot be empty.',
},
hostnameLabel: {
id: 'server.settings.general.hostname',
defaultMessage: 'Hostname',
},
subdomainPlaceholder: {
id: 'server.settings.general.subdomain-placeholder',
defaultMessage: 'Enter subdomain…',
},
hostnameDescription: {
id: 'server.settings.general.hostname-description',
defaultMessage: 'Your friends can connect to your server using this address.',
},
subdomainLengthError: {
id: 'server.settings.general.subdomain-length',
defaultMessage: 'Subdomain must be at least 5 characters long.',
},
subdomainCharsError: {
id: 'server.settings.general.subdomain-chars',
defaultMessage: 'Subdomain can only contain alphanumeric characters and dashes.',
},
prefHideSubdomainTitle: {
id: 'server.settings.general.pref.hide-subdomain.title',
defaultMessage: 'Hide subdomain label',
},
prefHideSubdomainDescription: {
id: 'server.settings.general.pref.hide-subdomain.description',
defaultMessage: 'When enabled, the subdomain label will be hidden from the server header.',
},
prefRamAsBytesTitle: {
id: 'server.settings.general.pref.ram-bytes.title',
defaultMessage: 'RAM as bytes',
},
prefRamAsBytesDescription: {
id: 'server.settings.general.pref.ram-bytes.description',
defaultMessage: 'Show RAM usage in bytes instead of a percentage.',
},
prefRamAsBytesForcedTooltip: {
id: 'server.settings.general.pref.ram-bytes.forced-tooltip',
defaultMessage: 'Feature flag enabled to always show RAM as bytes.',
},
comingSoonBadge: {
id: 'server.settings.general.coming-soon',
defaultMessage: 'Coming soon',
},
infoSectionTitle: {
id: 'server.settings.general.info.title',
defaultMessage: 'Info',
},
infoServerId: {
id: 'server.settings.general.info.server-id',
defaultMessage: 'Server ID',
},
infoNode: {
id: 'server.settings.general.info.node',
defaultMessage: 'Node',
},
infoHostname: {
id: 'server.settings.general.info.hostname',
defaultMessage: 'Hostname',
},
infoServerSpecs: {
id: 'server.settings.general.info.server-specs',
defaultMessage: 'Server specs',
},
specsAvailable: {
id: 'server.settings.general.info.specs-available',
defaultMessage: 'Available',
},
specsCpuRamLine: {
id: 'server.settings.general.info.specs-cpu-ram-line',
defaultMessage:
'{shared} {sharedNum, plural, one {Shared CPU} other {Shared CPUs}} (Bursts up to {burst} CPUs)',
},
specsRamGb: {
id: 'server.settings.general.info.specs-ram-gb',
defaultMessage: '{gb} GB RAM',
},
specsSwapGb: {
id: 'server.settings.general.info.specs-swap-gb',
defaultMessage: '{gb} GB Swap',
},
specsStorageGb: {
id: 'server.settings.general.info.specs-storage-gb',
defaultMessage: '{gb} GB SSD',
},
subdomainUnavailableTitle: {
id: 'server.settings.general.error.subdomain-taken.title',
defaultMessage: 'Subdomain not available',
},
subdomainUnavailableText: {
id: 'server.settings.general.error.subdomain-taken.text',
defaultMessage: 'The subdomain you entered is already in use.',
},
subdomainCheckFailedTitle: {
id: 'server.settings.general.error.subdomain-check.title',
defaultMessage: 'Error checking availability',
},
subdomainCheckFailedText: {
id: 'server.settings.general.error.subdomain-check.text',
defaultMessage: 'Failed to verify if the subdomain is available.',
},
settingsUpdateFailedTitle: {
id: 'server.settings.general.error.update-failed.title',
defaultMessage: 'Failed to update server settings',
},
settingsUpdateFailedText: {
id: 'server.settings.general.error.update-failed.text',
defaultMessage: 'An error occurred while attempting to update your server settings.',
},
})
const client = injectModrinthClient()
const { server: data, serverId, busyReasons } = injectModrinthServerContext()
const { featureFlags } = injectPageContext()
@@ -185,18 +323,13 @@ watch(serverName, (newValue, oldValue) => {
// Preferences
const preferences = {
hideSubdomainLabel: {
displayName: 'Hide subdomain label',
description: 'When enabled, the subdomain label will be hidden from the server header.',
title: messages.prefHideSubdomainTitle,
description: messages.prefHideSubdomainDescription,
implemented: true,
},
// autoRestart: {
// displayName: 'Auto restarts',
// description: 'Automatically restart the server if it crashes.',
// implemented: false,
// },
ramAsNumber: {
displayName: 'RAM as bytes',
description: 'Show RAM usage in bytes instead of a percentage.',
title: messages.prefRamAsBytesTitle,
description: messages.prefRamAsBytesDescription,
implemented: true,
},
} as const
@@ -229,7 +362,7 @@ const isPreferenceForcedByFeatureFlag = (key: string) =>
const getPreferenceTooltip = (key: string) =>
isPreferenceForcedByFeatureFlag(key)
? 'Feature flag enabled to always show RAM as bytes.'
? formatMessage(messages.prefRamAsBytesForcedTooltip)
: undefined
const getPreferenceValue = (key: string) =>
@@ -289,8 +422,10 @@ const getServerSpecs = (product?: Labrinth.Billing.Internal.Product | null) => {
}
}
const unknownLabelResolved = computed(() => formatMessage(commonMessages.unknownLabel))
const serverHostname = computed(() =>
serverSubdomain.value ? `${serverSubdomain.value}.modrinth.gg` : 'Unknown',
serverSubdomain.value ? `${serverSubdomain.value}.modrinth.gg` : unknownLabelResolved.value,
)
const serverSpecs = computed(() => getServerSpecs(serverProduct.value))
@@ -314,24 +449,36 @@ type InfoProperty =
}
// Info properties
const infoProperties = computed<InfoProperty[]>(() => [
{ name: 'Server ID', value: serverId ?? 'Unknown', type: 'copy' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown', type: 'copy' },
{ name: 'Hostname', value: serverHostname.value, type: 'copy' },
{
name: 'Server specs',
value: serverSpecs.value ? 'Available' : 'Unknown',
type: 'specs',
lines: serverSpecs.value
? [
`${serverSpecs.value.sharedCpus} Shared CPU${Number(serverSpecs.value.sharedCpus) > 1 ? 's' : ''} (Bursts up to ${serverSpecs.value.burstCpus} CPUs)`,
`${serverSpecs.value.ramGb} GB RAM`,
`${serverSpecs.value.swapGb} GB Swap`,
`${serverSpecs.value.storageGb} GB SSD`,
]
: [],
},
])
const infoProperties = computed<InfoProperty[]>(() => {
const u = unknownLabelResolved.value
const specs = serverSpecs.value
return [
{ name: formatMessage(messages.infoServerId), value: serverId ?? u, type: 'copy' },
{
name: formatMessage(messages.infoNode),
value: data.value?.node?.instance ?? u,
type: 'copy',
},
{ name: formatMessage(messages.infoHostname), value: serverHostname.value, type: 'copy' },
{
name: formatMessage(messages.infoServerSpecs),
value: specs ? formatMessage(messages.specsAvailable) : u,
type: 'specs',
lines: specs
? [
formatMessage(messages.specsCpuRamLine, {
shared: specs.sharedCpus,
sharedNum: Number(specs.sharedCpus),
burst: specs.burstCpus,
}),
formatMessage(messages.specsRamGb, { gb: specs.ramGb }),
formatMessage(messages.specsSwapGb, { gb: specs.swapGb }),
formatMessage(messages.specsStorageGb, { gb: specs.storageGb }),
]
: [],
},
]
})
// Unsaved changes tracking (API fields + preferences)
const hasUnsavedChanges = computed(
@@ -359,8 +506,8 @@ const saveGeneral = async () => {
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
title: formatMessage(messages.subdomainUnavailableTitle),
text: formatMessage(messages.subdomainUnavailableText),
})
return
}
@@ -370,8 +517,8 @@ const saveGeneral = async () => {
console.error('Error checking subdomain availability:', error)
addNotification({
type: 'error',
title: 'Error checking availability',
text: 'Failed to verify if the subdomain is available.',
title: formatMessage(messages.subdomainCheckFailedTitle),
text: formatMessage(messages.subdomainCheckFailedText),
})
return
}
@@ -385,15 +532,15 @@ const saveGeneral = async () => {
})
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
title: formatMessage(commonMessages.serverSettingsUpdatedTitle),
text: formatMessage(commonMessages.serverSettingsUpdatedText),
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server settings',
text: 'An error occurred while attempting to update your server settings.',
title: formatMessage(messages.settingsUpdateFailedTitle),
text: formatMessage(messages.settingsUpdateFailedText),
})
} finally {
isUpdating.value = false
@@ -2,24 +2,32 @@
<div>
<Teleport to="body">
<div class="relative z-[100]">
<NewModal ref="editAllocationModal" header="Edit allocation" width="550px">
<NewModal
ref="editAllocationModal"
:header="formatMessage(messages.editAllocationHeader)"
width="550px"
>
<form class="flex w-full flex-col gap-2" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<label for="edit-allocation-name" class="font-semibold text-contrast">
{{ formatMessage(messages.allocationNameLabel) }}
</label>
<StyledInput
id="edit-allocation-name"
ref="editAllocationInput"
v-model="editAllocationName"
wrapper-class="w-full"
:maxlength="32"
placeholder="e.g. Secondary allocation"
:placeholder="formatMessage(messages.allocationNamePlaceholder)"
/>
<div class="mb-1 mt-4 flex justify-end gap-2.5">
<ButtonStyled>
<button @click="editAllocationModal?.hide()">Cancel</button>
<button @click="editAllocationModal?.hide()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!editAllocationName || creatingAllocation" type="submit">
<SaveIcon /> Update allocation
<SaveIcon /> {{ formatMessage(messages.updateAllocationButton) }}
</button>
</ButtonStyled>
</div>
@@ -28,9 +36,9 @@
<ConfirmModal
ref="confirmDeleteModal"
title="Deleting allocation"
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
proceed-label="Delete"
:title="formatMessage(messages.deleteAllocationTitle)"
:description="deleteAllocationDescriptionText"
:proceed-label="formatMessage(commonMessages.deleteLabel)"
@proceed="confirmDeleteAllocation"
/>
</div>
@@ -47,16 +55,20 @@
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-semibold">Failed to load network settings</h1>
<h1 class="m-0 mb-2 w-fit text-4xl font-semibold">
{{ formatMessage(messages.loadNetworkErrorTitle) }}
</h1>
</div>
<p class="text-md text-secondary">
We couldn't load your server's network settings. Here's what we know:
{{ formatMessage(messages.loadNetworkErrorDescription) }}
<span class="break-all font-mono">{{
allocationsError?.message ?? 'Unknown error'
allocationsError?.message ?? formatMessage(commonMessages.unknownLabel)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
<button class="mt-6 !w-full">Retry</button>
<button class="mt-6 !w-full">
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
</div>
</div>
@@ -65,31 +77,37 @@
<div class="flex h-full flex-col gap-6">
<!-- Allocations section -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">Allocations</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.allocationsSectionTitle)
}}</span>
<div class="flex w-full flex-col items-center justify-start gap-2 sm:flex-row">
<StyledInput
v-model="createAllocationName"
wrapper-class="grow max-w-[400px]"
:maxlength="32"
placeholder="e.g. Secondary allocation"
:placeholder="formatMessage(messages.allocationNamePlaceholder)"
/>
<ButtonStyled color="brand">
<button
v-tooltip="!createAllocationName ? 'Enter a name to create an allocation' : ''"
v-tooltip="
!createAllocationName ? formatMessage(messages.createAllocationTooltip) : ''
"
:disabled="!createAllocationName || creatingAllocation"
@click="addNewAllocation"
>
<PlusIcon />
<span>Create allocation</span>
<span>{{ formatMessage(messages.createAllocationButton) }}</span>
</button>
</ButtonStyled>
</div>
<Table :columns="allocationColumns" :data="allocationRows" row-key="port">
<template #cell-name="{ row }">
<TagItem v-if="row.primary" class="!font-medium">Primary</TagItem>
<TagItem v-if="row.primary" class="!font-medium">{{
formatMessage(messages.primaryAllocationLabel)
}}</TagItem>
<span v-else class="font-semibold">{{ row.name }}</span>
</template>
<template #cell-port="{ row }">
@@ -117,16 +135,15 @@
</div>
</template>
</Table>
<span>
Create additional ports for internet-facing features like map viewers or voice chat
mods.
</span>
<span>{{ formatMessage(messages.allocationsHelpText) }}</span>
</div>
<!-- DNS records section -->
<div class="flex flex-col gap-2.5">
<label for="user-domain" class="flex flex-col gap-2">
<span class="text-lg font-semibold text-contrast">DNS records</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.dnsRecordsTitle)
}}</span>
</label>
<div class="flex w-full flex-col items-center justify-start gap-2 sm:flex-row">
<StyledInput
@@ -144,7 +161,7 @@
@click="exportDnsRecords"
>
<UploadIcon />
<span>Export</span>
<span>{{ formatMessage(messages.exportDnsButton) }}</span>
</button>
</ButtonStyled>
</div>
@@ -184,9 +201,7 @@
</template>
</Table>
<span>
Set up your personal domain to connect to your server via custom DNS records.
</span>
<span>{{ formatMessage(messages.dnsRecordsHelpText) }}</span>
</div>
</div>
</div>
@@ -209,13 +224,145 @@ import { computed, nextTick, ref } from 'vue'
import { ButtonStyled, ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components'
import type { TableColumn } from '#ui/components/base'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
editAllocationHeader: {
id: 'server.settings.network.edit-allocation.title',
defaultMessage: 'Edit allocation',
},
allocationNameLabel: {
id: 'server.settings.network.allocation-name.label',
defaultMessage: 'Name',
},
allocationNamePlaceholder: {
id: 'server.settings.network.allocation-name.placeholder',
defaultMessage: 'e.g. Secondary allocation',
},
updateAllocationButton: {
id: 'server.settings.network.update-allocation',
defaultMessage: 'Update allocation',
},
deleteAllocationTitle: {
id: 'server.settings.network.delete-allocation.title',
defaultMessage: 'Deleting allocation',
},
deleteAllocationDescription: {
id: 'server.settings.network.delete-allocation.description',
defaultMessage:
'You are deleting the allocation on port {port}. This cannot be reserved again. Are you sure you want to proceed?',
},
loadNetworkErrorTitle: {
id: 'server.settings.network.error.load.title',
defaultMessage: 'Failed to load network settings',
},
loadNetworkErrorDescription: {
id: 'server.settings.network.error.load.description',
defaultMessage: "We couldn't load your server's network settings. Here's what we know:",
},
allocationsSectionTitle: {
id: 'server.settings.network.allocations.title',
defaultMessage: 'Allocations',
},
createAllocationTooltip: {
id: 'server.settings.network.create-allocation.tooltip',
defaultMessage: 'Enter a name to create an allocation',
},
createAllocationButton: {
id: 'server.settings.network.create-allocation',
defaultMessage: 'Create allocation',
},
primaryAllocationLabel: {
id: 'server.settings.network.primary-allocation',
defaultMessage: 'Primary',
},
primaryAllocationRowName: {
id: 'server.settings.network.primary-allocation-row-name',
defaultMessage: 'Primary allocation',
},
allocationsHelpText: {
id: 'server.settings.network.allocations.help',
defaultMessage:
'Create additional ports for internet-facing features like map viewers or voice chat mods.',
},
dnsRecordsTitle: {
id: 'server.settings.network.dns.title',
defaultMessage: 'DNS records',
},
exportDnsButton: {
id: 'server.settings.network.dns.export',
defaultMessage: 'Export',
},
dnsRecordsHelpText: {
id: 'server.settings.network.dns.help',
defaultMessage: 'Set up your personal domain to connect to your server via custom DNS records.',
},
columnName: {
id: 'server.settings.network.column.name',
defaultMessage: 'Name',
},
columnPort: {
id: 'server.settings.network.column.port',
defaultMessage: 'Port',
},
columnActions: {
id: 'server.settings.network.column.actions',
defaultMessage: 'Actions',
},
columnRecordType: {
id: 'server.settings.network.column.record-type',
defaultMessage: 'Type',
},
columnRecordName: {
id: 'server.settings.network.column.record-name',
defaultMessage: 'Name',
},
columnRecordContent: {
id: 'server.settings.network.column.record-content',
defaultMessage: 'Content',
},
allocationReservedTitle: {
id: 'server.settings.network.success.allocation-reserved.title',
defaultMessage: 'Allocation reserved',
},
allocationReservedText: {
id: 'server.settings.network.success.allocation-reserved.text',
defaultMessage: 'Your allocation has been reserved.',
},
allocationRemovedTitle: {
id: 'server.settings.network.success.allocation-removed.title',
defaultMessage: 'Allocation removed',
},
allocationRemovedText: {
id: 'server.settings.network.success.allocation-removed.text',
defaultMessage: 'Your allocation has been removed.',
},
allocationUpdatedTitle: {
id: 'server.settings.network.success.allocation-updated.title',
defaultMessage: 'Allocation updated',
},
allocationUpdatedText: {
id: 'server.settings.network.success.allocation-updated.text',
defaultMessage: 'Your allocation has been updated.',
},
textCopiedTitle: {
id: 'server.settings.network.success.text-copied.title',
defaultMessage: 'Text copied',
},
textCopiedText: {
id: 'server.settings.network.success.text-copied.text',
defaultMessage: '{text} has been copied to your clipboard',
},
})
const { server, serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -237,15 +384,15 @@ const {
})
const allocations = allocationsData
const allocationColumns: TableColumn[] = [
{ key: 'name', label: 'Name', width: '40%' },
{ key: 'port', label: 'Port' },
{ key: 'actions', label: 'Actions', width: '33%', align: 'right' },
]
const allocationColumns = computed<TableColumn[]>(() => [
{ key: 'name', label: formatMessage(messages.columnName), width: '40%' },
{ key: 'port', label: formatMessage(messages.columnPort) },
{ key: 'actions', label: formatMessage(messages.columnActions), width: '33%', align: 'right' },
])
const allocationRows = computed(() => {
const primary = {
name: 'Primary allocation',
name: formatMessage(messages.primaryAllocationRowName),
port: serverPrimaryPort.value,
primary: true,
}
@@ -257,11 +404,17 @@ const allocationRows = computed(() => {
return [primary, ...extra]
})
const dnsColumns: TableColumn[] = [
{ key: 'type', label: 'Type', width: '20%' },
{ key: 'name', label: 'Name', width: '35%' },
{ key: 'content', label: 'Content' },
]
const dnsColumns = computed<TableColumn[]>(() => [
{ key: 'type', label: formatMessage(messages.columnRecordType), width: '20%' },
{ key: 'name', label: formatMessage(messages.columnRecordName), width: '35%' },
{ key: 'content', label: formatMessage(messages.columnRecordContent) },
])
const deleteAllocationDescriptionText = computed(() =>
allocationToDelete.value != null
? formatMessage(messages.deleteAllocationDescription, { port: allocationToDelete.value })
: '',
)
const editAllocationModal = ref<typeof NewModal>()
const confirmDeleteModal = ref<typeof ConfirmModal>()
@@ -284,8 +437,8 @@ const addNewAllocation = async () => {
addNotification({
type: 'success',
title: 'Allocation reserved',
text: 'Your allocation has been reserved.',
title: formatMessage(messages.allocationReservedTitle),
text: formatMessage(messages.allocationReservedText),
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
@@ -318,8 +471,8 @@ const confirmDeleteAllocation = async () => {
addNotification({
type: 'success',
title: 'Allocation removed',
text: 'Your allocation has been removed.',
title: formatMessage(messages.allocationRemovedTitle),
text: formatMessage(messages.allocationRemovedText),
})
allocationToDelete.value = null
@@ -342,8 +495,8 @@ const editAllocation = async () => {
addNotification({
type: 'success',
title: 'Allocation updated',
text: 'Your allocation has been updated.',
title: formatMessage(messages.allocationUpdatedTitle),
text: formatMessage(messages.allocationUpdatedText),
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
@@ -404,8 +557,8 @@ const copyText = (text: string) => {
navigator.clipboard.writeText(text)
addNotification({
type: 'success',
title: 'Text copied',
text: `${text} has been copied to your clipboard`,
title: formatMessage(messages.textCopiedTitle),
text: formatMessage(messages.textCopiedText, { text }),
})
}
</script>
@@ -5,32 +5,37 @@
<Admonition
v-if="hasNoProperties"
type="warning"
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
:body="formatMessage(messages.missingPropertiesWarning)"
/>
<div class="flex flex-col gap-2">
<div class="m-0">
Edit the Minecraft server properties file here, or use the
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
Files tab
</AutoLink>
to edit the full file. If you're unsure about a setting, the
<AutoLink
class="goto-link !inline-block"
to="https://minecraft.wiki/w/Server.properties"
target="_blank"
>
Minecraft Wiki
</AutoLink>
has more details.
<IntlFormatted :message-id="messages.introParagraph">
<template #files-link="{ children }">
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
<component :is="() => children" />
</AutoLink>
</template>
<template #wiki-link="{ children }">
<AutoLink
class="goto-link !inline-block"
to="https://minecraft.wiki/w/Server.properties"
target="_blank"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</div>
</div>
<div class="w-full text-sm">
<label for="search-server-properties" class="sr-only"> Search server properties </label>
<label for="search-server-properties" class="sr-only">
{{ formatMessage(messages.searchPropertiesAriaLabel) }}
</label>
<StyledInput
id="search-server-properties"
v-model="searchInput"
@@ -39,7 +44,7 @@
:icon="SearchIcon"
name="search"
autocomplete="off"
placeholder="Search server properties..."
:placeholder="formatMessage(messages.searchPropertiesPlaceholder)"
/>
</div>
<div class="flex flex-col gap-3 pb-2">
@@ -51,7 +56,9 @@
>
<div class="flex w-full flex-col gap-1.5">
<div v-if="isPropertyVisible('gamemode')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">Gamemode</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelGamemode)
}}</span>
<Chips
v-model="combinedGamemode"
:items="gamemodeItems"
@@ -63,7 +70,9 @@
v-if="combinedGamemode !== 'hardcore' && isPropertyVisible('difficulty')"
class="flex flex-col gap-2.5 my-1"
>
<span class="font-semibold text-contrast">Difficulty</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelDifficulty)
}}</span>
<Chips
v-model="selectedDifficulty"
:items="difficultyItems"
@@ -72,23 +81,27 @@
</div>
<div v-if="isPropertyVisible('max_players')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">Max players</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelMaxPlayers)
}}</span>
<StyledInput
id="server-property-max-players"
:model-value="liveProperties.max_players"
type="number"
placeholder="20"
:placeholder="formatMessage(messages.placeholderDefaultMaxPlayers)"
wrapper-class="w-full max-w-[450px]"
@update:model-value="liveProperties.max_players = String($event)"
/>
</div>
<div v-if="isPropertyVisible('motd')" class="flex flex-col gap-2.5 my-1">
<span class="font-semibold text-contrast">MOTD</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelMotd)
}}</span>
<StyledInput
id="server-property-motd"
v-model="liveProperties.motd"
placeholder="A Minecraft Server"
:placeholder="formatMessage(messages.placeholderDefaultMotd)"
wrapper-class="w-full max-w-[450px]"
/>
</div>
@@ -97,7 +110,9 @@
v-if="isPropertyVisible('allow_flight')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Allow flight</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelAllowFlight)
}}</span>
<Toggle
id="server-property-allow-flight"
:model-value="liveProperties.allow_flight === 'true'"
@@ -109,7 +124,9 @@
v-if="isPropertyVisible('allow_cheats')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Allow cheats</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelAllowCheats)
}}</span>
<Toggle
id="server-property-allow-cheats"
:model-value="liveProperties.allow_cheats === 'true'"
@@ -121,7 +138,9 @@
v-if="isPropertyVisible('white_list')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Enable whitelist</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelEnableWhitelist)
}}</span>
<Toggle id="server-property-whitelist" v-model="whitelistEnabled" />
</div>
@@ -129,7 +148,9 @@
v-if="isPropertyVisible('spawn_protection')"
class="flex flex-row items-center justify-between gap-4 h-10"
>
<span class="font-semibold text-contrast">Enable spawn protection</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelEnableSpawnProtection)
}}</span>
<Toggle
id="server-property-spawn-protection-toggle"
v-model="spawnProtectionEnabled"
@@ -140,7 +161,9 @@
v-if="spawnProtectionEnabled && isPropertyVisible('spawn_protection')"
class="flex items-center justify-between h-10"
>
<span class="font-semibold text-contrast">Protection radius</span>
<span class="font-semibold text-contrast">{{
formatMessage(messages.labelProtectionRadius)
}}</span>
<StyledInput
id="server-property-spawn-protection-radius"
:model-value="liveProperties.spawn_protection"
@@ -161,7 +184,9 @@
button-class="flex w-full flex-col gap-2 bg-transparent m-0 p-0 border-none"
>
<template #title>
<span class="text-lg font-semibold text-contrast">Advanced properties</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.advancedPropertiesTitle)
}}</span>
</template>
<div class="flex flex-col gap-6 pt-4">
@@ -201,7 +226,7 @@
:id="`server-property-${key}`"
:model-value="liveProperties[key]"
type="number"
placeholder="Type here..."
:placeholder="formatMessage(messages.propertyValuePlaceholder)"
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = String($event)"
@@ -211,7 +236,7 @@
<StyledInput
:id="`server-property-${key}`"
v-model="liveProperties[key]"
placeholder="Type here..."
:placeholder="formatMessage(messages.propertyValuePlaceholder)"
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
/>
@@ -222,14 +247,17 @@
</div>
</template>
<div>
All other properties can be edited in server.properties via the
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
Files tab </AutoLink
>.
<IntlFormatted :message-id="messages.footerParagraph">
<template #files-link="{ children }">
<AutoLink
class="goto-link !inline-block"
:to="filesTabLink"
@click="onFilesTabLinkClick"
>
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</div>
</div>
</Accordion>
@@ -239,8 +267,12 @@
class="flex flex-col items-center gap-2 py-8 text-center text-secondary"
>
<SearchIcon class="size-10" />
<span class="text-lg font-semibold text-contrast">No properties found</span>
<span>No properties match "{{ searchInput }}".</span>
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.noSearchResultsTitle)
}}</span>
<span>{{
formatMessage(messages.noSearchResultsDescription, { query: searchInput })
}}</span>
</div>
</div>
</div>
@@ -272,15 +304,230 @@ import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { Accordion, Admonition, AutoLink, Chips, StyledInput, Toggle } from '#ui/components'
import IntlFormatted from '#ui/components/base/IntlFormatted.vue'
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectServerSettings } from '#ui/layouts/shared/server-settings'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
missingPropertiesWarning: {
id: 'server.settings.properties.warning.missing',
defaultMessage:
"Some expected properties are missing from your server.properties — this usually means the server hasn't completed its first startup yet.",
},
introParagraph: {
id: 'server.settings.properties.intro',
defaultMessage:
"Edit the Minecraft server properties file here, or use the <files-link>Files tab</files-link> to edit the full file. If you're unsure about a setting, the <wiki-link>Minecraft Wiki</wiki-link> has more details.",
},
searchPropertiesAriaLabel: {
id: 'server.settings.properties.search.aria',
defaultMessage: 'Search server properties',
},
searchPropertiesPlaceholder: {
id: 'server.settings.properties.search.placeholder',
defaultMessage: 'Search server properties…',
},
labelGamemode: {
id: 'server.settings.properties.label.gamemode',
defaultMessage: 'Gamemode',
},
labelDifficulty: {
id: 'server.settings.properties.label.difficulty',
defaultMessage: 'Difficulty',
},
labelMaxPlayers: {
id: 'server.settings.properties.label.max-players',
defaultMessage: 'Max players',
},
labelMotd: {
id: 'server.settings.properties.label.motd',
defaultMessage: 'MOTD',
},
labelAllowFlight: {
id: 'server.settings.properties.label.allow-flight',
defaultMessage: 'Allow flight',
},
labelAllowCheats: {
id: 'server.settings.properties.label.allow-cheats',
defaultMessage: 'Allow cheats',
},
labelEnableWhitelist: {
id: 'server.settings.properties.label.enable-whitelist',
defaultMessage: 'Enable whitelist',
},
labelEnableSpawnProtection: {
id: 'server.settings.properties.label.enable-spawn-protection',
defaultMessage: 'Enable spawn protection',
},
labelProtectionRadius: {
id: 'server.settings.properties.label.protection-radius',
defaultMessage: 'Protection radius',
},
advancedPropertiesTitle: {
id: 'server.settings.properties.advanced.title',
defaultMessage: 'Advanced properties',
},
groupPerformance: {
id: 'server.settings.properties.group.performance',
defaultMessage: 'Performance',
},
groupResourcePack: {
id: 'server.settings.properties.group.resource-pack',
defaultMessage: 'Resource pack',
},
propertyValuePlaceholder: {
id: 'server.settings.properties.placeholder.value',
defaultMessage: 'Type here…',
},
placeholderDefaultMaxPlayers: {
id: 'server.settings.properties.placeholder.max-players',
defaultMessage: '20',
},
placeholderDefaultMotd: {
id: 'server.settings.properties.placeholder.motd',
defaultMessage: 'A Minecraft Server',
},
footerParagraph: {
id: 'server.settings.properties.footer',
defaultMessage:
'All other properties can be edited in server.properties via the <files-link>Files tab</files-link>.',
},
noSearchResultsTitle: {
id: 'server.settings.properties.search.no-results.title',
defaultMessage: 'No properties found',
},
noSearchResultsDescription: {
id: 'server.settings.properties.search.no-results.description',
defaultMessage: 'No properties match "{query}".',
},
propertiesUpdatedTitle: {
id: 'server.settings.properties.success.updated.title',
defaultMessage: 'Server properties updated',
},
propertiesUpdatedText: {
id: 'server.settings.properties.success.updated.text',
defaultMessage: 'Your server properties were successfully changed.',
},
propertiesUpdateFailedTitle: {
id: 'server.settings.properties.error.update.title',
defaultMessage: 'Failed to update server properties',
},
propertiesUpdateFailedFallback: {
id: 'server.settings.properties.error.update.fallback',
defaultMessage: 'An error occurred.',
},
})
const propertyFieldMessages = defineMessages({
allow_cheats: {
id: 'server.settings.properties.field.allow_cheats',
defaultMessage: 'Allow cheats',
},
allow_flight: {
id: 'server.settings.properties.field.allow_flight',
defaultMessage: 'Allow flight',
},
difficulty: {
id: 'server.settings.properties.field.difficulty',
defaultMessage: 'Difficulty',
},
enforce_whitelist: {
id: 'server.settings.properties.field.enforce_whitelist',
defaultMessage: 'Enforce whitelist',
},
force_gamemode: {
id: 'server.settings.properties.field.force_gamemode',
defaultMessage: 'Force gamemode',
},
gamemode: {
id: 'server.settings.properties.field.gamemode',
defaultMessage: 'Gamemode',
},
generate_structures: {
id: 'server.settings.properties.field.generate_structures',
defaultMessage: 'Generate structures',
},
generator_settings: {
id: 'server.settings.properties.field.generator_settings',
defaultMessage: 'Generator settings',
},
hardcore: {
id: 'server.settings.properties.field.hardcore',
defaultMessage: 'Hardcore',
},
level_seed: {
id: 'server.settings.properties.field.level_seed',
defaultMessage: 'Level seed',
},
level_type: {
id: 'server.settings.properties.field.level_type',
defaultMessage: 'Level type',
},
max_players: {
id: 'server.settings.properties.field.max_players',
defaultMessage: 'Max players',
},
max_tick_time: {
id: 'server.settings.properties.field.max_tick_time',
defaultMessage: 'Max tick time',
},
motd: {
id: 'server.settings.properties.field.motd',
defaultMessage: 'MOTD',
},
pause_when_empty_seconds: {
id: 'server.settings.properties.field.pause_when_empty_seconds',
defaultMessage: 'Pause when empty (seconds)',
},
player_idle_timeout: {
id: 'server.settings.properties.field.player_idle_timeout',
defaultMessage: 'Player idle timeout',
},
require_resource_pack: {
id: 'server.settings.properties.field.require_resource_pack',
defaultMessage: 'Require resource pack',
},
resource_pack: {
id: 'server.settings.properties.field.resource_pack',
defaultMessage: 'Resource pack',
},
resource_pack_id: {
id: 'server.settings.properties.field.resource_pack_id',
defaultMessage: 'Resource pack ID',
},
resource_pack_sha1: {
id: 'server.settings.properties.field.resource_pack_sha1',
defaultMessage: 'Resource pack SHA-1',
},
simulation_distance: {
id: 'server.settings.properties.field.simulation_distance',
defaultMessage: 'Simulation distance',
},
spawn_protection: {
id: 'server.settings.properties.field.spawn_protection',
defaultMessage: 'Spawn protection',
},
sync_chunk_writes: {
id: 'server.settings.properties.field.sync_chunk_writes',
defaultMessage: 'Sync chunk writes',
},
view_distance: {
id: 'server.settings.properties.field.view_distance',
defaultMessage: 'View distance',
},
white_list: {
id: 'server.settings.properties.field.white_list',
defaultMessage: 'Whitelist',
},
})
const client = injectModrinthClient()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
@@ -329,9 +576,9 @@ function getPropertyDef(key: string): PropertyDef {
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
}
const ADVANCED_GROUPS = [
const ADVANCED_GROUP_DEFS = [
{
label: 'Performance',
labelMessage: messages.groupPerformance,
keys: [
'view_distance',
'simulation_distance',
@@ -342,10 +589,10 @@ const ADVANCED_GROUPS = [
],
},
{
label: 'Resource Pack',
labelMessage: messages.groupResourcePack,
keys: ['resource_pack', 'resource_pack_id', 'resource_pack_sha1', 'require_resource_pack'],
},
]
] as const
type CombinedGamemode = 'survival' | 'creative' | 'hardcore'
const gamemodeItems: CombinedGamemode[] = ['survival', 'creative', 'hardcore']
@@ -494,15 +741,18 @@ const { mutateAsync: saveProperties, isPending: isUpdating } = useMutation({
syncFormFromData()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
title: formatMessage(messages.propertiesUpdatedTitle),
text: formatMessage(messages.propertiesUpdatedText),
})
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: error instanceof Error ? error.message : 'An error occurred.',
title: formatMessage(messages.propertiesUpdateFailedTitle),
text:
error instanceof Error
? error.message
: formatMessage(messages.propertiesUpdateFailedFallback),
})
},
})
@@ -512,8 +762,8 @@ function resetProperties() {
}
const advancedGroupedProperties = computed(() =>
ADVANCED_GROUPS.map((group) => ({
label: group.label,
ADVANCED_GROUP_DEFS.map((group) => ({
label: formatMessage(group.labelMessage),
properties: group.keys.filter((key) => key in liveProperties.value),
})).filter((g) => g.properties.length > 0),
)
@@ -551,6 +801,10 @@ const hasVisibleAdvancedProperties = computed(() =>
)
function formatPropertyName(name: string): string {
const known = propertyFieldMessages[name as keyof typeof propertyFieldMessages]
if (known) {
return formatMessage(known)
}
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
@@ -29,7 +29,6 @@ export interface ServerSettingsTabContext {
export interface ServerSettingsTabDefinition {
id: ServerSettingsTabId
label: string
icon: Component
href?: (ctx: ServerSettingsTabContext) => string
external?: boolean
@@ -39,33 +38,27 @@ export interface ServerSettingsTabDefinition {
export const serverSettingsTabDefinitions: ServerSettingsTabDefinition[] = [
{
id: 'general',
label: 'General',
icon: SettingsIcon,
},
{
id: 'installation',
label: 'Installation',
icon: WrenchIcon,
},
{
id: 'network',
label: 'Network',
icon: VersionIcon,
},
{
id: 'properties',
label: 'Properties',
icon: ListIcon,
shown: ({ serverStatus }) => serverStatus !== 'installing',
},
{
id: 'advanced',
label: 'Advanced',
icon: TextQuoteIcon,
},
{
id: 'billing',
label: 'Billing',
icon: CardIcon,
href: ({ serverId }) => `/settings/billing#server-${serverId}`,
external: true,
@@ -73,7 +66,6 @@ export const serverSettingsTabDefinitions: ServerSettingsTabDefinition[] = [
},
{
id: 'admin-billing',
label: 'Admin Billing',
icon: ModrinthIcon,
href: ({ ownerId }) => `/admin/billing/${ownerId}`,
external: true,
@@ -1,14 +1,18 @@
<template>
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-16 max-w-[500px]">
<div class="flex flex-col gap-2 w-full">
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth Hosting</h2>
<h2 class="m-0 text-2xl font-semibold text-contrast">
{{ formatMessage(messages.welcomeTitle) }}
</h2>
<p class="m-0 text-base text-secondary">
Your server is ready. Here's what you need to do to start playing!
{{ formatMessage(messages.welcomeDescription) }}
</p>
</div>
<div class="flex flex-col gap-4">
<span class="text-base font-medium text-secondary"> Setup your server (~2mins) </span>
<span class="text-base font-medium text-secondary">
{{ formatMessage(messages.setupHeading) }}
</span>
<div class="rounded-[20px] border border-solid border-surface-5 bg-surface-3 p-5">
<div class="flex flex-col">
@@ -41,11 +45,13 @@
<ButtonStyled v-if="uploading" size="large">
<button class="ml-auto" disabled>
<SpinnerIcon class="animate-spin" />
Uploading ({{ uploadPercent }}%)
{{ formatMessage(messages.uploadingProgress, { percent: uploadPercent }) }}
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" size="large">
<button class="ml-auto" @click="openModal">Setup server <RightArrowIcon /></button>
<button class="ml-auto" @click="openModal">
{{ formatMessage(messages.setupServerButton) }} <RightArrowIcon />
</button>
</ButtonStyled>
</div>
@@ -66,7 +72,13 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { GlobeIcon, PackageIcon, RightArrowIcon, SpinnerIcon, UsersIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import {
ButtonStyled,
defineMessages,
injectModrinthClient,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -77,6 +89,73 @@ import { injectModrinthServerContext } from '#ui/providers'
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
welcomeTitle: {
id: 'servers.onboarding.welcome-title',
defaultMessage: 'Welcome to Modrinth Hosting',
},
welcomeDescription: {
id: 'servers.onboarding.welcome-description',
defaultMessage: "Your server is ready. Here's what you need to do to start playing!",
},
setupHeading: {
id: 'servers.onboarding.setup-heading',
defaultMessage: 'Set up your server (~2 mins)',
},
uploadingProgress: {
id: 'servers.onboarding.uploading-progress',
defaultMessage: 'Uploading ({percent}%)',
},
setupServerButton: {
id: 'servers.onboarding.setup-server-button',
defaultMessage: 'Set up server',
},
step1Title: {
id: 'servers.onboarding.step-1.title',
defaultMessage: 'Choose what to play',
},
step1Description: {
id: 'servers.onboarding.step-1.description',
defaultMessage:
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
},
step2Title: {
id: 'servers.onboarding.step-2.title',
defaultMessage: 'Configure your world',
},
step2Description: {
id: 'servers.onboarding.step-2.description',
defaultMessage:
'Set up your world just like singleplayer. Choose your game mode and world seed.',
},
step3Title: {
id: 'servers.onboarding.step-3.title',
defaultMessage: 'Invite your friends',
},
step3Description: {
id: 'servers.onboarding.step-3.description',
defaultMessage:
"Share your server with friends by copying the address and letting them know which mods they'll need to join.",
},
modpackUploadFailedTitle: {
id: 'servers.onboarding.modpack-upload-failed.title',
defaultMessage: 'Modpack upload failed',
},
modpackUploadFailedText: {
id: 'servers.onboarding.modpack-upload-failed.text',
defaultMessage: 'An unexpected error occurred while uploading. Please try again later.',
},
installationFailedTitle: {
id: 'servers.onboarding.installation-failed.title',
defaultMessage: 'Installation failed',
},
installationFailedText: {
id: 'servers.onboarding.installation-failed.text',
defaultMessage: 'An unexpected error occurred while installing. Please try again later.',
},
})
async function searchModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
@@ -209,8 +288,8 @@ const onCreate = async (config: CreationFlowContextValue) => {
await finalizeSetup()
} catch {
addNotification({
title: 'Modpack upload failed',
text: 'An unexpected error occurred while uploading. Please try again later.',
title: formatMessage(messages.modpackUploadFailedTitle),
text: formatMessage(messages.modpackUploadFailedText),
type: 'error',
})
config.loading.value = false
@@ -252,31 +331,29 @@ const onCreate = async (config: CreationFlowContextValue) => {
await finalizeSetup()
} catch {
addNotification({
title: 'Installation failed',
text: 'An unexpected error occurred while installing. Please try again later.',
title: formatMessage(messages.installationFailedTitle),
text: formatMessage(messages.installationFailedText),
type: 'error',
})
config.loading.value = false
}
}
const steps = [
const steps = computed(() => [
{
icon: PackageIcon,
title: 'Choose what to play',
description:
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
title: formatMessage(messages.step1Title),
description: formatMessage(messages.step1Description),
},
{
icon: GlobeIcon,
title: 'Configure your world',
description: 'Set up your world just like singleplayer. Choose your gamemode and world seed.',
title: formatMessage(messages.step2Title),
description: formatMessage(messages.step2Description),
},
{
icon: UsersIcon,
title: 'Invite your friends',
description:
"Share your server with friends by copying the address and letting them know which mods they'll need to join.",
title: formatMessage(messages.step3Title),
description: formatMessage(messages.step3Description),
},
]
])
</script>
@@ -11,16 +11,18 @@
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">
{{ formatMessage(messages.loadFailedTitle) }}
</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
{{ formatMessage(messages.loadFailedDescription) }}
</p>
<p>
<span class="break-all font-mono">{{ error.message }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="refetch">
<button class="mt-6 !w-full">Retry</button>
<button class="mt-6 !w-full">{{ formatMessage(commonMessages.retryButton) }}</button>
</ButtonStyled>
</div>
</div>
@@ -33,7 +35,9 @@
<BackupDeleteModal ref="deleteBackupModal" @delete="deleteBackup" />
<div v-if="backupsData?.length" class="mb-2 flex items-center align-middle justify-between">
<span class="text-2xl font-semibold text-contrast">Backups</span>
<span class="text-2xl font-semibold text-contrast">{{
formatMessage(messages.backupsHeading)
}}</span>
<ButtonStyled color="brand">
<button
v-tooltip="backupCreationDisabled"
@@ -41,7 +45,7 @@
@click="showCreateModel"
>
<PlusIcon class="size-5" />
Create backup
{{ formatMessage(messages.createBackup) }}
</button>
</ButtonStyled>
</div>
@@ -55,13 +59,13 @@
>
<template v-if="!backupsData">
<SpinnerIcon class="animate-spin" />
Loading backups...
{{ formatMessage(messages.loadingBackups) }}
</template>
<template v-else>
<EmptyState
type="empty-inbox"
heading="No backups yet"
description="Create your first backup"
:heading="formatMessage(messages.noBackupsHeading)"
:description="formatMessage(messages.noBackupsDescription)"
>
<template #actions>
<ButtonStyled color="brand">
@@ -72,7 +76,7 @@
@click="showCreateModel"
>
<PlusIcon class="size-5" />
Create backup
{{ formatMessage(messages.createBackup) }}
</button>
</ButtonStyled>
</template>
@@ -81,10 +85,12 @@
</div>
<div v-else key="list" class="flex flex-col gap-1.5">
<template v-for="group in groupedBackups" :key="group.label">
<template v-for="group in groupedBackups" :key="group.label.id">
<div class="flex items-center gap-2">
<component :is="group.icon" v-if="group.icon" class="size-6 text-secondary" />
<span class="text-lg font-semibold text-secondary">{{ group.label }}</span>
<span class="text-lg font-semibold text-secondary">{{
formatMessage(group.label)
}}</span>
</div>
<div class="flex gap-2">
@@ -156,15 +162,83 @@ import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
import { useVIntl } from '#ui/composables/i18n'
import { defineMessages, type MessageDescriptor, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
loadFailedTitle: {
id: 'servers.manage.backups.load-failed.title',
defaultMessage: 'Failed to load backups',
},
loadFailedDescription: {
id: 'servers.manage.backups.load-failed.description',
defaultMessage: "We couldn't load your server's backups. Here's what went wrong:",
},
backupsHeading: {
id: 'servers.manage.backups.heading',
defaultMessage: 'Backups',
},
createBackup: {
id: 'servers.manage.backups.create-backup',
defaultMessage: 'Create backup',
},
loadingBackups: {
id: 'servers.manage.backups.loading',
defaultMessage: 'Loading backups...',
},
noBackupsHeading: {
id: 'servers.manage.backups.empty.heading',
defaultMessage: 'No backups yet',
},
noBackupsDescription: {
id: 'servers.manage.backups.empty.description',
defaultMessage: 'Create your first backup',
},
groupJustNow: {
id: 'servers.manage.backups.group.just-now',
defaultMessage: 'Just now',
},
groupEarlierToday: {
id: 'servers.manage.backups.group.earlier-today',
defaultMessage: 'Earlier today',
},
groupYesterday: {
id: 'servers.manage.backups.group.yesterday',
defaultMessage: 'Yesterday',
},
groupLastTwoWeeks: {
id: 'servers.manage.backups.group.last-two-weeks',
defaultMessage: 'Last 2 weeks',
},
groupOlder: {
id: 'servers.manage.backups.group.older',
defaultMessage: 'Older',
},
restoreDisabledRunning: {
id: 'servers.manage.backups.tooltip.restore-disabled-running',
defaultMessage: 'Cannot restore backup while server is running',
},
backupSlotsFull: {
id: 'servers.manage.backups.tooltip.backup-slots-full',
defaultMessage: 'All {quota, number} of your backup slots are in use',
},
backupInProgressTooltip: {
id: 'servers.manage.backups.tooltip.backup-in-progress',
defaultMessage: 'A backup is already in progress',
},
errorDeletingBackupTitle: {
id: 'servers.manage.backups.notification.delete-error.title',
defaultMessage: 'Error deleting backup',
},
})
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { server, worldId, backupsState, markBackupCancelled, busyReasons } =
@@ -240,7 +314,7 @@ const backups = computed(() => {
})
type BackupGroup = {
label: string
label: MessageDescriptor
icon: Component | null
backups: Archon.Backups.v1.Backup[]
}
@@ -251,8 +325,12 @@ const groupedBackups = computed((): BackupGroup[] => {
const now = dayjs()
const groups: BackupGroup[] = []
const addToGroup = (label: string, icon: Component | null, backup: Archon.Backups.v1.Backup) => {
let group = groups.find((g) => g.label === label)
const addToGroup = (
label: MessageDescriptor,
icon: Component | null,
backup: Archon.Backups.v1.Backup,
) => {
let group = groups.find((g) => g.label.id === label.id)
if (!group) {
group = { label, icon, backups: [] }
groups.push(group)
@@ -268,15 +346,15 @@ const groupedBackups = computed((): BackupGroup[] => {
const diffDays = now.diff(created, 'day')
if (diffMinutes < 30 && isToday) {
addToGroup('Just now', CalendarIcon, backup)
addToGroup(messages.groupJustNow, CalendarIcon, backup)
} else if (isToday) {
addToGroup('Earlier today', CalendarIcon, backup)
addToGroup(messages.groupEarlierToday, CalendarIcon, backup)
} else if (isYesterday) {
addToGroup('Yesterday', CalendarIcon, backup)
addToGroup(messages.groupYesterday, CalendarIcon, backup)
} else if (diffDays <= 14) {
addToGroup('Last 2 weeks', CalendarIcon, backup)
addToGroup(messages.groupLastTwoWeeks, CalendarIcon, backup)
} else {
addToGroup('Older', CalendarIcon, backup)
addToGroup(messages.groupOlder, CalendarIcon, backup)
}
}
@@ -291,7 +369,7 @@ const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
const backupRestoreDisabled = computed(() => {
if (props.isServerRunning) {
return 'Cannot restore backup while server is running'
return formatMessage(messages.restoreDisabledRunning)
}
if (busyReasons.value.length > 0) {
return formatMessage(busyReasons.value[0].reason)
@@ -304,7 +382,7 @@ const backupCreationDisabled = computed(() => {
if (quota !== undefined) {
const usedCount = backupsData.value?.length ?? server.value.used_backup_quota ?? 0
if (usedCount >= quota) {
return `All ${quota} of your backup slots are in use`
return formatMessage(messages.backupSlotsFull, { quota })
}
}
if (busyReasons.value.length > 0) {
@@ -312,7 +390,7 @@ const backupCreationDisabled = computed(() => {
}
// also check for active backups, combining REST data with WS overlay
if (backups.value.some((b) => b.status === 'in_progress' || b.status === 'pending')) {
return 'A backup is already in progress'
return formatMessage(messages.backupInProgressTooltip)
}
return undefined
})
@@ -338,7 +416,7 @@ function deleteBackup(backup?: Archon.Backups.v1.Backup) {
if (!backup) {
addNotification({
type: 'error',
title: 'Error deleting backup',
title: formatMessage(messages.errorDeletingBackupTitle),
text: 'Backup is null',
})
return
@@ -349,7 +427,7 @@ function deleteBackup(backup?: Archon.Backups.v1.Backup) {
const message = err instanceof Error ? err.message : String(err)
addNotification({
type: 'error',
title: 'Error deleting backup',
title: formatMessage(messages.errorDeletingBackupTitle),
text: message,
})
},
@@ -65,10 +65,52 @@ import { useStorage } from '@vueuse/core'
import { computed, defineAsyncComponent, ref, shallowRef, watch } from 'vue'
import { RouterLink } from 'vue-router'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext, injectPageContext } from '#ui/providers'
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const { formatMessage } = useVIntl()
const messages = defineMessages({
cpuTitle: {
id: 'servers.manage.stats.cpu.title',
defaultMessage: 'CPU',
},
memoryTitle: {
id: 'servers.manage.stats.memory.title',
defaultMessage: 'Memory',
},
storageTitle: {
id: 'servers.manage.stats.storage.title',
defaultMessage: 'Storage',
},
bytesWithUnit: {
id: 'servers.manage.stats.bytes-with-unit',
defaultMessage: '{value} {unit}',
},
unitByte: {
id: 'servers.manage.stats.unit.byte',
defaultMessage: 'B',
},
unitKilobyte: {
id: 'servers.manage.stats.unit.kilobyte',
defaultMessage: 'KB',
},
unitMegabyte: {
id: 'servers.manage.stats.unit.megabyte',
defaultMessage: 'MB',
},
unitGigabyte: {
id: 'servers.manage.stats.unit.gigabyte',
defaultMessage: 'GB',
},
ramTotalSecondary: {
id: 'servers.manage.stats.ram-total-secondary',
defaultMessage: '/ {total}',
},
})
const { serverId } = injectModrinthServerContext()
const { featureFlags } = injectPageContext()
@@ -168,24 +210,41 @@ const buildChartOptions = (warning: boolean, index: number, dataMax: number) =>
const cpuChartOptions = computed(() => buildChartOptions(cpuWarning.value, 0, cpuDataMax))
const ramChartOptions = computed(() => buildChartOptions(ramWarning.value, 1, ramDataMax))
const cpuSeries = computed(() => [{ name: 'CPU', data: cpuData.value }])
const ramSeries = computed(() => [{ name: 'Memory', data: ramData.value }])
const byteUnitMessages = [
messages.unitByte,
messages.unitKilobyte,
messages.unitMegabyte,
messages.unitGigabyte,
] as const
const cpuSeries = computed(() => [{ name: formatMessage(messages.cpuTitle), data: cpuData.value }])
const ramSeries = computed(() => [
{ name: formatMessage(messages.memoryTitle), data: ramData.value },
])
const formatBytes = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
while (value >= 1024 && unit < byteUnitMessages.length - 1) {
value /= 1024
unit++
}
return `${Math.round(value * 10) / 10} ${units[unit]}`
const rounded = String(Math.round(value * 10) / 10)
return formatMessage(messages.bytesWithUnit, {
value: rounded,
unit: formatMessage(byteUnitMessages[unit]),
})
}
const metrics = computed(() => {
const storageMetric = {
title: 'Storage',
value: props.loading ? '0 B' : formatBytes(stats.value.storage_usage_bytes ?? 0),
title: formatMessage(messages.storageTitle),
value: props.loading
? formatMessage(messages.bytesWithUnit, {
value: '0',
unit: formatMessage(messages.unitByte),
})
: formatBytes(stats.value.storage_usage_bytes ?? 0),
secondary: null as string | null,
icon: FolderOpenIcon,
showGraph: false,
@@ -197,7 +256,7 @@ const metrics = computed(() => {
if (props.loading) {
return [
{
title: 'CPU',
title: formatMessage(messages.cpuTitle),
value: '0.00%',
secondary: null as string | null,
icon: CpuIcon,
@@ -207,7 +266,7 @@ const metrics = computed(() => {
link: null,
},
{
title: 'Memory',
title: formatMessage(messages.memoryTitle),
value: '0.00%',
secondary: null as string | null,
icon: DatabaseIcon,
@@ -222,7 +281,7 @@ const metrics = computed(() => {
return [
{
title: 'CPU',
title: formatMessage(messages.cpuTitle),
value: `${cpuPercent.value.toFixed(2)}%`,
secondary: null as string | null,
icon: CpuIcon,
@@ -232,12 +291,14 @@ const metrics = computed(() => {
link: null,
},
{
title: 'Memory',
title: formatMessage(messages.memoryTitle),
value: showRamAsBytes.value
? formatBytes(stats.value.ram_usage_bytes ?? 0)
: `${ramPercent.value.toFixed(2)}%`,
secondary: showRamAsBytes.value
? `/ ${formatBytes(stats.value.ram_total_bytes ?? 0)}`
? ` ${formatMessage(messages.ramTotalSecondary, {
total: formatBytes(stats.value.ram_total_bytes ?? 0),
})}`
: (null as string | null),
icon: DatabaseIcon,
showGraph: true,
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useVIntl } from '#ui/composables/i18n'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
@@ -27,6 +27,45 @@ const { serverId, fsOps, busyReasons, uploadState, cancelUpload: cancelUploadRef
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
fileDeletedTitle: {
id: 'files.manage.delete-success-title',
defaultMessage: 'File deleted',
},
fileDeletedText: {
id: 'files.manage.delete-success-text',
defaultMessage: 'Your file has been deleted.',
},
renamedTitle: {
id: 'files.manage.rename-success-title',
defaultMessage: 'Renamed',
},
renamedTo: {
id: 'files.manage.rename-success-text',
defaultMessage: 'Renamed to {name}',
},
movedTitle: {
id: 'files.manage.move-success-title',
defaultMessage: 'Moved',
},
movedTo: {
id: 'files.manage.move-success-text',
defaultMessage: 'Moved to {path}',
},
itemCreatedTitle: {
id: 'files.manage.create-success-title',
defaultMessage: '{type, select, directory {Folder} other {File}} created',
},
itemCreatedText: {
id: 'files.manage.create-success-text',
defaultMessage: 'Created {name}',
},
downloadFailedText: {
id: 'files.manage.download-failed-text',
defaultMessage: 'Could not download the file.',
},
})
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
@@ -175,8 +214,8 @@ const deleteMutation = useMutation({
},
onSuccess: () => {
addNotification({
title: 'File deleted',
text: 'Your file has been deleted.',
title: formatMessage(messages.fileDeletedTitle),
text: formatMessage(messages.fileDeletedText),
type: 'success',
})
},
@@ -218,7 +257,11 @@ const renameMutation = useMutation({
})
},
onSuccess: (_, { newName }) => {
addNotification({ title: 'Renamed', text: `Renamed to ${newName}`, type: 'success' })
addNotification({
title: formatMessage(messages.renamedTitle),
text: formatMessage(messages.renamedTo, { name: newName }),
type: 'success',
})
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
@@ -247,7 +290,11 @@ const moveMutation = useMutation({
})
},
onSuccess: (_, { destination }) => {
addNotification({ title: 'Moved', text: `Moved to ${destination}`, type: 'success' })
addNotification({
title: formatMessage(messages.movedTitle),
text: formatMessage(messages.movedTo, { path: destination }),
type: 'success',
})
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['files', serverId] })
@@ -286,10 +333,10 @@ const createMutation = useMutation({
})
},
onSuccess: (_, { path, type }) => {
const name = path.split('/').pop()
const name = path.split('/').pop() ?? ''
addNotification({
title: `${type === 'directory' ? 'Folder' : 'File'} created`,
text: `Created ${name}`,
title: formatMessage(messages.itemCreatedTitle, { type }),
text: formatMessage(messages.itemCreatedText, { name }),
type: 'success',
})
},
@@ -339,7 +386,7 @@ async function downloadFile(path: string, fileName: string): Promise<void> {
} catch {
addNotification({
title: formatMessage(commonMessages.downloadFailedLabel),
text: 'Could not download the file.',
text: formatMessage(messages.downloadFailedText),
type: 'error',
})
}
@@ -404,7 +451,7 @@ async function uploadFiles(files: File[]) {
if (err instanceof Error && err.message === 'Upload cancelled') break
addNotification({
title: formatMessage(commonMessages.uploadFailedLabel),
text: `Failed to upload ${file.name}`,
text: formatMessage(commonMessages.uploadFailedFileDetail, { fileName: file.name }),
type: 'error',
})
}
@@ -61,7 +61,10 @@
<li v-if="fetchError" class="text-red">
<p>{{ formatMessage(messages.errorDetails) }}</p>
<CopyCode
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
:text="
(fetchError as ModrinthServersFetchError).message ||
formatMessage(messages.unknownErrorDetails)
"
:copyable="false"
:selectable="false"
:language="'json'"
@@ -319,6 +322,30 @@ const messages = defineMessages({
id: 'servers.manage.resubscribe-error.text',
defaultMessage: 'An error occurred while resubscribing to your Modrinth server.',
},
unknownErrorDetails: {
id: 'servers.manage.error.unknown-details',
defaultMessage: 'Unknown error',
},
planSmall: {
id: 'servers.manage.plan.small',
defaultMessage: 'Small plan',
},
planMedium: {
id: 'servers.manage.plan.medium',
defaultMessage: 'Medium plan',
},
planLarge: {
id: 'servers.manage.plan.large',
defaultMessage: 'Large plan',
},
planCustom: {
id: 'servers.manage.plan.custom',
defaultMessage: 'Custom plan',
},
resubscribeFallbackServerName: {
id: 'servers.manage.resubscribe.fallback-server-name',
defaultMessage: 'this server',
},
})
const isPollingForNewServers = ref(false)
@@ -778,18 +805,19 @@ function getProductFromPriceId(priceId: string | null | undefined) {
}
function getPlanName(product: Labrinth.Billing.Internal.Product | null): string {
if (!product) return 'Medium plan'
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return 'Medium plan'
if (!product) return formatMessage(messages.planMedium)
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal')
return formatMessage(messages.planMedium)
switch (product.metadata.ram) {
case 4096:
return 'Small plan'
return formatMessage(messages.planSmall)
case 6144:
return 'Medium plan'
return formatMessage(messages.planMedium)
case 8192:
return 'Large plan'
return formatMessage(messages.planLarge)
default:
return 'Custom plan'
return formatMessage(messages.planCustom)
}
}
@@ -850,7 +878,8 @@ function openResubscribeModal(
subscriptionId: subscription.id,
wasSuspended: !!charge?.due && dayjs(charge.due).isBefore(dayjs()),
serverName:
serverList.value.find((server) => server.server_id === serverId)?.name ?? 'this server',
serverList.value.find((server) => server.server_id === serverId)?.name ??
formatMessage(messages.resubscribeFallbackServerName),
planName: getPlanName(product),
ramGb: getRamGb(product),
storageGb: getStorageGb(product),
@@ -7,7 +7,9 @@
/>
<div class="flex min-h-[700px] flex-col gap-2">
<span class="text-2xl font-semibold text-contrast">Console</span>
<span class="text-2xl font-semibold text-contrast">{{
formatMessage(messages.consoleTitle)
}}</span>
<ConsolePageLayout />
</div>
@@ -17,10 +19,9 @@
v-if="isWsAuthIncorrect"
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
>
<h2>Could not connect to the server.</h2>
<h2>{{ formatMessage(messages.wsAuthErrorTitle) }}</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the
page. (WebSocket Authentication Failed)
{{ formatMessage(messages.wsAuthErrorDescription) }}
</p>
</div>
@@ -29,7 +30,7 @@
class="self-start rounded-lg bg-surface-3 px-3 py-1 text-sm text-contrast hover:brightness-125"
@click="downloadLog4jDebug"
>
Download WS debug JSON
{{ formatMessage(messages.downloadWsDebugJson) }}
</button>
</div>
</template>
@@ -40,11 +41,34 @@ import { useStorage } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import { useModrinthServersConsole } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
import ServerManageStats from './components/ServerManageStats.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
consoleTitle: {
id: 'servers.manage.overview.console.title',
defaultMessage: 'Console',
},
wsAuthErrorTitle: {
id: 'servers.manage.overview.ws-auth-error.title',
defaultMessage: 'Could not connect to the server.',
},
wsAuthErrorDescription: {
id: 'servers.manage.overview.ws-auth-error.description',
defaultMessage:
'An error occurred while attempting to connect to your server. Please try refreshing the page. (WebSocket Authentication Failed)',
},
downloadWsDebugJson: {
id: 'servers.manage.overview.download-ws-debug-json',
defaultMessage: 'Download WS debug JSON',
},
})
const props = withDefaults(
defineProps<{
showAdvancedDebugInfo?: boolean
@@ -22,8 +22,8 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="We're getting your server ready"
description="Your server's hardware is being prepared and will be available shortly!"
:title="formatMessage(serverManageMessages.preparingServerTitle)"
:description="formatMessage(serverManageMessages.preparingServerDescription)"
:icon="TransferIcon"
icon-color="blue"
:action="generalErrorAction"
@@ -34,8 +34,8 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Server upgrading"
description="Your server's hardware is currently being upgraded and will be back online shortly!"
:title="formatMessage(serverManageMessages.serverUpgradingTitle)"
:description="formatMessage(serverManageMessages.serverUpgradingDescription)"
:icon="TransferIcon"
icon-color="blue"
:action="generalErrorAction"
@@ -46,7 +46,7 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Server suspended"
:title="formatMessage(serverManageMessages.serverSuspendedTitle)"
:description="suspendedDescription"
:icon="LockIcon"
icon-color="orange"
@@ -58,8 +58,8 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="An error occured."
description="Please contact Modrinth Support."
:title="formatMessage(serverManageMessages.forbiddenErrorTitle)"
:description="formatMessage(serverManageMessages.forbiddenErrorDescription)"
:icon="TransferIcon"
icon-color="orange"
:error-details="generalErrorDetails"
@@ -71,7 +71,7 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Server Node Unavailable"
:title="formatMessage(serverManageMessages.nodeUnavailableTitle)"
:icon="TriangleAlertIcon"
icon-color="red"
:action="nodeUnavailableAction"
@@ -80,16 +80,13 @@
<template #description>
<div class="text-md space-y-4">
<p class="leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is not accessible
at the moment. We are working to resolve the issue as quickly as possible.
{{ formatMessage(serverManageMessages.nodeUnavailableP1) }}
</p>
<p class="leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as
the issue is resolved.
{{ formatMessage(serverManageMessages.nodeUnavailableP2) }}
</p>
<p class="leading-[170%] text-secondary">
If reloading does not work initially, please contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
{{ formatMessage(serverManageMessages.nodeUnavailableP3) }}
</p>
</div>
</template>
@@ -149,7 +146,11 @@
>
<ButtonStyled circular size="large">
<button
v-tooltip="showSettingsHint ? undefined : 'Server settings'"
v-tooltip="
showSettingsHint
? undefined
: formatMessage(serverManageMessages.serverSettingsTooltip)
"
@click="
() => {
openServerSettingsModal()
@@ -216,7 +217,7 @@
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
<div class="flex gap-2 text-2xl font-bold">{{ formattedInstallErrorTitle }}</div>
</div>
<div
@@ -228,44 +229,36 @@
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
{{ formatMessage(serverManageMessages.installErrorInvalidIntro) }}
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Hosting supports it.
{{ formatMessage(serverManageMessages.installErrorBulletRecentMc) }}
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
{{ formatMessage(serverManageMessages.installErrorBulletModpack) }}
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
{{ formatMessage(serverManageMessages.installErrorBulletReinstall) }}
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
{{ formatMessage(serverManageMessages.installErrorBulletSupport) }}
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
{{ formatMessage(serverManageMessages.copyDebugInfoButton) }}
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
{{ formatMessage(serverManageMessages.installErrorInternalBody) }}
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
An error occurred while installing your server because Modrinth Hosting does not
support the version of Minecraft or the loader you specified. Try reinstalling
your server with a different version or loader, and if the problem persists,
please contact Modrinth Support with your server's debug information.
{{ formatMessage(serverManageMessages.installErrorUnsupportedBody) }}
</div>
<div
@@ -273,13 +266,17 @@
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
<button @click="openInstallLog">
<FileIcon />{{
formatMessage(serverManageMessages.openInstallationLogButton)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
{{ formatMessage(serverManageMessages.copyDebugInfoButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
@@ -288,7 +285,7 @@
@click="openServerSettingsModal('installation')"
>
<RightArrowIcon />
Change Loader
{{ formatMessage(serverManageMessages.changeLoaderButton) }}
</button>
</ButtonStyled>
</div>
@@ -312,7 +309,7 @@
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
{{ formatMessage(serverManageMessages.wsDisconnectedMessage) }}
</div>
<div
@@ -321,7 +318,7 @@
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<LoaderCircleIcon class="h-5 w-5 animate-spin" />
Hang on, we're reconnecting to your server.
{{ formatMessage(serverManageMessages.wsReconnectingMessage) }}
</div>
<Transition
@@ -361,7 +358,12 @@
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
</template>
<template #header>
Uploading files ({{ uploadState.completedFiles }}/{{ uploadState.totalFiles }})
{{
formatMessage(serverManageMessages.uploadingFilesHeader, {
completed: uploadState.completedFiles,
total: uploadState.totalFiles,
})
}}
<span v-if="uploadState.currentFileName" class="font-normal text-secondary">
{{ uploadState.currentFileName }}
</span>
@@ -374,7 +376,9 @@
</span>
<template v-if="cancelUpload" #top-right-actions>
<ButtonStyled type="outlined" color="blue">
<button class="!border" @click="cancelUpload?.()">Cancel</button>
<button class="!border" @click="cancelUpload?.()">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</template>
<template #progress>
@@ -393,7 +397,9 @@
v-if="showAdvancedDebugInfo"
class="experimental-styles-within relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<h2 class="m-0 text-lg font-extrabold text-contrast">
{{ formatMessage(serverManageMessages.serverDataHeading) }}
</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
safeStringify(serverData)
}}</pre>
@@ -476,6 +482,7 @@ import {
injectNotificationManager,
provideServerSettingsModal,
} from '#ui/providers'
import { commonMessages, serverPanelNavMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import FileOperationAdmonitions from '../../../shared/files-tab/components/FileOperationAdmonitions.vue'
@@ -569,6 +576,194 @@ const settingsHintMessages = defineMessages({
},
})
const serverManageMessages = defineMessages({
preparingServerTitle: {
id: 'servers.manage.error.preparing.title',
defaultMessage: "We're getting your server ready",
},
preparingServerDescription: {
id: 'servers.manage.error.preparing.description',
defaultMessage: "Your server's hardware is being prepared and will be available shortly!",
},
serverUpgradingTitle: {
id: 'servers.manage.error.upgrading.title',
defaultMessage: 'Server upgrading',
},
serverUpgradingDescription: {
id: 'servers.manage.error.upgrading.description',
defaultMessage:
"Your server's hardware is currently being upgraded and will be back online shortly!",
},
serverSuspendedTitle: {
id: 'servers.manage.error.suspended.title',
defaultMessage: 'Server suspended',
},
suspendedCancelledDescription: {
id: 'servers.manage.error.suspended.cancelled',
defaultMessage:
'Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error.',
},
suspendedWithReasonDescription: {
id: 'servers.manage.error.suspended.with-reason',
defaultMessage:
'Your server has been suspended: {reason}\nContact Modrinth Support if you believe this is an error.',
},
suspendedDefaultDescription: {
id: 'servers.manage.error.suspended.default',
defaultMessage:
'Your server has been suspended.\nContact Modrinth Support if you believe this is an error.',
},
forbiddenErrorTitle: {
id: 'servers.manage.error.forbidden.title',
defaultMessage: 'An error occurred.',
},
forbiddenErrorDescription: {
id: 'servers.manage.error.forbidden.description',
defaultMessage: 'Please contact Modrinth Support.',
},
nodeUnavailableTitle: {
id: 'servers.manage.error.node-unavailable.title',
defaultMessage: 'Server Node Unavailable',
},
nodeUnavailableP1: {
id: 'servers.manage.error.node-unavailable.p1',
defaultMessage:
"Your server's node, where your Modrinth Server is physically hosted, is not accessible at the moment. We are working to resolve the issue as quickly as possible.",
},
nodeUnavailableP2: {
id: 'servers.manage.error.node-unavailable.p2',
defaultMessage:
'Your data is safe and will not be lost, and your server will be back online as soon as the issue is resolved.',
},
nodeUnavailableP3: {
id: 'servers.manage.error.node-unavailable.p3',
defaultMessage:
"If reloading does not work initially, please contact Modrinth Support via the chat bubble in the bottom right corner and we'll be happy to help.",
},
serverSettingsTooltip: {
id: 'servers.manage.tooltip.server-settings',
defaultMessage: 'Server settings',
},
installErrorInvalidIntro: {
id: 'servers.manage.install-error.invalid-version.intro',
defaultMessage:
'An invalid loader or Minecraft version was specified and could not be installed.',
},
installErrorBulletRecentMc: {
id: 'servers.manage.install-error.invalid-version.bullet-recent-mc',
defaultMessage:
'If this version of Minecraft was released recently, please check if Modrinth Hosting supports it.',
},
installErrorBulletModpack: {
id: 'servers.manage.install-error.invalid-version.bullet-modpack',
defaultMessage:
"If you've installed a modpack, it may have been packaged incorrectly or may not be compatible with the loader.",
},
installErrorBulletReinstall: {
id: 'servers.manage.install-error.invalid-version.bullet-reinstall',
defaultMessage:
'Your server may need to be reinstalled with a valid mod loader and version. You can change the loader by clicking the "Change Loader" button.',
},
installErrorBulletSupport: {
id: 'servers.manage.install-error.invalid-version.bullet-support',
defaultMessage: "If you're stuck, please contact Modrinth Support with the information below:",
},
installErrorInternalBody: {
id: 'servers.manage.install-error.internal.body',
defaultMessage:
"An internal error occurred while installing your server. Don't fret — try reinstalling your server, and if the problem persists, please contact Modrinth support with your server's debug information.",
},
installErrorUnsupportedBody: {
id: 'servers.manage.install-error.unsupported-version.body',
defaultMessage:
"An error occurred while installing your server because Modrinth Hosting does not support the version of Minecraft or the loader you specified. Try reinstalling your server with a different version or loader, and if the problem persists, please contact Modrinth Support with your server's debug information.",
},
copyDebugInfoButton: {
id: 'servers.manage.install-error.copy-debug-info',
defaultMessage: 'Copy Debug Info',
},
openInstallationLogButton: {
id: 'servers.manage.install-error.open-installation-log',
defaultMessage: 'Open Installation Log',
},
changeLoaderButton: {
id: 'servers.manage.install-error.change-loader',
defaultMessage: 'Change Loader',
},
wsDisconnectedMessage: {
id: 'servers.manage.ws.disconnected',
defaultMessage: 'Something went wrong...',
},
wsReconnectingMessage: {
id: 'servers.manage.ws.reconnecting',
defaultMessage: "Hang on, we're reconnecting to your server.",
},
uploadingFilesHeader: {
id: 'servers.manage.upload.header',
defaultMessage: 'Uploading files ({completed}/{total})',
},
serverDataHeading: {
id: 'servers.manage.debug.server-data-heading',
defaultMessage: 'Server data',
},
detailLabelServerId: {
id: 'servers.manage.details.server-id',
defaultMessage: 'Server ID',
},
detailLabelNode: {
id: 'servers.manage.details.node',
defaultMessage: 'Node',
},
detailLabelErrorMessage: {
id: 'servers.manage.details.error-message',
defaultMessage: 'Error message',
},
detailLabelTimestamp: {
id: 'servers.manage.details.timestamp',
defaultMessage: 'Timestamp',
},
detailLabelErrorName: {
id: 'servers.manage.details.error-name',
defaultMessage: 'Error Name',
},
detailLabelOriginalError: {
id: 'servers.manage.details.original-error',
defaultMessage: 'Original Error',
},
detailLabelStackTrace: {
id: 'servers.manage.details.stack-trace',
defaultMessage: 'Stack Trace',
},
nodePingFailedMessage: {
id: 'servers.manage.details.node-ping-failed',
defaultMessage: 'Unable to reach node. Ping test failed.',
},
goToBillingSettings: {
id: 'servers.manage.action.go-to-billing',
defaultMessage: 'Go to billing settings',
},
goBackToAllServers: {
id: 'servers.manage.action.go-back-to-servers',
defaultMessage: 'Go back to all servers',
},
reloadPageButton: {
id: 'servers.manage.action.reload-page',
defaultMessage: 'Reload',
},
notificationDismissNoticeError: {
id: 'servers.manage.notification.dismiss-notice-error',
defaultMessage: 'Error dismissing notice',
},
retryInstallationFailed: {
id: 'servers.manage.notification.retry-installation-failed',
defaultMessage: 'Failed to retry installation',
},
installationErrorTitle: {
id: 'servers.manage.install-error.title',
defaultMessage: 'Installation error',
},
})
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
@@ -586,6 +781,14 @@ const errorTitle = ref('Error')
const errorMessage = ref('An unexpected error occurred.')
const errorLog = ref('')
const errorLogFile = ref('')
const formattedInstallErrorTitle = computed(() => {
const t = errorTitle.value
if (t === 'Installation error') return formatMessage(serverManageMessages.installationErrorTitle)
if (t === 'Error') return formatMessage(commonMessages.errorLabel)
return t
})
const isOnboarding = computed(() => serverData.value?.flows?.intro)
const SETTINGS_HINT_KEY = 'server-panel-settings-hint-dismissed'
@@ -844,25 +1047,25 @@ watch(serverData, (data) => {
const navLinks = computed<Tab[]>(() => [
{
label: 'Overview',
label: formatMessage(serverPanelNavMessages.overview),
href: `/hosting/manage/${props.serverId}`,
icon: LayoutTemplateIcon,
subpages: [],
},
{
label: 'Content',
label: formatMessage(serverPanelNavMessages.content),
href: `/hosting/manage/${props.serverId}/content`,
icon: BoxesIcon,
subpages: ['mods', 'datapacks'],
},
{
label: 'Files',
label: formatMessage(serverPanelNavMessages.files),
href: `/hosting/manage/${props.serverId}/files`,
icon: FolderOpenIcon,
subpages: [],
},
{
label: 'Backups',
label: formatMessage(serverPanelNavMessages.backups),
href: `/hosting/manage/${props.serverId}/backups`,
icon: DatabaseBackupIcon,
subpages: [],
@@ -878,7 +1081,7 @@ const surveyNotice = computed(() => serverData.value?.notices?.find((n) => n.lev
async function dismissNotice(noticeId: number) {
await client.archon.servers_v0.dismissNotice(props.serverId, noticeId).catch((err) => {
addNotification({
title: 'Error dismissing notice',
title: formatMessage(serverManageMessages.notificationDismissNoticeError),
text: err,
type: 'error',
})
@@ -982,7 +1185,10 @@ async function handleContentRetry() {
} catch (err) {
addNotification({
type: 'error',
text: err instanceof Error ? err.message : 'Failed to retry installation',
text:
err instanceof Error
? err.message
: formatMessage(serverManageMessages.retryInstallationFailed),
})
}
}
@@ -1224,62 +1430,64 @@ const nodeAccessible = ref(true)
const nodeUnavailableDetails = computed(() => [
{
label: 'Server ID',
label: formatMessage(serverManageMessages.detailLabelServerId),
value: props.serverId,
type: 'inline' as const,
},
{
label: 'Node',
label: formatMessage(serverManageMessages.detailLabelNode),
value:
(serverError.value?.responseData as { hostname?: string } | undefined)?.hostname ??
serverData.value?.datacenter ??
'Unknown',
formatMessage(commonMessages.unknownLabel),
type: 'inline' as const,
},
{
label: 'Error message',
label: formatMessage(serverManageMessages.detailLabelErrorMessage),
value: nodeAccessible.value
? (serverError.value?.message ?? 'Unknown')
: 'Unable to reach node. Ping test failed.',
? (serverError.value?.message ?? formatMessage(commonMessages.unknownLabel))
: formatMessage(serverManageMessages.nodePingFailedMessage),
type: 'block' as const,
},
])
const suspendedDescription = computed(() => {
if (serverData.value?.suspension_reason === 'cancelled') {
return 'Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error.'
return formatMessage(serverManageMessages.suspendedCancelledDescription)
}
if (serverData.value?.suspension_reason) {
return `Your server has been suspended: ${serverData.value.suspension_reason}\nContact Modrinth Support if you believe this is an error.`
return formatMessage(serverManageMessages.suspendedWithReasonDescription, {
reason: serverData.value.suspension_reason,
})
}
return 'Your server has been suspended.\nContact Modrinth Support if you believe this is an error.'
return formatMessage(serverManageMessages.suspendedDefaultDescription)
})
const generalErrorDetails = computed(() => [
{
label: 'Server ID',
label: formatMessage(serverManageMessages.detailLabelServerId),
value: props.serverId,
type: 'inline' as const,
},
{
label: 'Timestamp',
label: formatMessage(serverManageMessages.detailLabelTimestamp),
value: String(new Date().toISOString()),
type: 'inline' as const,
},
{
label: 'Error Name',
label: formatMessage(serverManageMessages.detailLabelErrorName),
value: serverError.value?.name,
type: 'inline' as const,
},
{
label: 'Error Message',
label: formatMessage(serverManageMessages.detailLabelErrorMessage),
value: serverError.value?.message,
type: 'block' as const,
},
...(serverError.value?.originalError
? [
{
label: 'Original Error',
label: formatMessage(serverManageMessages.detailLabelOriginalError),
value: String(serverError.value.originalError),
type: 'hidden' as const,
},
@@ -1288,7 +1496,7 @@ const generalErrorDetails = computed(() => [
...(serverError.value?.stack
? [
{
label: 'Stack Trace',
label: formatMessage(serverManageMessages.detailLabelStackTrace),
value: serverError.value.stack,
type: 'hidden' as const,
},
@@ -1297,26 +1505,33 @@ const generalErrorDetails = computed(() => [
])
const suspendedAction = computed(() => ({
label: 'Go to billing settings',
label: formatMessage(serverManageMessages.goToBillingSettings),
onClick: () => props.navigateToBilling?.(),
color: 'brand' as const,
}))
const generalErrorAction = computed(() => ({
label: 'Go back to all servers',
label: formatMessage(serverManageMessages.goBackToAllServers),
onClick: () => props.navigateToServers?.(),
color: 'brand' as const,
}))
const nodeUnavailableAction = computed(() => ({
label: 'Reload',
label: formatMessage(serverManageMessages.reloadPageButton),
onClick: () => props.reloadPage(),
color: 'brand' as const,
disabled: false,
}))
const copyServerDebugInfo = () => {
const debugInfo = `Server ID: ${serverData.value?.server_id}\nError: ${errorMessage.value}\nKind: ${serverData.value?.upstream?.kind}\nProject ID: ${serverData.value?.upstream?.project_id}\nVersion ID: ${serverData.value?.upstream?.version_id}\nLog: ${errorLog.value}`
const debugInfo = [
`Server ID: ${String(serverData.value?.server_id ?? '')}`,
`Error: ${errorMessage.value}`,
`Kind: ${String(serverData.value?.upstream?.kind ?? '')}`,
`Project ID: ${String(serverData.value?.upstream?.project_id ?? '')}`,
`Version ID: ${String(serverData.value?.upstream?.version_id ?? '')}`,
`Log: ${errorLog.value}`,
].join('\n')
navigator.clipboard.writeText(debugInfo)
copied.value = true
setTimeout(() => {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
import type { Archon } from '@modrinth/api-client'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import BackupItem from '../../components/servers/backups/BackupItem.vue'
const meta = {
title: 'Servers/BackupItem',
component: BackupItem,
args: {
preview: false,
showCopyIdAction: false,
showDebugInfo: false,
restoreDisabled: undefined,
},
} satisfies Meta<typeof BackupItem>
export default meta
type Story = StoryObj<typeof meta>
function makeBackup(overrides: Partial<Archon.Backups.v1.Backup> = {}): Archon.Backups.v1.Backup {
return {
id: 'backup-001',
physical_id: 'phys-001',
name: 'Backup #5',
created_at: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
automated: false,
status: 'done',
interrupted: false,
ongoing: false,
locked: false,
...overrides,
}
}
export const Default: Story = {
name: 'Default (manual)',
args: {
backup: makeBackup({ name: 'Base finished!!' }),
},
}
export const Automated: Story = {
name: 'Automated',
args: {
backup: makeBackup({ automated: true, name: 'Backup #2' }),
},
}
export const Creating: Story = {
name: 'Creating (in progress)',
args: {
backup: makeBackup({
automated: true,
name: 'Backup #3',
status: 'in_progress',
ongoing: true,
task: {
create: { progress: 0.4, state: 'ongoing' },
},
}),
},
}
export const Restoring: Story = {
name: 'Restoring (in progress)',
args: {
backup: makeBackup({
name: 'Backup #5',
automated: true,
task: {
restore: { progress: 0.6, state: 'ongoing' },
},
}),
},
}
export const FailedCreate: Story = {
name: 'Failed (create)',
args: {
backup: makeBackup({ status: 'error', name: 'Backup #4' }),
},
}
export const FailedRestore: Story = {
name: 'Failed (restore)',
args: {
backup: makeBackup({
name: 'Backup #5',
task: {
restore: { progress: 0, state: 'failed' },
},
}),
},
}
export const Preview: Story = {
name: 'Preview (compact, used in delete modal)',
args: {
backup: makeBackup({ name: 'Base finished!!' }),
preview: true,
},
}
export const RestoreDisabled: Story = {
name: 'Restore disabled (server running)',
args: {
backup: makeBackup({ name: 'Backup #5', automated: true }),
restoreDisabled: 'Cannot restore backup while server is running',
},
}
export const AllStates: Story = {
render: () => ({
components: { BackupItem },
setup() {
const now = new Date(Date.now() - 1000 * 60 * 10).toISOString()
function makeBackup(overrides: Partial<Archon.Backups.v1.Backup>): Archon.Backups.v1.Backup {
return {
id: 'backup-001',
physical_id: 'phys-001',
name: 'Backup #5',
created_at: now,
automated: false,
status: 'done',
interrupted: false,
ongoing: false,
locked: false,
...overrides,
}
}
return {
manual: makeBackup({ name: 'Base finished!!' }),
automated: makeBackup({ automated: true, name: 'Backup #2' }),
creating: makeBackup({
automated: true,
name: 'Backup #3',
status: 'in_progress',
ongoing: true,
task: { create: { progress: 0.4, state: 'ongoing' } },
}),
restoring: makeBackup({
automated: true,
task: { restore: { progress: 0.6, state: 'ongoing' } },
}),
failedCreate: makeBackup({ status: 'error', name: 'Backup #4' }),
failedRestore: makeBackup({
task: { restore: { progress: 0, state: 'failed' } },
}),
}
},
template: /* html */ `
<div style="display: flex; flex-direction: column; gap: 0.75rem; max-width: 900px;">
<BackupItem :backup="manual" />
<BackupItem :backup="automated" />
<BackupItem :backup="creating" />
<BackupItem :backup="restoring" />
<BackupItem :backup="failedCreate" />
<BackupItem :backup="failedRestore" />
<BackupItem :backup="manual" preview />
</div>
`,
}),
}
+72
View File
@@ -567,6 +567,16 @@ export const commonMessages = defineMessages({
id: 'label.download-failed',
defaultMessage: 'Download failed',
},
/** Value is pre-formatted numeric string; unit is a stable key for ICU (B, KB, KiB, Bytes, …). */
fileSizeFormatted: {
id: 'label.file-size.formatted',
defaultMessage:
'{value} {unit, select, B {B} KB {KB} MB {MB} GB {GB} TB {TB} PB {PB} EB {EB} KiB {KiB} MiB {MiB} GiB {GiB} Bytes {Bytes} other {B}}',
},
uploadFailedFileDetail: {
id: 'notification.upload-failed.file-detail',
defaultMessage: 'Failed to upload {fileName}',
},
projectCreated: {
id: 'project.about.details.created',
defaultMessage: 'Created {date}',
@@ -608,6 +618,18 @@ export const commonMessages = defineMessages({
defaultMessage:
'{count} {countPlural, plural, one {recent play} other {recent plays}} from Modrinth in the past 2 weeks',
},
serverSettingsUpdatedTitle: {
id: 'server.settings.success.updated.title',
defaultMessage: 'Server settings updated',
},
serverSettingsUpdatedText: {
id: 'server.settings.success.updated.text',
defaultMessage: 'Your server settings were successfully changed.',
},
consoleFilterAllLevels: {
id: 'servers.console.filter.all-levels',
defaultMessage: 'All',
},
})
export const formFieldLabels = defineMessages({
@@ -1086,3 +1108,53 @@ export const paymentMethodMessages = defineMessages({
defaultMessage: 'Charities',
},
})
export const serverPanelNavMessages = defineMessages({
overview: {
id: 'server.panel.nav.overview',
defaultMessage: 'Overview',
},
content: {
id: 'server.panel.nav.content',
defaultMessage: 'Content',
},
files: {
id: 'server.panel.nav.files',
defaultMessage: 'Files',
},
backups: {
id: 'server.panel.nav.backups',
defaultMessage: 'Backups',
},
})
export const serverSettingsTabMessages = defineMessages({
general: {
id: 'server.settings.tabs.general',
defaultMessage: 'General',
},
installation: {
id: 'server.settings.tabs.installation',
defaultMessage: 'Installation',
},
network: {
id: 'server.settings.tabs.network',
defaultMessage: 'Network',
},
properties: {
id: 'server.settings.tabs.properties',
defaultMessage: 'Properties',
},
advanced: {
id: 'server.settings.tabs.advanced',
defaultMessage: 'Advanced',
},
billing: {
id: 'server.settings.tabs.billing',
defaultMessage: 'Billing',
},
'admin-billing': {
id: 'server.settings.tabs.admin-billing',
defaultMessage: 'Admin Billing',
},
})