feat: world switching page implementation

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:21 +01:00
parent 1067b02830
commit a94e15e278
8 changed files with 719 additions and 51 deletions
@@ -0,0 +1,311 @@
<template>
<article
class="flex min-h-[19.75rem] w-full flex-col overflow-hidden rounded-2xl border border-solid border-surface-5 bg-bg-raised shadow-xl"
:class="(world as any)?.active ? '!border-brand' : ''"
>
<template v-if="world.type === 'empty'">
<div class="flex flex-1 flex-col items-center pt-[3.125rem] text-center">
<svg
class="size-[5.6645rem]"
viewBox="0 0 91 91"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<rect
x="22.4356"
y="0.629395"
width="71"
height="71"
rx="19.5"
transform="rotate(17.8856 22.4356 0.629395)"
stroke="#42444A"
/>
<rect
x="4.36354"
y="16.5661"
width="71"
height="71"
rx="19.5"
transform="rotate(-8.79487 4.36354 16.5661)"
fill="#34363C"
/>
<rect
x="4.36354"
y="16.5661"
width="71"
height="71"
rx="19.5"
transform="rotate(-8.79487 4.36354 16.5661)"
stroke="#42444A"
/>
<g :clip-path="`url(#${emptyCardClipId})`">
<path
d="M47.4227 62.6919C56.5193 61.2846 62.7525 52.7695 61.3452 43.673C59.9378 34.5764 51.4227 28.3432 42.3262 29.7505C33.2297 31.1579 26.9964 39.673 28.4038 48.7695C29.8111 57.866 38.3262 64.0993 47.4227 62.6919Z"
stroke="#B0BAC5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M42.3246 29.7502C38.7824 34.8453 37.3358 41.1078 38.2846 47.2402C39.2334 53.3727 42.5048 58.9052 47.4212 62.6916C50.9634 57.5965 52.41 51.3341 51.4612 45.2016C50.5124 39.0691 47.241 33.5366 42.3246 29.7502Z"
stroke="#B0BAC5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M28.4023 48.7695L61.3437 43.673"
stroke="#B0BAC5"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<defs>
<clipPath :id="emptyCardClipId">
<rect
width="40"
height="40"
fill="white"
transform="translate(22.0508 29.5137) rotate(-8.79487)"
/>
</clipPath>
</defs>
</svg>
<div class="mt-6 flex flex-col gap-1">
<h2 class="m-0 text-2xl font-extrabold leading-8 text-contrast">{{ world.name }}</h2>
<p class="m-0 text-base font-medium leading-6 text-secondary">
{{ formatMessage(messages.newWorldInstance) }}
</p>
</div>
</div>
<div class="px-5 pb-4">
<ButtonStyled color="brand">
<button class="w-full" @click="emit('create', world.id)">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createWorld) }}
</button>
</ButtonStyled>
</div>
</template>
<template v-else>
<header class="flex min-h-[5.75rem] flex-col justify-center gap-1 px-5 py-4">
<div class="flex min-w-0 items-center justify-between gap-3">
<span
class="m-0 truncate text-xl font-semibold text-contrast"
ref="worldNameRef"
v-tooltip="truncatedTooltip(worldNameRef, world.name)"
>{{ world.name }}</span
>
<span
v-if="world.active"
class="shrink-0 rounded-full bg-brand-highlight border border-brand border-solid px-2.5 py-1 text-green"
>
{{ formatMessage(messages.active) }}
</span>
</div>
<div class="flex min-w-0 text-md items-center gap-2 text-secondary">
{{ world.gameVersion }} <BulletDivider /> {{ world.loaderLabel }}
</div>
</header>
<div
class="flex flex-1 flex-col gap-3 border-0 border-y bg-surface-2 border-solid border-surface-5 my-auto px-5 py-4"
>
<div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,70%)] items-center gap-4 text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
>
<span>{{ formatMessage(commonMessages.modpackLabel) }}</span>
<div
v-if="world.linkedModpack"
class="flex min-w-0 items-center gap-2 text-right font-semibold text-contrast"
>
<AutoLink :to="world.linkedModpack.link" class="shrink-0">
<Avatar
:src="world.linkedModpack.iconUrl"
:alt="world.linkedModpack.name"
:tint-by="world.linkedModpack.name"
size="1.25rem"
no-shadow
/>
</AutoLink>
<AutoLink
:to="world.linkedModpack.link"
class="min-w-0 font-semibold text-contrast"
:class="world.linkedModpack.link ? 'hover:underline' : ''"
>
<span
ref="modpackNameRef"
v-tooltip="truncatedTooltip(modpackNameRef, world.linkedModpack.name)"
class="block truncate"
>
{{ world.linkedModpack.name }}
</span>
</AutoLink>
</div>
<span v-else class="font-semibold text-contrast">{{ formatMessage(messages.none) }}</span>
</div>
<div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,25%)] items-center gap-4 text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
>
<span>{{ formatMessage(messages.installedContent) }}</span>
<span class="font-semibold text-contrast">{{ installedContentLabel }}</span>
</div>
<div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base font-medium text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
>
<span>{{ formatMessage(messages.lastActive) }}</span>
<span class="font-semibold text-contrast">{{ lastActiveLabel }}</span>
</div>
<div
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base font-medium text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
>
<span>{{ formatMessage(messages.created) }}</span>
<span class="font-semibold text-contrast">{{ createdLabel }}</span>
</div>
</div>
<footer class="flex items-center justify-between gap-3 px-5 py-4">
<ButtonStyled>
<button @click="emit('edit', world.id)" class="!shadow-none">
<PencilIcon aria-hidden="true" />
{{ formatMessage(messages.editWorld) }}
</button>
</ButtonStyled>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.worldSettings)"
@click="emit('settings', world.id)"
class="!shadow-none"
>
<Settings2Icon aria-hidden="true" />
</button>
</ButtonStyled>
</footer>
</template>
</article>
</template>
<script setup lang="ts">
import { PencilIcon, PlusIcon, Settings2Icon } from '@modrinth/assets'
import { computed, useId, useTemplateRef } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { useFormatDateTime, useRelativeTime } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { truncatedTooltip } from '#ui/utils'
import { commonMessages } from '#ui/utils/common-messages'
const messages = defineMessages({
newWorldInstance: {
id: 'servers.manage.worlds.card.empty-description',
defaultMessage: 'New world instance',
},
createWorld: {
id: 'servers.manage.worlds.card.create',
defaultMessage: 'Create world',
},
active: {
id: 'servers.manage.worlds.card.active',
defaultMessage: 'Active',
},
none: {
id: 'servers.manage.worlds.card.none',
defaultMessage: 'None',
},
installedContent: {
id: 'servers.manage.worlds.card.installed-content',
defaultMessage: 'Installed content',
},
lastActive: {
id: 'servers.manage.worlds.card.last-active',
defaultMessage: 'Last active',
},
created: {
id: 'servers.manage.worlds.card.created',
defaultMessage: 'Created',
},
editWorld: {
id: 'servers.manage.worlds.card.edit',
defaultMessage: 'Edit world',
},
worldSettings: {
id: 'servers.manage.worlds.card.settings',
defaultMessage: 'World settings',
},
notTrackedYet: {
id: 'servers.manage.worlds.card.not-tracked-yet',
defaultMessage: 'Not tracked yet',
},
})
type LinkedModpack = {
name: string
iconUrl: string | null
link: string | null
}
type UsedWorld = {
type: 'world'
id: string
name: string
active: boolean
gameVersion: string | null
loaderLabel: string | null
linkedModpack: LinkedModpack | null
installedContentCount: number | null
lastActiveAt: string | null
createdAt: string | null
}
type EmptyWorld = {
type: 'empty'
id: string
name: string
}
const props = defineProps<{
world: UsedWorld | EmptyWorld
}>()
const emit = defineEmits<{
create: [slotId: string]
edit: [worldId: string]
settings: [worldId: string]
}>()
const formatRelativeTime = useRelativeTime()
const formatDate = useFormatDateTime({ dateStyle: 'medium' })
const { formatMessage } = useVIntl()
const emptyCardClipId = useId()
const modpackNameRef = useTemplateRef<HTMLElement>('modpackNameRef')
const worldNameRef = useTemplateRef<HTMLElement>('worldNameRef')
const installedContentLabel = computed(() => {
if (props.world.type === 'empty') return ''
return props.world.installedContentCount === null
? formatMessage(commonMessages.unknownLabel)
: String(props.world.installedContentCount)
})
const lastActiveLabel = computed(() => {
if (props.world.type === 'empty') return ''
return props.world.lastActiveAt
? formatRelativeTime(props.world.lastActiveAt)
: formatMessage(messages.notTrackedYet)
})
const createdLabel = computed(() => {
if (props.world.type === 'empty') return ''
return props.world.createdAt
? formatDate(props.world.createdAt)
: formatMessage(commonMessages.unknownLabel)
})
</script>
@@ -22,8 +22,8 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="We're getting your server ready"
description="Your server's hardware is being prepared and will be available shortly!"
:title="formatMessage(messages.serverPreparingTitle)"
:description="formatMessage(messages.serverPreparingDescription)"
:icon="TransferIcon"
icon-color="blue"
:action="generalErrorAction"
@@ -34,8 +34,8 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Server upgrading"
description="Your server's hardware is currently being upgraded and will be back online shortly!"
:title="formatMessage(messages.serverUpgradingTitle)"
:description="formatMessage(messages.serverUpgradingDescription)"
:icon="TransferIcon"
icon-color="blue"
:action="generalErrorAction"
@@ -46,7 +46,7 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Server suspended"
:title="formatMessage(messages.serverSuspendedTitle)"
:description="suspendedDescription"
:icon="LockIcon"
icon-color="orange"
@@ -58,8 +58,8 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="An error occured."
description="Please contact Modrinth Support."
:title="formatMessage(messages.generalErrorTitle)"
:description="formatMessage(messages.contactSupportDescription)"
:icon="TransferIcon"
icon-color="orange"
:error-details="generalErrorDetails"
@@ -71,7 +71,7 @@
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Server Node Unavailable"
:title="formatMessage(messages.nodeUnavailableTitle)"
:icon="TriangleAlertIcon"
icon-color="red"
:action="nodeUnavailableAction"
@@ -80,16 +80,13 @@
<template #description>
<div class="text-md space-y-4">
<p class="leading-[170%] text-secondary">
Your server's node, where your Modrinth Server is physically hosted, is not accessible
at the moment. We are working to resolve the issue as quickly as possible.
{{ formatMessage(messages.nodeUnavailableDescription) }}
</p>
<p class="leading-[170%] text-secondary">
Your data is safe and will not be lost, and your server will be back online as soon as
the issue is resolved.
{{ formatMessage(messages.nodeUnavailableDataDescription) }}
</p>
<p class="leading-[170%] text-secondary">
If reloading does not work initially, please contact Modrinth Support via the chat
bubble in the bottom right corner and we'll be happy to help.
{{ formatMessage(messages.nodeUnavailableSupportDescription) }}
</p>
</div>
</template>
@@ -132,7 +129,7 @@
>
<ButtonStyled circular size="large">
<button
v-tooltip="showSettingsHint ? undefined : 'Server settings'"
v-tooltip="showSettingsHint ? undefined : formatMessage(messages.serverSettings)"
@click="
() => {
openServerSettingsModal()
@@ -199,70 +196,59 @@
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
<div class="flex gap-2 text-2xl font-bold">{{ errorTitleLabel }}</div>
</div>
<div
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
class="font-normal"
>
<div v-if="errorTitle === 'installation'" class="font-normal">
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
{{ formatMessage(messages.installInvalidVersionDescription) }}
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Hosting supports it.
{{ formatMessage(messages.installRecentMinecraftVersionNotice) }}
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
{{ formatMessage(messages.installModpackCompatibilityNotice) }}
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
{{ formatMessage(messages.installChangeLoaderNotice) }}
</li>
<li>
If you're stuck, please contact Modrinth Support with the information below:
{{ formatMessage(messages.installSupportNotice) }}
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
{{ formatMessage(messages.copyDebugInfo) }}
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret — try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
{{ formatMessage(messages.installInternalErrorDescription) }}
</div>
<div
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
>
An error occurred while installing your server because Modrinth Hosting does not
support the version of Minecraft or the loader you specified. Try reinstalling
your server with a different version or loader, and if the problem persists,
please contact Modrinth Support with your server's debug information.
{{ formatMessage(messages.installUnsupportedVersionDescription) }}
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<div class="mt-2 flex flex-col gap-4 sm:flex-row">
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
<button @click="openInstallLog">
<FileIcon />
{{ formatMessage(messages.openInstallationLog) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
{{ formatMessage(messages.copyDebugInfo) }}
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
@@ -271,7 +257,7 @@
@click="openServerSettingsModal('installation')"
>
<RightArrowIcon />
Change Loader
{{ formatMessage(messages.changeLoader) }}
</button>
</ButtonStyled>
</div>
@@ -295,7 +281,7 @@
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
{{ formatMessage(messages.websocketError) }}
</div>
<div
@@ -304,7 +290,7 @@
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
>
<LoaderCircleIcon class="h-5 w-5 animate-spin" />
Hang on, we're reconnecting to your server.
{{ formatMessage(messages.websocketReconnecting) }}
</div>
<ServerPanelAdmonitions
@@ -322,7 +308,9 @@
v-if="showAdvancedDebugInfo"
class="relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<h2 class="m-0 text-lg font-extrabold text-contrast">
{{ formatMessage(messages.serverDataTitle) }}
</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
safeStringify(serverData)
}}</pre>
@@ -361,6 +349,7 @@ import {
TransferIcon,
TriangleAlertIcon,
UsersIcon,
WorldIcon,
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
@@ -493,6 +482,33 @@ const settingsHintMessages = defineMessages({
},
})
const messages = defineMessages({
serverSettings: {
id: 'servers.manage.server-settings',
defaultMessage: 'Server settings',
},
overviewNav: {
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',
},
})
// disabled, keeping the animation logic cos it's really nice and we might want to re-enable in future
const DISABLE_LOADING_ANIM = true
@@ -815,25 +831,31 @@ watch(serverData, (data) => {
const navLinks = computed<Tab[]>(() => [
{
label: 'Overview',
label: formatMessage(messages.overviewNav),
href: `/hosting/manage/${props.serverId}`,
icon: LayoutTemplateIcon,
subpages: [],
},
{
label: 'Content',
label: formatMessage(messages.contentNav),
href: `/hosting/manage/${props.serverId}/content`,
icon: BoxesIcon,
subpages: ['mods', 'datapacks'],
},
{
label: 'Files',
label: formatMessage(messages.worldsNav),
href: `/hosting/manage/${props.serverId}/worlds`,
icon: WorldIcon,
subpages: [],
},
{
label: formatMessage(messages.filesNav),
href: `/hosting/manage/${props.serverId}/files`,
icon: FolderOpenIcon,
subpages: [],
},
{
label: 'Backups',
label: formatMessage(messages.backupsNav),
href: `/hosting/manage/${props.serverId}/backups`,
icon: DatabaseBackupIcon,
subpages: [],
@@ -0,0 +1,288 @@
<template>
<div class="flex flex-col gap-4">
<div
v-if="worldsPending"
class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,20.25rem),1fr))] gap-6"
>
<div
v-for="slot in WORLD_SLOT_COUNT"
:key="`world-card-skeleton-${slot}`"
class="min-h-[19.75rem] animate-pulse rounded-2xl border border-solid border-surface-5 bg-bg-raised shadow-xl"
/>
</div>
<div
v-else
class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,20.25rem),1fr))] gap-6"
>
<WorldCard
v-for="world in worldSlots"
:key="world.id"
:world="world"
@create="handleCreateWorld"
@edit="handleEditWorld"
@settings="handleEditWorld"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectServerSettingsModal,
} from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
import WorldCard from '#ui/components/servers/WorldCard.vue'
const messages = defineMessages({
worldSlotName: {
id: 'servers.manage.worlds.slot-name',
defaultMessage: 'World #{index}',
},
})
type LinkedModpack = {
name: string
iconUrl: string | null
link: string | null
}
type WorldSlot =
| {
type: 'world'
id: string
name: string
active: boolean
gameVersion: string | null
loaderLabel: string | null
linkedModpack: LinkedModpack | null
installedContentCount: number | null
lastActiveAt: string | null
createdAt: string | null
}
| {
type: 'empty'
id: string
name: string
}
type ContentSummary = {
gameVersion: string | null
loader: string | null
loaderVersion: string | null
linkedModpack: LinkedModpack | null
installedContentCount: number | null
}
const WORLD_SLOT_COUNT = 3
const client = injectModrinthClient()
const { serverId, server, isServerRunning } = injectModrinthServerContext()
const { openServerSettings } = injectServerSettingsModal()
const { formatMessage } = useVIntl()
const worldsQuery = useQuery({
queryKey: computed(() => ['servers', 'worlds', 'summary', 'v1', serverId]),
queryFn: loadWorldSlots,
staleTime: 30_000,
})
const worldsPending = computed(() => worldsQuery.isLoading.value && !worldsQuery.data.value)
const worldSlots = computed(() => worldsQuery.data.value ?? [])
async function loadWorldSlots(): Promise<WorldSlot[]> {
try {
const serverFull = await client.archon.servers_v1.get(serverId)
const slots = await Promise.all(
serverFull.worlds.map(async (world, index) => {
const content = await loadContentSummary(world, index)
return toWorldSlot(world, content)
}),
)
return padWorldSlots(slots)
} catch {
return createDummyWorldSlots()
}
}
async function loadContentSummary(
world: Archon.Servers.v1.WorldFull,
index: number,
): Promise<ContentSummary> {
try {
const content = await client.archon.content_v1.getAddons(serverId, world.id, {
addons: true,
updates: false,
})
return {
gameVersion: content.game_version ?? world.content?.game_version ?? null,
loader: content.modloader ?? world.content?.modloader ?? null,
loaderVersion: content.modloader_version ?? world.content?.modloader_version ?? null,
linkedModpack: getLinkedModpack(content.modpack),
installedContentCount: content.addons?.length ?? 0,
}
} catch {
return createDummyContentSummary(world, index)
}
}
function toWorldSlot(
world: Archon.Servers.v1.WorldFull,
content: ContentSummary,
): WorldSlot {
return {
type: 'world',
id: world.id,
name: world.name,
active: world.is_active,
gameVersion: content.gameVersion,
loaderLabel: getLoaderLabel(content.loader, content.loaderVersion),
linkedModpack: content.linkedModpack,
installedContentCount: content.installedContentCount,
lastActiveAt: getLatestKnownActivity(world),
createdAt: world.created_at,
}
}
function padWorldSlots(slots: WorldSlot[]): WorldSlot[] {
const padded = [...slots]
for (let i = padded.length; i < WORLD_SLOT_COUNT; i++) {
padded.push({
type: 'empty',
id: `empty-world-slot-${i + 1}`,
name: formatMessage(messages.worldSlotName, { index: i + 1 }),
})
}
return padded
}
function getLinkedModpack(modpack: Archon.Content.v1.ModpackFields | null): LinkedModpack | null {
if (!modpack) return null
const name =
modpack.title ??
(modpack.spec.platform === 'local_file' ? modpack.spec.name : modpack.spec.project_id)
return {
name,
iconUrl: modpack.icon_url ?? null,
link: modpack.spec.platform === 'modrinth' ? `/project/${modpack.spec.project_id}` : null,
}
}
function getLoaderLabel(loader: string | null, loaderVersion: string | null): string | null {
if (!loader) return null
const normalizedLoader = loader.toLowerCase()
return [formatLoaderLabel(normalizedLoader), loaderVersion].filter(Boolean).join(' ')
}
function getLatestKnownActivity(world: Archon.Servers.v1.WorldFull): string | null {
const latestBackup = latestDate(world.backups.map((backup) => backup.created_at))
if (latestBackup) return latestBackup
if (world.is_active && isServerRunning.value) return new Date().toISOString()
return world.created_at ?? null
}
function latestDate(dates: string[]): string | null {
let latest = 0
let latestIso: string | null = null
for (const date of dates) {
const timestamp = new Date(date).getTime()
if (!Number.isFinite(timestamp) || timestamp <= latest) continue
latest = timestamp
latestIso = date
}
return latestIso
}
function createDummyContentSummary(
world: Archon.Servers.v1.WorldFull,
index: number,
): ContentSummary {
const gameVersion = world.content?.game_version ?? server.value?.mc_version ?? '1.20.4'
const loader = world.content?.modloader ?? server.value?.loader?.toLowerCase() ?? 'fabric'
const loaderVersion = world.content?.modloader_version ?? server.value?.loader_version ?? '0.16.6'
if (index === 0) {
return {
gameVersion,
loader,
loaderVersion,
linkedModpack: {
name: 'Cobblemon Official',
iconUrl: null,
link: null,
},
installedContentCount: 47,
}
}
return {
gameVersion,
loader,
loaderVersion,
linkedModpack: null,
installedContentCount: 13,
}
}
function createDummyWorldSlots(): WorldSlot[] {
const now = Date.now()
const twoHoursAgo = new Date(now - 2 * 60 * 60 * 1000).toISOString()
const sixteenDaysAgo = new Date(now - 16 * 24 * 60 * 60 * 1000).toISOString()
const createdAt = new Date(now - 197 * 24 * 60 * 60 * 1000).toISOString()
return [
{
type: 'world',
id: 'dummy-world-1',
name: 'Cobbletown',
active: true,
gameVersion: '1.20.4',
loaderLabel: 'Fabric 0.16.6',
linkedModpack: {
name: 'Cobblemon Official',
iconUrl: null,
link: null,
},
installedContentCount: 47,
lastActiveAt: twoHoursAgo,
createdAt,
},
{
type: 'world',
id: 'dummy-world-2',
name: 'SMP Season 4',
active: false,
gameVersion: '1.20.4',
loaderLabel: 'Fabric 0.16.6',
linkedModpack: null,
installedContentCount: 13,
lastActiveAt: sixteenDaysAgo,
createdAt,
},
{
type: 'empty',
id: 'empty-world-slot-3',
name: formatMessage(messages.worldSlotName, { index: 3 }),
},
]
}
function handleEditWorld() {
openServerSettings({ tabId: 'installation' })
}
function handleCreateWorld() {
openServerSettings({ tabId: 'installation' })
}
</script>
+1
View File
@@ -6,3 +6,4 @@ 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 ServersManageWorldsPage } from './hosting/manage/worlds.vue'