From f922145230dfebcf49617c6cc8ebde834850e307 Mon Sep 17 00:00:00 2001 From: "Calum H. (IMB11)" Date: Mon, 27 Apr 2026 14:10:10 +0100 Subject: [PATCH] feat: reshuffle layout for worlds --- apps/app-frontend/src/App.vue | 49 ++++ .../src/pages/hosting/manage/Backups.vue | 2 +- .../src/pages/hosting/manage/Content.vue | 2 +- .../src/pages/hosting/manage/Index.vue | 2 +- .../src/pages/hosting/manage/World.vue | 16 ++ .../src/pages/hosting/manage/index.js | 3 +- .../providers/setup/server-install-content.ts | 9 +- apps/app-frontend/src/routes.js | 34 +++ .../composables/use-server-install-content.ts | 14 +- .../middleware/server-world-tabs.global.ts | 40 ++++ .../hosting/manage/[id]/worlds/[world_id].vue | 11 + .../[id]/{ => worlds/[world_id]}/backups.vue | 2 +- .../[id]/{ => worlds/[world_id]}/files.vue | 0 .../[world_id]/index.vue} | 2 +- .../components/servers/ServerManageStats.vue | 6 +- .../servers/ServerSettingsModal.vue | 7 + .../admonitions/ServerPanelAdmonitions.vue | 6 +- .../servers/backups/BackupCreateModal.vue | 4 +- .../servers/backups/BackupRenameModal.vue | 4 +- .../servers/backups/BackupRestoreModal.vue | 4 +- .../server-header/PanelServerActionButton.vue | 21 +- .../server-header/ServerManageHeader.vue | 125 ++++++---- .../src/composables/server-backups-queue.ts | 2 +- .../server-settings/pages/installation.vue | 11 +- .../server-settings/pages/properties.vue | 6 +- .../hosting/manage/[id]/onboarding.vue | 6 +- .../wrapped/hosting/manage/backups.vue | 4 +- .../wrapped/hosting/manage/content.vue | 17 +- .../layouts/wrapped/hosting/manage/root.vue | 85 ++++--- .../wrapped/hosting/manage/world-root.vue | 213 ++++++++++++++++++ .../layouts/wrapped/hosting/manage/worlds.vue | 12 +- packages/ui/src/layouts/wrapped/index.ts | 1 + .../servers/ServerPanelAdmonitions.stories.ts | 2 +- 33 files changed, 591 insertions(+), 131 deletions(-) create mode 100644 apps/app-frontend/src/pages/hosting/manage/World.vue create mode 100644 apps/frontend/src/middleware/server-world-tabs.global.ts create mode 100644 apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id].vue rename apps/frontend/src/pages/hosting/manage/[id]/{ => worlds/[world_id]}/backups.vue (93%) rename apps/frontend/src/pages/hosting/manage/[id]/{ => worlds/[world_id]}/files.vue (100%) rename apps/frontend/src/pages/hosting/manage/[id]/{content.vue => worlds/[world_id]/index.vue} (96%) create mode 100644 packages/ui/src/layouts/wrapped/hosting/manage/world-root.vue diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 1f39661978..cdd035ef92 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -495,6 +495,11 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({ }, }) +router.beforeEach(async (to) => { + const redirect = await resolveLegacyServerWorldTabRedirect(to) + if (redirect) return redirect +}) + router.beforeEach(() => { suspensePending = false if (routerToken) loading.end(routerToken) @@ -526,6 +531,50 @@ function onSuspensePending() { 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() { if (suspenseToken) { loading.end(suspenseToken) diff --git a/apps/app-frontend/src/pages/hosting/manage/Backups.vue b/apps/app-frontend/src/pages/hosting/manage/Backups.vue index 0ebb7bd800..b62de2ba6d 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Backups.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Backups.vue @@ -13,7 +13,7 @@ const queryClient = useQueryClient() if (worldId.value) { try { await queryClient.ensureQueryData({ - queryKey: ['backups', 'list', serverId], + queryKey: ['backups', 'list', serverId, worldId.value], queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!), staleTime: 30_000, }) diff --git a/apps/app-frontend/src/pages/hosting/manage/Content.vue b/apps/app-frontend/src/pages/hosting/manage/Content.vue index ab7d242452..1306234cc3 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Content.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Content.vue @@ -13,7 +13,7 @@ const queryClient = useQueryClient() if (worldId.value) { try { await queryClient.ensureQueryData({ - queryKey: ['content', 'list', 'v1', serverId], + queryKey: ['content', 'list', 'v1', serverId, worldId.value], queryFn: () => client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }), staleTime: 30_000, diff --git a/apps/app-frontend/src/pages/hosting/manage/Index.vue b/apps/app-frontend/src/pages/hosting/manage/Index.vue index f1e4388755..030f74a7e3 100644 --- a/apps/app-frontend/src/pages/hosting/manage/Index.vue +++ b/apps/app-frontend/src/pages/hosting/manage/Index.vue @@ -93,7 +93,7 @@ watch( breadcrumbs.setName('Server', server.name) breadcrumbs.setContext({ name: server.name, - link: `/hosting/manage/${serverId.value}/content`, + link: `/hosting/manage/${serverId.value}/worlds`, }) } }, diff --git a/apps/app-frontend/src/pages/hosting/manage/World.vue b/apps/app-frontend/src/pages/hosting/manage/World.vue new file mode 100644 index 0000000000..fe52492c50 --- /dev/null +++ b/apps/app-frontend/src/pages/hosting/manage/World.vue @@ -0,0 +1,16 @@ + + + diff --git a/apps/app-frontend/src/pages/hosting/manage/index.js b/apps/app-frontend/src/pages/hosting/manage/index.js index 18c803a863..635f13efeb 100644 --- a/apps/app-frontend/src/pages/hosting/manage/index.js +++ b/apps/app-frontend/src/pages/hosting/manage/index.js @@ -4,6 +4,7 @@ import Content from './Content.vue' import Files from './Files.vue' import Index from './Index.vue' import Overview from './Overview.vue' +import World from './World.vue' import Worlds from './Worlds.vue' -export { Access, Backups, Content, Files, Index, Overview, Worlds } +export { Access, Backups, Content, Files, Index, Overview, World, Worlds } diff --git a/apps/app-frontend/src/providers/setup/server-install-content.ts b/apps/app-frontend/src/providers/setup/server-install-content.ts index eea327e639..7b82793a85 100644 --- a/apps/app-frontend/src/providers/setup/server-install-content.ts +++ b/apps/app-frontend/src/providers/setup/server-install-content.ts @@ -252,7 +252,7 @@ export function createServerInstallContent(opts: { if (serverFlowFrom.value === 'reset-server') { return `/hosting/manage/${sid}?openSettings=installation` } - return `/hosting/manage/${sid}/content` + return getServerWorldContentPath(sid, effectiveServerWorldId.value) }) const serverBackLabel = computed(() => { if (serverFlowFrom.value === 'onboarding') return 'Back to setup' @@ -556,7 +556,7 @@ export function createServerInstallContent(opts: { if (serverFlowFrom.value === 'onboarding') { await client.archon.servers_v1.endIntro(sid) - await router.push(`/hosting/manage/${sid}/content`) + await router.push(getServerWorldContentPath(sid, wid)) return } @@ -571,6 +571,11 @@ export function createServerInstallContent(opts: { 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 { serverIdQuery, worldIdQuery, diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js index 1c70b34d93..2d133f0079 100644 --- a/apps/app-frontend/src/routes.js +++ b/apps/app-frontend/src/routes.js @@ -65,6 +65,40 @@ export default new createRouter({ 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', name: 'ServerManageFiles', diff --git a/apps/frontend/src/composables/use-server-install-content.ts b/apps/frontend/src/composables/use-server-install-content.ts index e3c579d684..3c27837c9c 100644 --- a/apps/frontend/src/composables/use-server-install-content.ts +++ b/apps/frontend/src/composables/use-server-install-content.ts @@ -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({ queryKey: contentQueryKey, queryFn: () => @@ -659,7 +662,7 @@ export function useServerInstallContent({ if (fromContext.value === 'onboarding') { await client.archon.servers_v1.endIntro(currentServerId.value) queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] }) - navigateTo(`/hosting/manage/${currentServerId.value}/content`) + navigateTo(getServerWorldContentPath(currentServerId.value, currentWorldId.value ?? null)) } else { 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 === 'reset-server') 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(() => { if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup) if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset) diff --git a/apps/frontend/src/middleware/server-world-tabs.global.ts b/apps/frontend/src/middleware/server-world-tabs.global.ts new file mode 100644 index 0000000000..2a740b7dea --- /dev/null +++ b/apps/frontend/src/middleware/server-world-tabs.global.ts @@ -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 }) +}) diff --git a/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id].vue b/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id].vue new file mode 100644 index 0000000000..357dc14f4a --- /dev/null +++ b/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id].vue @@ -0,0 +1,11 @@ + + + diff --git a/apps/frontend/src/pages/hosting/manage/[id]/backups.vue b/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/backups.vue similarity index 93% rename from apps/frontend/src/pages/hosting/manage/[id]/backups.vue rename to apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/backups.vue index 467662653f..ab36852f6a 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/backups.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/backups.vue @@ -14,7 +14,7 @@ const flags = useFeatureFlags() if (worldId.value) { try { await queryClient.ensureQueryData({ - queryKey: ['backups', 'list', serverId], + queryKey: ['backups', 'list', serverId, worldId.value], queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!), staleTime: 30_000, }) diff --git a/apps/frontend/src/pages/hosting/manage/[id]/files.vue b/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/files.vue similarity index 100% rename from apps/frontend/src/pages/hosting/manage/[id]/files.vue rename to apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/files.vue diff --git a/apps/frontend/src/pages/hosting/manage/[id]/content.vue b/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/index.vue similarity index 96% rename from apps/frontend/src/pages/hosting/manage/[id]/content.vue rename to apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/index.vue index 8530076bff..e5e8e9e517 100644 --- a/apps/frontend/src/pages/hosting/manage/[id]/content.vue +++ b/apps/frontend/src/pages/hosting/manage/[id]/worlds/[world_id]/index.vue @@ -38,7 +38,7 @@ const contentWorldId = await getContentWorldId() if (contentWorldId) { try { const content = await queryClient.ensureQueryData({ - queryKey: ['content', 'list', 'v1', serverId], + queryKey: ['content', 'list', 'v1', serverId, contentWorldId], queryFn: () => client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }), staleTime: 30_000, diff --git a/packages/ui/src/components/servers/ServerManageStats.vue b/packages/ui/src/components/servers/ServerManageStats.vue index ef3f54d31e..47247646c6 100644 --- a/packages/ui/src/components/servers/ServerManageStats.vue +++ b/packages/ui/src/components/servers/ServerManageStats.vue @@ -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 | 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) { diff --git a/packages/ui/src/components/servers/ServerSettingsModal.vue b/packages/ui/src/components/servers/ServerSettingsModal.vue index d63706a963..31ead087ee 100644 --- a/packages/ui/src/components/servers/ServerSettingsModal.vue +++ b/packages/ui/src/components/servers/ServerSettingsModal.vue @@ -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!), diff --git a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue index 87e9a12746..408d97930e 100644 --- a/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue +++ b/packages/ui/src/components/servers/admonitions/ServerPanelAdmonitions.vue @@ -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( () => diff --git a/packages/ui/src/components/servers/backups/BackupCreateModal.vue b/packages/ui/src/components/servers/backups/BackupCreateModal.vue index 363f428cc7..4be1ce629b 100644 --- a/packages/ui/src/components/servers/backups/BackupCreateModal.vue +++ b/packages/ui/src/components/servers/backups/BackupCreateModal.vue @@ -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>() diff --git a/packages/ui/src/components/servers/backups/BackupRenameModal.vue b/packages/ui/src/components/servers/backups/BackupRenameModal.vue index 54d090990a..8247bce6c8 100644 --- a/packages/ui/src/components/servers/backups/BackupRenameModal.vue +++ b/packages/ui/src/components/servers/backups/BackupRenameModal.vue @@ -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>() diff --git a/packages/ui/src/components/servers/backups/BackupRestoreModal.vue b/packages/ui/src/components/servers/backups/BackupRestoreModal.vue index 64dc1de2c6..52b77cc90f 100644 --- a/packages/ui/src/components/servers/backups/BackupRestoreModal.vue +++ b/packages/ui/src/components/servers/backups/BackupRestoreModal.vue @@ -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>() diff --git a/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue index 52d47effe2..b895aa1432 100644 --- a/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue +++ b/packages/ui/src/components/servers/server-header/PanelServerActionButton.vue @@ -1,14 +1,14 @@