mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 04:56:52 +00:00
feat: world switching page implementation
This commit is contained in:
@@ -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>
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user