chore: i18n pass on server panel before worlds proj

This commit is contained in:
Calum H. (IMB11)
2026-04-18 17:00:11 +01:00
parent db3bc4eda1
commit b0ffcb0df7
32 changed files with 3083 additions and 486 deletions
@@ -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,