feat: reshuffle layout for worlds

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:22 +01:00
parent 5d49e9fa53
commit f922145230
33 changed files with 591 additions and 131 deletions
@@ -77,7 +77,7 @@ onMounted(() => {
isClient.value = true
})
const { serverId } = injectModrinthServerContext()
const { serverId, worldId } = injectModrinthServerContext()
const { featureFlags } = injectPageContext()
const props = withDefaults(
@@ -190,7 +190,9 @@ const metrics = computed(() => {
showGraph: false,
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
series: null as { name: string; data: number[] }[] | null,
link: `/hosting/manage/${encodeURIComponent(serverId)}/files`,
link: worldId.value
? `/hosting/manage/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId.value)}/files`
: `/hosting/manage/${encodeURIComponent(serverId)}/worlds`,
}
if (props.loading) {
@@ -195,6 +195,13 @@ async function show({ serverId, tabIndex, tabId }: ShowOptions) {
queryKey: ['servers', 'properties', 'v1', targetServerId, worldId.value],
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
})
queryClient.prefetchQuery({
queryKey: ['content', 'list', 'v1', targetServerId, worldId.value],
queryFn: () =>
client.archon.content_v1.getAddons(targetServerId, worldId.value!, {
from_modpack: false,
}),
})
queryClient.prefetchQuery({
queryKey: ['servers', 'startup', 'v1', targetServerId, worldId.value],
queryFn: () => client.archon.options_v1.getStartup(targetServerId, worldId.value!),
@@ -55,8 +55,12 @@ const messages = defineMessages({
},
})
const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
const isOnContentTab = computed(
() =>
route.path.includes('/content') ||
(!!route.params.world_id && !isOnFilesTab.value && !route.path.includes('/backups')),
)
const bannerCoversInstalling = computed(
() =>
@@ -103,12 +103,12 @@ const props = withDefaults(
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
const createMutation = useMutation({
mutationFn: (name: string) =>
client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
})
const modal = ref<InstanceType<typeof NewModal>>()
@@ -85,12 +85,12 @@ const props = withDefaults(
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
})
const modal = ref<InstanceType<typeof NewModal>>()
@@ -74,7 +74,7 @@ const props = withDefaults(
},
)
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
function safetyBackupName(backupName: string) {
const base = `Before restoring "${backupName}"`
@@ -84,7 +84,7 @@ function safetyBackupName(backupName: string) {
const restoreMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_queue_v1.restore(ctx.serverId, ctx.worldId.value!, backupId, { name }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
})
const modal = ref<InstanceType<typeof NewModal>>()
@@ -1,14 +1,14 @@
<template>
<div class="contents">
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
<ButtonStyled v-if="isInstalling" type="standard" color="brand" :size="size">
<button disabled class="flex-shrink-0">
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
</button>
</ButtonStyled>
<template v-else-if="showRestartButton">
<ButtonStyled type="standard" color="orange" size="large">
<ButtonStyled type="standard" color="orange" :size="size">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<UpdatedIcon />
<span>{{ primaryActionText }}</span>
@@ -17,7 +17,7 @@
<JoinedButtons
color="red"
size="large"
:size="size"
:actions="stopSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill"
@@ -34,7 +34,7 @@
<template v-else-if="isStopping">
<JoinedButtons
color="red"
size="large"
:size="size"
:actions="stopSplitActions"
:primary-disabled="true"
:dropdown-disabled="!canKill"
@@ -49,10 +49,10 @@
</template>
<template v-else>
<ButtonStyled type="standard" color="brand" size="large">
<ButtonStyled type="standard" color="brand" :size="size">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<PlayIcon />
<span>{{ primaryActionText }}</span>
<span>{{ startActionText }}</span>
</button>
</ButtonStyled>
</template>
@@ -77,9 +77,13 @@ import { useServerPowerAction } from './use-server-power-action'
const props = withDefaults(
defineProps<{
disabled?: boolean
size?: 'standard' | 'large' | 'small'
startLabel?: string
}>(),
{
disabled: false,
size: 'large',
startLabel: 'Start',
},
)
@@ -97,6 +101,11 @@ const {
disabled: computed(() => props.disabled),
})
const size = computed(() => props.size)
const startActionText = computed(() =>
primaryActionText.value === 'Start' ? props.startLabel : primaryActionText.value,
)
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
{
id: 'stop',
@@ -18,46 +18,28 @@
<SettingsIcon />
Configuring server...
</div>
<div v-else class="flex flex-wrap items-center gap-2">
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium">
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" />
{{ formatLoaderLabel(props.server.loader) }} {{ props.server.mc_version }}
</div>
<div
v-if="
props.server?.loader &&
props.server?.net?.domain &&
!userPreferences.hideSubdomainLabel
"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
/>
<div
v-if="props.server?.net?.domain && !userPreferences.hideSubdomainLabel"
v-tooltip="'Copy server address'"
class="flex cursor-pointer items-center gap-2 font-medium hover:underline text-nowrap"
@click="copyServerAddress"
>
<LinkIcon class="flex size-5 shrink-0" />
{{ props.server.net.domain }}.modrinth.gg
</div>
<div v-if="showUptime" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<div v-if="showUptime" class="flex items-center gap-2 font-medium">
<TimerIcon class="flex size-5 shrink-0" />
{{ formattedUptime }}
</div>
<div
v-if="showProject && (props.server?.loader || props.server?.net?.domain || showUptime)"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
/>
<div v-else class="flex min-w-0 flex-wrap items-center gap-2">
<template v-for="(item, index) in headerStats" :key="item.id">
<div v-if="index > 0" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<button
v-if="item.copyable"
v-tooltip="'Copy server address'"
class="m-0 flex min-w-0 cursor-pointer items-center gap-2 border-0 bg-transparent p-0 font-medium text-secondary hover:underline text-nowrap"
type="button"
@click="copyServerAddress"
>
<component :is="item.icon" class="flex size-5 shrink-0" />
<span class="truncate">{{ item.label }}</span>
</button>
<div v-else class="flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap">
<component :is="item.icon" class="flex size-5 shrink-0" />
<span class="truncate">{{ item.label }}</span>
</div>
</template>
<div v-if="showProject && headerStats.length > 0" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<div
v-if="showProject"
class="flex items-center gap-1.5 font-medium text-primary text-nowrap"
class="flex min-w-0 items-center gap-1.5 font-medium text-primary text-nowrap"
>
Linked to
<Avatar
@@ -81,8 +63,9 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { NuxtModrinthClient } from '@modrinth/api-client'
import { LinkIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
import { GlobeIcon, LinkIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
import { useStorage } from '@vueuse/core'
import type { Component } from 'vue'
import { computed } from 'vue'
import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components'
@@ -102,12 +85,20 @@ type ServerProjectSummary = {
icon_url?: string | null
}
type HeaderStat = {
id: string
label: string
icon: Component
copyable?: boolean
}
const props = withDefaults(
defineProps<{
server: Archon.Servers.v0.Server | null | undefined
serverImage?: string | null
serverProject?: ServerProjectSummary | null
serverProjectLink?: string
activeWorldName?: string | null
uptimeSeconds?: number
showUptime?: boolean
backHref?: string
@@ -118,6 +109,7 @@ const props = withDefaults(
serverImage: null,
serverProject: null,
serverProjectLink: '',
activeWorldName: null,
uptimeSeconds: 0,
showUptime: true,
backHref: '/hosting/manage',
@@ -158,6 +150,57 @@ const formattedUptime = computed(() => {
return formatted.trim()
})
const serverAddress = computed(() => {
const domain = props.server?.net?.domain
if (domain) return `${domain}.modrinth.gg`
const ip = props.server?.net?.ip
if (!ip) return null
const port = props.server?.net?.port
return port ? `${ip}:${port}` : ip
})
const showAddress = computed(
() => !!serverAddress.value && (!props.server?.net?.domain || !userPreferences.value.hideSubdomainLabel),
)
const headerStats = computed<HeaderStat[]>(() => {
const stats: HeaderStat[] = []
const worldName = props.activeWorldName
if (worldName) {
stats.push({
id: 'world',
label: worldName,
icon: GlobeIcon,
})
}
if (props.server?.loader) {
stats.push({
id: 'loader',
label: props.server.mc_version
? `${formatLoaderLabel(props.server.loader)} ${props.server.mc_version}`
: formatLoaderLabel(props.server.loader),
icon: LoaderIcon,
})
}
if (showAddress.value && serverAddress.value) {
stats.push({
id: 'address',
label: serverAddress.value,
icon: LinkIcon,
copyable: true,
})
}
if (showUptime.value) {
stats.push({
id: 'uptime',
label: formattedUptime.value,
icon: TimerIcon,
})
}
return stats
})
const showProject = computed(() => !!props.serverProject)
const serverProjectLink = computed(() => {
@@ -171,8 +214,8 @@ const serverProjectLink = computed(() => {
})
function copyServerAddress() {
if (!props.server?.net?.domain) return
navigator.clipboard.writeText(`${props.server.net.domain}.modrinth.gg`)
if (!serverAddress.value) return
navigator.clipboard.writeText(serverAddress.value)
addNotification({
title: 'Server address copied',
text: "Your server's address has been copied to your clipboard.",
@@ -12,7 +12,7 @@ export function useServerBackupsQueue(serverId: Ref<string>, worldId: Ref<string
const client = injectModrinthClient()
const queryClient = useQueryClient()
const queryKey = computed(() => ['backups', 'queue', serverId.value] as const)
const queryKey = computed(() => ['backups', 'queue', serverId.value, worldId.value] as const)
const progressOverlay = reactive(new Map<ProgressKey, number>())
const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
@@ -227,6 +227,7 @@ const resetServerDisabledTooltip = computed(() => {
})
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
const contentListQueryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
function showResetServerModal() {
if (resetServerDisabled.value) return
@@ -237,13 +238,13 @@ async function invalidateServerState() {
debug('invalidateServerState: starting')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: contentListQueryKey.value }),
])
debug('invalidateServerState: complete')
}
const addonsQuery = useQuery({
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
queryKey: contentListQueryKey,
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
@@ -670,7 +671,7 @@ provideInstallationSettings({
const previousData = addonsQuery.data.value
if (previousData) {
debug('unlinkModpack: optimistically removing modpack from cache')
queryClient.setQueryData(['content', 'list', 'v1', serverId], {
queryClient.setQueryData(contentListQueryKey.value, {
...previousData,
modpack: null,
})
@@ -682,7 +683,7 @@ provideInstallationSettings({
} catch (err) {
debug('unlinkModpack: failed, reverting cache', err)
if (previousData) {
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData)
queryClient.setQueryData(contentListQueryKey.value, previousData)
}
addNotification({
type: 'error',
@@ -695,7 +696,7 @@ provideInstallationSettings({
queryKey: ['servers', 'detail', serverId],
}),
queryClient.invalidateQueries({
queryKey: ['content', 'list', 'v1', serverId],
queryKey: contentListQueryKey.value,
}),
])
debug('unlinkModpack: invalidation complete')
@@ -317,8 +317,10 @@ const { canUseAdvancedSettings, canUsePowerActions, permissionDeniedMessage } =
const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
)
const filesTabLink = computed(
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
const filesTabLink = computed(() =>
worldId.value
? `/hosting/manage/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId.value)}/files?path=/&editing=server.properties`
: `/hosting/manage/${encodeURIComponent(serverId)}/worlds`,
)
const serverSettings = injectServerSettings(null)
@@ -272,7 +272,11 @@ async function finalizeSetup() {
client.archon.servers_v1.endIntro(serverId).then(() => {
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
})
await router.push(`/hosting/manage/${serverId}/`)
await router.push(
worldId.value
? `/hosting/manage/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId.value)}`
: `/hosting/manage/${encodeURIComponent(serverId)}/worlds`,
)
}
/** Map UI loader names to API Modloader values */
@@ -362,7 +362,7 @@ const filterPillOptions = computed<FilterPillOption[]>(() => [
])
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { server, worldId, busyReasons } = injectModrinthServerContext()
const { server, serverId, worldId, busyReasons } = injectModrinthServerContext()
const props = defineProps<{
isServerRunning: boolean
@@ -371,9 +371,7 @@ const props = defineProps<{
}>()
const route = useRoute()
const serverId = route.params.id as string
const BACKUP_HIGHLIGHT_DURATION_MS = 5_000
defineEmits(['onDownload'])
const { backups, invalidate, activeOperationByBackupId, hasActiveCreate, hasActiveRestore, query } =
@@ -4,7 +4,7 @@ import { ClipboardCopyIcon } from '@modrinth/assets'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRouter } from 'vue-router'
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
@@ -115,7 +115,7 @@ const messages = defineMessages({
})
const client = injectModrinthClient()
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
const { server, serverId, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
injectModrinthServerContext()
const contentUploadSession = useUploadSessionUpload({
client,
@@ -127,10 +127,8 @@ const contentUploadSession = useUploadSessionUpload({
const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
const { canSetup, permissionDeniedMessage } = useServerPermissions()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const serverId = route.params.id as string
const type = computed(() => {
const loader = server.value?.loader?.toLowerCase()
@@ -139,8 +137,15 @@ const type = computed(() => {
return 'mod'
})
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
const modpackContentQueryKey = computed(() => ['content', 'list', 'v1', serverId, 'modpack'])
const queryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
const modpackContentQueryKey = computed(() => [
'content',
'list',
'v1',
serverId,
worldId.value,
'modpack',
])
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
@@ -92,7 +92,17 @@
</template>
</ErrorInformationCard>
</div>
<!-- SERVER START -->
<div
v-else-if="serverData && isWorldDetailRoute"
data-pyro-world-manager-root
class="experimental-styles-within relative mx-auto box-border flex w-full min-w-0 flex-col px-6 transition-all duration-300"
:class="[
'server-panel-' + revealState,
isNuxt ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
]"
>
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
</div>
<div
v-else-if="serverData"
data-pyro-server-manager-root
@@ -115,6 +125,7 @@
:server="serverData"
:server-image="serverImage"
:server-project="serverProject"
:active-world-name="activeWorldName"
:uptime-seconds="showUptime ? uptimeSeconds : undefined"
>
<template #actions>
@@ -305,8 +316,8 @@
</template>
</div>
<div
v-if="showAdvancedDebugInfo"
class="relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
v-if="showAdvancedDebugInfo && !isWorldDetailRoute"
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">
{{ formatMessage(messages.serverDataTitle) }}
@@ -334,12 +345,9 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
CopyIcon,
DatabaseBackupIcon,
FileIcon,
FolderOpenIcon,
IssuesIcon,
LayoutTemplateIcon,
LoaderCircleIcon,
@@ -624,22 +632,10 @@ const messages = defineMessages({
id: 'servers.manage.nav.overview',
defaultMessage: 'Overview',
},
contentNav: {
id: 'servers.manage.nav.content',
defaultMessage: 'Content',
},
worldsNav: {
id: 'servers.manage.nav.worlds',
defaultMessage: 'Worlds',
},
filesNav: {
id: 'servers.manage.nav.files',
defaultMessage: 'Files',
},
backupsNav: {
id: 'servers.manage.nav.backups',
defaultMessage: 'Backups',
},
errorDismissingNotice: {
id: 'servers.manage.notice.dismiss-error',
defaultMessage: 'Error dismissing notice',
@@ -773,11 +769,29 @@ const { data: serverFull } = useQuery({
})
const worldId = computed(() => {
const routeWorldId = getRouteParam(route.params.world_id)
if (routeWorldId) return routeWorldId
if (!serverFull.value) return null
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
})
const isWorldDetailRoute = computed(() => !!getRouteParam(route.params.world_id))
const activeWorldName = computed(() => {
if (!serverFull.value) return null
const activeWorld = serverFull.value.worlds.find((world) => world.is_active)
return activeWorld?.name ?? serverFull.value.worlds[0]?.name ?? null
})
function getRouteParam(param: string | string[] | undefined): string | null {
if (Array.isArray(param)) return param[0] ?? null
return param ?? null
}
function getWorldPath(targetWorldId: string) {
return `/hosting/manage/${encodeURIComponent(props.serverId)}/worlds/${encodeURIComponent(targetWorldId)}`
}
const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQueue(
computed(() => props.serverId),
worldId,
@@ -1032,37 +1046,19 @@ watch(serverData, (data) => {
const navLinks = computed<Tab[]>(() => [
{
label: formatMessage(messages.overviewNav),
href: `/hosting/manage/${props.serverId}`,
href: `/hosting/manage/${encodeURIComponent(props.serverId)}`,
icon: LayoutTemplateIcon,
subpages: [],
},
{
label: formatMessage(messages.contentNav),
href: `/hosting/manage/${props.serverId}/content`,
icon: BoxesIcon,
subpages: ['mods', 'datapacks'],
},
{
label: formatMessage(messages.worldsNav),
href: `/hosting/manage/${props.serverId}/worlds`,
href: `/hosting/manage/${encodeURIComponent(props.serverId)}/worlds`,
icon: WorldIcon,
subpages: [],
},
{
label: formatMessage(messages.filesNav),
href: `/hosting/manage/${props.serverId}/files`,
icon: FolderOpenIcon,
subpages: [],
},
{
label: formatMessage(messages.backupsNav),
href: `/hosting/manage/${props.serverId}/backups`,
icon: DatabaseBackupIcon,
subpages: [],
},
{
label: 'Access',
href: `/hosting/manage/${props.serverId}/access`,
href: `/hosting/manage/${encodeURIComponent(props.serverId)}/access`,
icon: UsersIcon,
subpages: [],
},
@@ -1219,7 +1215,7 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
}
const handleNewMod = () => {
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
}
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
@@ -1305,7 +1301,7 @@ const onReinstall = async (
debug('[root.vue] onReinstall: triggering immediate invalidation')
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
}
const onReinstallFailed = () => {
@@ -1354,7 +1350,7 @@ async function invalidateAfterInstall() {
queryClient.invalidateQueries({
queryKey: ['servers', 'startup', 'v1', props.serverId],
}),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] }),
])
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
@@ -1478,7 +1474,10 @@ const copyServerDebugInfo = () => {
}
const openInstallLog = () => {
const url = `/hosting/manage/${props.serverId}/files?editing=${encodeURIComponent(errorLogFile.value)}`
const filesPath = worldId.value
? `${getWorldPath(worldId.value)}/files`
: `/hosting/manage/${encodeURIComponent(props.serverId)}/worlds`
const url = `${filesPath}?editing=${encodeURIComponent(errorLogFile.value)}`
window.history.pushState({}, '', url)
window.dispatchEvent(new PopStateEvent('popstate'))
}
@@ -0,0 +1,213 @@
<template>
<div class="flex min-h-[36rem] flex-col gap-6 text-primary">
<div class="flex flex-col gap-2">
<RouterLink
:to="worldsPath"
class="flex w-fit items-center gap-1 text-base font-medium text-blue hover:underline"
>
<ChevronLeftIcon class="size-4" aria-hidden="true" />
{{ formatMessage(messages.allWorlds) }}
</RouterLink>
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="flex min-w-0 flex-col gap-1">
<h1 class="m-0 truncate text-2xl font-semibold leading-8 text-contrast">
{{ worldName }}
</h1>
<div class="flex flex-wrap items-center gap-2 text-base font-medium text-secondary">
<template v-for="(item, index) in worldMetadata" :key="item">
<span>{{ item }}</span>
<BulletDivider v-if="index < worldMetadata.length - 1" />
</template>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<PanelServerActionButton size="standard" start-label="Start world" />
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.worldSettings)"
@click="openServerSettings({ tabId: 'installation' })"
>
<SettingsIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="h-px w-full bg-surface-5" />
<NavTabs :links="worldTabLinks" replace />
<slot />
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
BoxesIcon,
ChevronLeftIcon,
DatabaseBackupIcon,
FolderOpenIcon,
SettingsIcon,
} from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import { PanelServerActionButton } from '#ui/components/servers/server-header'
import { useRelativeTime } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectServerSettingsModal,
} from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
interface Tab {
label: string
href: string
icon?: object
subpages?: string[]
}
const messages = defineMessages({
allWorlds: {
id: 'servers.manage.world.all-worlds',
defaultMessage: 'All worlds',
},
contentNav: {
id: 'servers.manage.nav.content',
defaultMessage: 'Content',
},
filesNav: {
id: 'servers.manage.nav.files',
defaultMessage: 'Files',
},
backupsNav: {
id: 'servers.manage.nav.backups',
defaultMessage: 'Backups',
},
worldFallbackName: {
id: 'servers.manage.world.fallback-name',
defaultMessage: 'World',
},
lastActive: {
id: 'servers.manage.world.last-active',
defaultMessage: 'Last active {time}',
},
worldSettings: {
id: 'servers.manage.world.settings',
defaultMessage: 'World settings',
},
})
const client = injectModrinthClient()
const { serverId, server, worldId, isServerRunning } = injectModrinthServerContext()
const { openServerSettings } = injectServerSettingsModal()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
const { data: serverFull } = useQuery({
queryKey: computed(() => ['servers', 'v1', 'detail', serverId]),
queryFn: () => client.archon.servers_v1.get(serverId),
staleTime: 30_000,
})
const worldsPath = computed(() => `/hosting/manage/${encodeURIComponent(serverId)}/worlds`)
const worldPath = computed(() =>
worldId.value ? `${worldsPath.value}/${encodeURIComponent(worldId.value)}` : worldsPath.value,
)
const currentWorld = computed(() => {
const id = worldId.value
if (!id) return null
return serverFull.value?.worlds.find((world) => world.id === id) ?? null
})
const worldName = computed(
() => currentWorld.value?.name ?? server.value?.name ?? formatMessage(messages.worldFallbackName),
)
const worldMetadata = computed(() =>
[gameVersionLabel.value, loaderLabel.value, lastActiveLabel.value].filter(
(item): item is string => !!item,
),
)
const gameVersionLabel = computed(() => {
const version = currentWorld.value?.content?.game_version ?? server.value?.mc_version
return version ? `MC ${version}` : null
})
const loaderLabel = computed(() => {
const loader = currentWorld.value?.content?.modloader ?? server.value?.loader?.toLowerCase()
if (!loader) return null
const loaderVersion =
currentWorld.value?.content?.modloader_version ?? server.value?.loader_version ?? null
return [formatLoaderLabel(loader), loaderVersion].filter(Boolean).join(' ')
})
const lastActiveLabel = computed(() => {
const latestBackup = currentWorld.value ? latestDate(currentWorld.value.backups) : null
const lastActiveAt =
latestBackup ??
(currentWorld.value?.is_active && isServerRunning.value
? new Date().toISOString()
: currentWorld.value?.created_at)
return lastActiveAt
? formatMessage(messages.lastActive, { time: formatRelativeTime(lastActiveAt) })
: null
})
const worldTabLinks = computed<Tab[]>(() => [
{
label: formatMessage(messages.contentNav),
href: worldPath.value,
icon: BoxesIcon,
subpages: [],
},
{
label: formatMessage(messages.filesNav),
href: `${worldPath.value}/files`,
icon: FolderOpenIcon,
subpages: [],
},
{
label: formatMessage(messages.backupsNav),
href: `${worldPath.value}/backups`,
icon: DatabaseBackupIcon,
subpages: [],
},
])
watch(
() => [serverFull.value, currentWorld.value, worldId.value] as const,
([full, world, id]) => {
if (full && id && !world) {
router.replace(worldsPath.value)
}
},
{ immediate: true },
)
function latestDate(backups: Archon.Servers.v1.WorldFull['backups']): string | null {
let latest = 0
let latestIso: string | null = null
for (const backup of backups) {
const timestamp = new Date(backup.created_at).getTime()
if (!Number.isFinite(timestamp) || timestamp <= latest) continue
latest = timestamp
latestIso = backup.created_at
}
return latestIso
}
</script>
@@ -17,7 +17,7 @@
:world="world"
@create="handleCreateWorld"
@edit="handleEditWorld"
@settings="handleEditWorld"
@settings="handleWorldSettings"
/>
</div>
</div>
@@ -27,6 +27,7 @@
import type { Archon } from '@modrinth/api-client'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
@@ -84,6 +85,7 @@ const client = injectModrinthClient()
const { serverId, server, isServerRunning } = injectModrinthServerContext()
const { openServerSettings } = injectServerSettingsModal()
const { formatMessage } = useVIntl()
const router = useRouter()
const worldsQuery = useQuery({
queryKey: computed(() => ['servers', 'worlds', 'summary', 'v1', serverId]),
@@ -272,7 +274,13 @@ function createDummyWorldSlots(): WorldSlot[] {
]
}
function handleEditWorld() {
function handleEditWorld(worldId: string) {
router.push(
`/hosting/manage/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId)}`,
)
}
function handleWorldSettings() {
openServerSettings({ tabId: 'installation' })
}
+1
View File
@@ -6,4 +6,5 @@ export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
export { default as ServersManageWorldRootLayout } from './hosting/manage/world-root.vue'
export { default as ServersManageWorldsPage } from './hosting/manage/worlds.vue'
@@ -25,7 +25,7 @@ const meta = {
setup() {
const router = useRouter()
onMounted(() => {
router.replace('/hosting/manage/demo-server/content')
router.replace('/hosting/manage/demo-server/worlds/demo-world')
})
const server = ref({