Files
modrinth/packages/ui/src/layouts/wrapped/hosting/manage/worlds.vue
T
2026-06-26 17:09:21 +01:00

283 lines
7.3 KiB
Vue

<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>