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
+49
View File
@@ -495,6 +495,11 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({
}, },
}) })
router.beforeEach(async (to) => {
const redirect = await resolveLegacyServerWorldTabRedirect(to)
if (redirect) return redirect
})
router.beforeEach(() => { router.beforeEach(() => {
suspensePending = false suspensePending = false
if (routerToken) loading.end(routerToken) if (routerToken) loading.end(routerToken)
@@ -526,6 +531,50 @@ function onSuspensePending() {
suspenseToken = loading.begin() suspenseToken = loading.begin()
} }
async function resolveLegacyServerWorldTabRedirect(to) {
if (!['ServerManageContent', 'ServerManageFiles', 'ServerManageBackups'].includes(to.name)) {
return null
}
const serverId = getRouteParam(to.params.id)
if (!serverId) return null
const tabPath =
to.name === 'ServerManageFiles' ? '/files' : to.name === 'ServerManageBackups' ? '/backups' : ''
const worldsPath = `/hosting/manage/${encodeURIComponent(serverId)}/worlds`
try {
const serverFull = await tauriApiClient.archon.servers_v1.get(serverId)
const world = serverFull.worlds.find((item) => item.is_active) ?? serverFull.worlds[0]
if (world) {
return {
path: `${worldsPath}/${encodeURIComponent(world.id)}${tabPath}`,
query: to.query,
hash: to.hash,
replace: true,
}
}
} catch {
return {
path: worldsPath,
query: to.query,
hash: to.hash,
replace: true,
}
}
return {
path: worldsPath,
query: to.query,
hash: to.hash,
replace: true,
}
}
function getRouteParam(param) {
return Array.isArray(param) ? param[0] : param
}
function onSuspenseResolve() { function onSuspenseResolve() {
if (suspenseToken) { if (suspenseToken) {
loading.end(suspenseToken) loading.end(suspenseToken)
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
if (worldId.value) { if (worldId.value) {
try { try {
await queryClient.ensureQueryData({ await queryClient.ensureQueryData({
queryKey: ['backups', 'list', serverId], queryKey: ['backups', 'list', serverId, worldId.value],
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!), queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
staleTime: 30_000, staleTime: 30_000,
}) })
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
if (worldId.value) { if (worldId.value) {
try { try {
await queryClient.ensureQueryData({ await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId], queryKey: ['content', 'list', 'v1', serverId, worldId.value],
queryFn: () => queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
staleTime: 30_000, staleTime: 30_000,
@@ -93,7 +93,7 @@ watch(
breadcrumbs.setName('Server', server.name) breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({ breadcrumbs.setContext({
name: server.name, name: server.name,
link: `/hosting/manage/${serverId.value}/content`, link: `/hosting/manage/${serverId.value}/worlds`,
}) })
} }
}, },
@@ -0,0 +1,16 @@
<template>
<ServersManageWorldRootLayout>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense>
<component :is="Component" />
</Suspense>
</template>
</RouterView>
</ServersManageWorldRootLayout>
</template>
<script setup lang="ts">
import { ServersManageWorldRootLayout } from '@modrinth/ui'
import { RouterView } from 'vue-router'
</script>
@@ -4,6 +4,7 @@ import Content from './Content.vue'
import Files from './Files.vue' import Files from './Files.vue'
import Index from './Index.vue' import Index from './Index.vue'
import Overview from './Overview.vue' import Overview from './Overview.vue'
import World from './World.vue'
import Worlds from './Worlds.vue' import Worlds from './Worlds.vue'
export { Access, Backups, Content, Files, Index, Overview, Worlds } export { Access, Backups, Content, Files, Index, Overview, World, Worlds }
@@ -252,7 +252,7 @@ export function createServerInstallContent(opts: {
if (serverFlowFrom.value === 'reset-server') { if (serverFlowFrom.value === 'reset-server') {
return `/hosting/manage/${sid}?openSettings=installation` return `/hosting/manage/${sid}?openSettings=installation`
} }
return `/hosting/manage/${sid}/content` return getServerWorldContentPath(sid, effectiveServerWorldId.value)
}) })
const serverBackLabel = computed(() => { const serverBackLabel = computed(() => {
if (serverFlowFrom.value === 'onboarding') return 'Back to setup' if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
@@ -556,7 +556,7 @@ export function createServerInstallContent(opts: {
if (serverFlowFrom.value === 'onboarding') { if (serverFlowFrom.value === 'onboarding') {
await client.archon.servers_v1.endIntro(sid) await client.archon.servers_v1.endIntro(sid)
await router.push(`/hosting/manage/${sid}/content`) await router.push(getServerWorldContentPath(sid, wid))
return return
} }
@@ -571,6 +571,11 @@ export function createServerInstallContent(opts: {
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id]) serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
} }
function getServerWorldContentPath(serverId: string, worldId: string | null) {
const base = `/hosting/manage/${encodeURIComponent(serverId)}/worlds`
return worldId ? `${base}/${encodeURIComponent(worldId)}` : base
}
return { return {
serverIdQuery, serverIdQuery,
worldIdQuery, worldIdQuery,
+34
View File
@@ -65,6 +65,40 @@ export default new createRouter({
breadcrumb: [{ name: '?Server' }], breadcrumb: [{ name: '?Server' }],
}, },
}, },
{
path: 'worlds/:world_id',
name: 'ServerManageWorld',
component: Hosting.World,
meta: {
breadcrumb: [{ name: '?Server' }],
},
children: [
{
path: '',
name: 'ServerManageWorldContent',
component: Hosting.Content,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'files',
name: 'ServerManageWorldFiles',
component: Hosting.Files,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'backups',
name: 'ServerManageWorldBackups',
component: Hosting.Backups,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
{ {
path: 'files', path: 'files',
name: 'ServerManageFiles', name: 'ServerManageFiles',
@@ -187,7 +187,10 @@ export function useServerInstallContent({
}, },
} }
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const) const contentQueryKey = computed(
() =>
['content', 'list', 'v1', currentServerId.value ?? '', currentWorldId.value ?? null] as const,
)
const { data: serverContentData, error: serverContentError } = useQuery({ const { data: serverContentData, error: serverContentError } = useQuery({
queryKey: contentQueryKey, queryKey: contentQueryKey,
queryFn: () => queryFn: () =>
@@ -659,7 +662,7 @@ export function useServerInstallContent({
if (fromContext.value === 'onboarding') { if (fromContext.value === 'onboarding') {
await client.archon.servers_v1.endIntro(currentServerId.value) await client.archon.servers_v1.endIntro(currentServerId.value)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] }) queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
navigateTo(`/hosting/manage/${currentServerId.value}/content`) navigateTo(getServerWorldContentPath(currentServerId.value, currentWorldId.value ?? null))
} else { } else {
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`) navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
} }
@@ -675,9 +678,14 @@ export function useServerInstallContent({
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type` if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
if (fromContext.value === 'reset-server') if (fromContext.value === 'reset-server')
return `/hosting/manage/${id}?openSettings=installation` return `/hosting/manage/${id}?openSettings=installation`
return `/hosting/manage/${id}/content` return getServerWorldContentPath(id, currentWorldId.value)
}) })
function getServerWorldContentPath(serverId: string, worldId: string | null) {
const base = `/hosting/manage/${encodeURIComponent(serverId)}/worlds`
return worldId ? `${base}/${encodeURIComponent(worldId)}` : base
}
const serverBackLabel = computed(() => { const serverBackLabel = computed(() => {
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup) if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset) if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
@@ -0,0 +1,40 @@
import { createModrinthClient } from '~/helpers/api.ts'
export default defineNuxtRouteMiddleware(async (to) => {
const match = to.path.match(/^\/hosting\/manage\/([^/]+)\/(content|files|backups)\/?$/)
if (!match) return
const serverId = decodeURIComponent(match[1])
const tab = match[2]
const worldsPath = `/hosting/manage/${encodeURIComponent(serverId)}/worlds`
const tabPath = tab === 'content' ? '' : `/${tab}`
const auth = await useAuth()
if (auth.value.token) {
try {
const config = useRuntimeConfig()
const client = createModrinthClient(auth, {
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
rateLimitKey: config.rateLimitKey,
})
const serverFull = await client.archon.servers_v1.get(serverId)
const world = serverFull.worlds.find((item) => item.is_active) ?? serverFull.worlds[0]
if (world) {
return navigateTo(
{
path: `${worldsPath}/${encodeURIComponent(world.id)}${tabPath}`,
query: to.query,
hash: to.hash,
},
{ replace: true },
)
}
} catch {
return navigateTo({ path: worldsPath, query: to.query, hash: to.hash }, { replace: true })
}
}
return navigateTo({ path: worldsPath, query: to.query, hash: to.hash }, { replace: true })
})
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { ServersManageWorldRootLayout } from '@modrinth/ui'
const route = useNativeRoute()
</script>
<template>
<ServersManageWorldRootLayout>
<NuxtPage :route="route" />
</ServersManageWorldRootLayout>
</template>
@@ -14,7 +14,7 @@ const flags = useFeatureFlags()
if (worldId.value) { if (worldId.value) {
try { try {
await queryClient.ensureQueryData({ await queryClient.ensureQueryData({
queryKey: ['backups', 'list', serverId], queryKey: ['backups', 'list', serverId, worldId.value],
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!), queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
staleTime: 30_000, staleTime: 30_000,
}) })
@@ -38,7 +38,7 @@ const contentWorldId = await getContentWorldId()
if (contentWorldId) { if (contentWorldId) {
try { try {
const content = await queryClient.ensureQueryData({ const content = await queryClient.ensureQueryData({
queryKey: ['content', 'list', 'v1', serverId], queryKey: ['content', 'list', 'v1', serverId, contentWorldId],
queryFn: () => queryFn: () =>
client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }), client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }),
staleTime: 30_000, staleTime: 30_000,
@@ -77,7 +77,7 @@ onMounted(() => {
isClient.value = true isClient.value = true
}) })
const { serverId } = injectModrinthServerContext() const { serverId, worldId } = injectModrinthServerContext()
const { featureFlags } = injectPageContext() const { featureFlags } = injectPageContext()
const props = withDefaults( const props = withDefaults(
@@ -190,7 +190,9 @@ const metrics = computed(() => {
showGraph: false, showGraph: false,
chartOptions: null as ReturnType<typeof buildChartOptions> | null, chartOptions: null as ReturnType<typeof buildChartOptions> | null,
series: null as { name: string; data: number[] }[] | 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) { if (props.loading) {
@@ -195,6 +195,13 @@ async function show({ serverId, tabIndex, tabId }: ShowOptions) {
queryKey: ['servers', 'properties', 'v1', targetServerId, worldId.value], queryKey: ['servers', 'properties', 'v1', targetServerId, worldId.value],
queryFn: () => client.archon.properties_v1.getProperties(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({ queryClient.prefetchQuery({
queryKey: ['servers', 'startup', 'v1', targetServerId, worldId.value], queryKey: ['servers', 'startup', 'v1', targetServerId, worldId.value],
queryFn: () => client.archon.options_v1.getStartup(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 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( 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({ const createMutation = useMutation({
mutationFn: (name: string) => mutationFn: (name: string) =>
client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }), 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>>() 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({ const renameMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) => mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }), 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>>() 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) { function safetyBackupName(backupName: string) {
const base = `Before restoring "${backupName}"` const base = `Before restoring "${backupName}"`
@@ -84,7 +84,7 @@ function safetyBackupName(backupName: string) {
const restoreMutation = useMutation({ const restoreMutation = useMutation({
mutationFn: ({ backupId, name }: { backupId: string; name: string }) => mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
client.archon.backups_queue_v1.restore(ctx.serverId, ctx.worldId.value!, backupId, { name }), 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>>() const modal = ref<InstanceType<typeof NewModal>>()
@@ -1,14 +1,14 @@
<template> <template>
<div class="contents"> <div class="contents">
<div class="flex flex-row items-center gap-2 rounded-lg"> <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"> <button disabled class="flex-shrink-0">
<LoaderCircleIcon class="size-5 animate-spin" /> Installing... <LoaderCircleIcon class="size-5 animate-spin" /> Installing...
</button> </button>
</ButtonStyled> </ButtonStyled>
<template v-else-if="showRestartButton"> <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"> <button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<UpdatedIcon /> <UpdatedIcon />
<span>{{ primaryActionText }}</span> <span>{{ primaryActionText }}</span>
@@ -17,7 +17,7 @@
<JoinedButtons <JoinedButtons
color="red" color="red"
size="large" :size="size"
:actions="stopSplitActions" :actions="stopSplitActions"
:primary-disabled="!canTakeAction" :primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill" :dropdown-disabled="!canKill"
@@ -34,7 +34,7 @@
<template v-else-if="isStopping"> <template v-else-if="isStopping">
<JoinedButtons <JoinedButtons
color="red" color="red"
size="large" :size="size"
:actions="stopSplitActions" :actions="stopSplitActions"
:primary-disabled="true" :primary-disabled="true"
:dropdown-disabled="!canKill" :dropdown-disabled="!canKill"
@@ -49,10 +49,10 @@
</template> </template>
<template v-else> <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"> <button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<PlayIcon /> <PlayIcon />
<span>{{ primaryActionText }}</span> <span>{{ startActionText }}</span>
</button> </button>
</ButtonStyled> </ButtonStyled>
</template> </template>
@@ -77,9 +77,13 @@ import { useServerPowerAction } from './use-server-power-action'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
disabled?: boolean disabled?: boolean
size?: 'standard' | 'large' | 'small'
startLabel?: string
}>(), }>(),
{ {
disabled: false, disabled: false,
size: 'large',
startLabel: 'Start',
}, },
) )
@@ -97,6 +101,11 @@ const {
disabled: computed(() => props.disabled), disabled: computed(() => props.disabled),
}) })
const size = computed(() => props.size)
const startActionText = computed(() =>
primaryActionText.value === 'Start' ? props.startLabel : primaryActionText.value,
)
const stopSplitActions = computed<JoinedButtonAction[]>(() => [ const stopSplitActions = computed<JoinedButtonAction[]>(() => [
{ {
id: 'stop', id: 'stop',
@@ -18,46 +18,28 @@
<SettingsIcon /> <SettingsIcon />
Configuring server... Configuring server...
</div> </div>
<div v-else class="flex flex-wrap items-center gap-2"> <div v-else class="flex min-w-0 flex-wrap items-center gap-2">
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium"> <template v-for="(item, index) in headerStats" :key="item.id">
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" /> <div v-if="index > 0" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
{{ formatLoaderLabel(props.server.loader) }} {{ props.server.mc_version }} <button
</div> v-if="item.copyable"
v-tooltip="'Copy server address'"
<div 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"
v-if=" type="button"
props.server?.loader && @click="copyServerAddress"
props.server?.net?.domain && >
!userPreferences.hideSubdomainLabel <component :is="item.icon" class="flex size-5 shrink-0" />
" <span class="truncate">{{ item.label }}</span>
class="h-1.5 w-1.5 rounded-full bg-surface-5" </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" />
<div <span class="truncate">{{ item.label }}</span>
v-if="props.server?.net?.domain && !userPreferences.hideSubdomainLabel" </div>
v-tooltip="'Copy server address'" </template>
class="flex cursor-pointer items-center gap-2 font-medium hover:underline text-nowrap" <div v-if="showProject && headerStats.length > 0" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
@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 <div
v-if="showProject" 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 Linked to
<Avatar <Avatar
@@ -81,8 +63,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { NuxtModrinthClient } 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 { useStorage } from '@vueuse/core'
import type { Component } from 'vue'
import { computed } from 'vue' import { computed } from 'vue'
import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components' import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components'
@@ -102,12 +85,20 @@ type ServerProjectSummary = {
icon_url?: string | null icon_url?: string | null
} }
type HeaderStat = {
id: string
label: string
icon: Component
copyable?: boolean
}
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
server: Archon.Servers.v0.Server | null | undefined server: Archon.Servers.v0.Server | null | undefined
serverImage?: string | null serverImage?: string | null
serverProject?: ServerProjectSummary | null serverProject?: ServerProjectSummary | null
serverProjectLink?: string serverProjectLink?: string
activeWorldName?: string | null
uptimeSeconds?: number uptimeSeconds?: number
showUptime?: boolean showUptime?: boolean
backHref?: string backHref?: string
@@ -118,6 +109,7 @@ const props = withDefaults(
serverImage: null, serverImage: null,
serverProject: null, serverProject: null,
serverProjectLink: '', serverProjectLink: '',
activeWorldName: null,
uptimeSeconds: 0, uptimeSeconds: 0,
showUptime: true, showUptime: true,
backHref: '/hosting/manage', backHref: '/hosting/manage',
@@ -158,6 +150,57 @@ const formattedUptime = computed(() => {
return formatted.trim() 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 showProject = computed(() => !!props.serverProject)
const serverProjectLink = computed(() => { const serverProjectLink = computed(() => {
@@ -171,8 +214,8 @@ const serverProjectLink = computed(() => {
}) })
function copyServerAddress() { function copyServerAddress() {
if (!props.server?.net?.domain) return if (!serverAddress.value) return
navigator.clipboard.writeText(`${props.server.net.domain}.modrinth.gg`) navigator.clipboard.writeText(serverAddress.value)
addNotification({ addNotification({
title: 'Server address copied', title: 'Server address copied',
text: "Your server's address has been copied to your clipboard.", 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 client = injectModrinthClient()
const queryClient = useQueryClient() 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 progressOverlay = reactive(new Map<ProgressKey, number>())
const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>() const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
@@ -227,6 +227,7 @@ const resetServerDisabledTooltip = computed(() => {
}) })
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>() const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
const setupModal = ref<InstanceType<typeof ServerSetupModal>>() const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
const contentListQueryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
function showResetServerModal() { function showResetServerModal() {
if (resetServerDisabled.value) return if (resetServerDisabled.value) return
@@ -237,13 +238,13 @@ async function invalidateServerState() {
debug('invalidateServerState: starting') debug('invalidateServerState: starting')
await Promise.all([ await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }), queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }), queryClient.invalidateQueries({ queryKey: contentListQueryKey.value }),
]) ])
debug('invalidateServerState: complete') debug('invalidateServerState: complete')
} }
const addonsQuery = useQuery({ const addonsQuery = useQuery({
queryKey: computed(() => ['content', 'list', 'v1', serverId]), queryKey: contentListQueryKey,
queryFn: () => queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null), enabled: computed(() => worldId.value !== null),
@@ -670,7 +671,7 @@ provideInstallationSettings({
const previousData = addonsQuery.data.value const previousData = addonsQuery.data.value
if (previousData) { if (previousData) {
debug('unlinkModpack: optimistically removing modpack from cache') debug('unlinkModpack: optimistically removing modpack from cache')
queryClient.setQueryData(['content', 'list', 'v1', serverId], { queryClient.setQueryData(contentListQueryKey.value, {
...previousData, ...previousData,
modpack: null, modpack: null,
}) })
@@ -682,7 +683,7 @@ provideInstallationSettings({
} catch (err) { } catch (err) {
debug('unlinkModpack: failed, reverting cache', err) debug('unlinkModpack: failed, reverting cache', err)
if (previousData) { if (previousData) {
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData) queryClient.setQueryData(contentListQueryKey.value, previousData)
} }
addNotification({ addNotification({
type: 'error', type: 'error',
@@ -695,7 +696,7 @@ provideInstallationSettings({
queryKey: ['servers', 'detail', serverId], queryKey: ['servers', 'detail', serverId],
}), }),
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['content', 'list', 'v1', serverId], queryKey: contentListQueryKey.value,
}), }),
]) ])
debug('unlinkModpack: invalidation complete') debug('unlinkModpack: invalidation complete')
@@ -317,8 +317,10 @@ const { canUseAdvancedSettings, canUsePowerActions, permissionDeniedMessage } =
const advancedActionTooltip = computed(() => const advancedActionTooltip = computed(() =>
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value, canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
) )
const filesTabLink = computed( const filesTabLink = computed(() =>
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`, worldId.value
? `/hosting/manage/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId.value)}/files?path=/&editing=server.properties`
: `/hosting/manage/${encodeURIComponent(serverId)}/worlds`,
) )
const serverSettings = injectServerSettings(null) const serverSettings = injectServerSettings(null)
@@ -272,7 +272,11 @@ async function finalizeSetup() {
client.archon.servers_v1.endIntro(serverId).then(() => { client.archon.servers_v1.endIntro(serverId).then(() => {
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }) 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 */ /** Map UI loader names to API Modloader values */
@@ -362,7 +362,7 @@ const filterPillOptions = computed<FilterPillOption[]>(() => [
]) ])
const client = injectModrinthClient() const client = injectModrinthClient()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { server, worldId, busyReasons } = injectModrinthServerContext() const { server, serverId, worldId, busyReasons } = injectModrinthServerContext()
const props = defineProps<{ const props = defineProps<{
isServerRunning: boolean isServerRunning: boolean
@@ -371,9 +371,7 @@ const props = defineProps<{
}>() }>()
const route = useRoute() const route = useRoute()
const serverId = route.params.id as string
const BACKUP_HIGHLIGHT_DURATION_MS = 5_000 const BACKUP_HIGHLIGHT_DURATION_MS = 5_000
defineEmits(['onDownload']) defineEmits(['onDownload'])
const { backups, invalidate, activeOperationByBackupId, hasActiveCreate, hasActiveRestore, query } = 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 { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core' import { useIntervalFn } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' 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 ReadyTransition from '#ui/components/base/ReadyTransition.vue'
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload' import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
@@ -115,7 +115,7 @@ const messages = defineMessages({
}) })
const client = injectModrinthClient() const client = injectModrinthClient()
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } = const { server, serverId, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
injectModrinthServerContext() injectModrinthServerContext()
const contentUploadSession = useUploadSessionUpload({ const contentUploadSession = useUploadSessionUpload({
client, client,
@@ -127,10 +127,8 @@ const contentUploadSession = useUploadSessionUpload({
const { addNotification } = injectNotificationManager() const { addNotification } = injectNotificationManager()
const { openServerSettings, browseServerContent } = injectServerSettingsModal() const { openServerSettings, browseServerContent } = injectServerSettingsModal()
const { canSetup, permissionDeniedMessage } = useServerPermissions() const { canSetup, permissionDeniedMessage } = useServerPermissions()
const route = useRoute()
const router = useRouter() const router = useRouter()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const serverId = route.params.id as string
const type = computed(() => { const type = computed(() => {
const loader = server.value?.loader?.toLowerCase() const loader = server.value?.loader?.toLowerCase()
@@ -139,8 +137,15 @@ const type = computed(() => {
return 'mod' return 'mod'
}) })
const queryKey = computed(() => ['content', 'list', 'v1', serverId]) const queryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
const modpackContentQueryKey = computed(() => ['content', 'list', 'v1', serverId, 'modpack']) const modpackContentQueryKey = computed(() => [
'content',
'list',
'v1',
serverId,
worldId.value,
'modpack',
])
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) { function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
@@ -92,7 +92,17 @@
</template> </template>
</ErrorInformationCard> </ErrorInformationCard>
</div> </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 <div
v-else-if="serverData" v-else-if="serverData"
data-pyro-server-manager-root data-pyro-server-manager-root
@@ -115,6 +125,7 @@
:server="serverData" :server="serverData"
:server-image="serverImage" :server-image="serverImage"
:server-project="serverProject" :server-project="serverProject"
:active-world-name="activeWorldName"
:uptime-seconds="showUptime ? uptimeSeconds : undefined" :uptime-seconds="showUptime ? uptimeSeconds : undefined"
> >
<template #actions> <template #actions>
@@ -305,8 +316,8 @@
</template> </template>
</div> </div>
<div <div
v-if="showAdvancedDebugInfo" v-if="showAdvancedDebugInfo && !isWorldDetailRoute"
class="relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6" 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"> <h2 class="m-0 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.serverDataTitle) }} {{ formatMessage(messages.serverDataTitle) }}
@@ -334,12 +345,9 @@
import type { Archon, Labrinth } from '@modrinth/api-client' import type { Archon, Labrinth } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError } from '@modrinth/api-client' import { getNodeWebSocketUrl, ModrinthApiError } from '@modrinth/api-client'
import { import {
BoxesIcon,
CheckIcon, CheckIcon,
CopyIcon, CopyIcon,
DatabaseBackupIcon,
FileIcon, FileIcon,
FolderOpenIcon,
IssuesIcon, IssuesIcon,
LayoutTemplateIcon, LayoutTemplateIcon,
LoaderCircleIcon, LoaderCircleIcon,
@@ -624,22 +632,10 @@ const messages = defineMessages({
id: 'servers.manage.nav.overview', id: 'servers.manage.nav.overview',
defaultMessage: 'Overview', defaultMessage: 'Overview',
}, },
contentNav: {
id: 'servers.manage.nav.content',
defaultMessage: 'Content',
},
worldsNav: { worldsNav: {
id: 'servers.manage.nav.worlds', id: 'servers.manage.nav.worlds',
defaultMessage: 'Worlds', defaultMessage: 'Worlds',
}, },
filesNav: {
id: 'servers.manage.nav.files',
defaultMessage: 'Files',
},
backupsNav: {
id: 'servers.manage.nav.backups',
defaultMessage: 'Backups',
},
errorDismissingNotice: { errorDismissingNotice: {
id: 'servers.manage.notice.dismiss-error', id: 'servers.manage.notice.dismiss-error',
defaultMessage: 'Error dismissing notice', defaultMessage: 'Error dismissing notice',
@@ -773,11 +769,29 @@ const { data: serverFull } = useQuery({
}) })
const worldId = computed(() => { const worldId = computed(() => {
const routeWorldId = getRouteParam(route.params.world_id)
if (routeWorldId) return routeWorldId
if (!serverFull.value) return null if (!serverFull.value) return null
const activeWorld = serverFull.value.worlds.find((w) => w.is_active) const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null 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( const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQueue(
computed(() => props.serverId), computed(() => props.serverId),
worldId, worldId,
@@ -1032,37 +1046,19 @@ watch(serverData, (data) => {
const navLinks = computed<Tab[]>(() => [ const navLinks = computed<Tab[]>(() => [
{ {
label: formatMessage(messages.overviewNav), label: formatMessage(messages.overviewNav),
href: `/hosting/manage/${props.serverId}`, href: `/hosting/manage/${encodeURIComponent(props.serverId)}`,
icon: LayoutTemplateIcon, icon: LayoutTemplateIcon,
subpages: [], subpages: [],
}, },
{
label: formatMessage(messages.contentNav),
href: `/hosting/manage/${props.serverId}/content`,
icon: BoxesIcon,
subpages: ['mods', 'datapacks'],
},
{ {
label: formatMessage(messages.worldsNav), label: formatMessage(messages.worldsNav),
href: `/hosting/manage/${props.serverId}/worlds`, href: `/hosting/manage/${encodeURIComponent(props.serverId)}/worlds`,
icon: WorldIcon, icon: WorldIcon,
subpages: [], 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', label: 'Access',
href: `/hosting/manage/${props.serverId}/access`, href: `/hosting/manage/${encodeURIComponent(props.serverId)}/access`,
icon: UsersIcon, icon: UsersIcon,
subpages: [], subpages: [],
}, },
@@ -1219,7 +1215,7 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
} }
const handleNewMod = () => { const handleNewMod = () => {
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }) queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
} }
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => { const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
@@ -1305,7 +1301,7 @@ const onReinstall = async (
debug('[root.vue] onReinstall: triggering immediate invalidation') debug('[root.vue] onReinstall: triggering immediate invalidation')
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] }) queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }) queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
} }
const onReinstallFailed = () => { const onReinstallFailed = () => {
@@ -1354,7 +1350,7 @@ async function invalidateAfterInstall() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['servers', 'startup', 'v1', props.serverId], queryKey: ['servers', 'startup', 'v1', props.serverId],
}), }),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }), queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] }),
]) ])
} catch (err: unknown) { } catch (err: unknown) {
console.error('Error refreshing data after installation:', err) console.error('Error refreshing data after installation:', err)
@@ -1478,7 +1474,10 @@ const copyServerDebugInfo = () => {
} }
const openInstallLog = () => { 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.history.pushState({}, '', url)
window.dispatchEvent(new PopStateEvent('popstate')) 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" :world="world"
@create="handleCreateWorld" @create="handleCreateWorld"
@edit="handleEditWorld" @edit="handleEditWorld"
@settings="handleEditWorld" @settings="handleWorldSettings"
/> />
</div> </div>
</div> </div>
@@ -27,6 +27,7 @@
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { import {
@@ -84,6 +85,7 @@ const client = injectModrinthClient()
const { serverId, server, isServerRunning } = injectModrinthServerContext() const { serverId, server, isServerRunning } = injectModrinthServerContext()
const { openServerSettings } = injectServerSettingsModal() const { openServerSettings } = injectServerSettingsModal()
const { formatMessage } = useVIntl() const { formatMessage } = useVIntl()
const router = useRouter()
const worldsQuery = useQuery({ const worldsQuery = useQuery({
queryKey: computed(() => ['servers', 'worlds', 'summary', 'v1', serverId]), 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' }) 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 ServersManagePageIndex } from './hosting/manage/index.vue'
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue' export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
export { default as ServersManageRootLayout } from './hosting/manage/root.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' export { default as ServersManageWorldsPage } from './hosting/manage/worlds.vue'
@@ -25,7 +25,7 @@ const meta = {
setup() { setup() {
const router = useRouter() const router = useRouter()
onMounted(() => { onMounted(() => {
router.replace('/hosting/manage/demo-server/content') router.replace('/hosting/manage/demo-server/worlds/demo-world')
}) })
const server = ref({ const server = ref({