mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: reshuffle layout for worlds
This commit is contained in:
@@ -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<typeof buildChartOptions> | 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) {
|
||||
|
||||
@@ -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!),
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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<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({
|
||||
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<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) {
|
||||
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<InstanceType<typeof NewModal>>()
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<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">
|
||||
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<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">
|
||||
<UpdatedIcon />
|
||||
<span>{{ primaryActionText }}</span>
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
<JoinedButtons
|
||||
color="red"
|
||||
size="large"
|
||||
:size="size"
|
||||
:actions="stopSplitActions"
|
||||
:primary-disabled="!canTakeAction"
|
||||
:dropdown-disabled="!canKill"
|
||||
@@ -34,7 +34,7 @@
|
||||
<template v-else-if="isStopping">
|
||||
<JoinedButtons
|
||||
color="red"
|
||||
size="large"
|
||||
:size="size"
|
||||
:actions="stopSplitActions"
|
||||
:primary-disabled="true"
|
||||
:dropdown-disabled="!canKill"
|
||||
@@ -49,10 +49,10 @@
|
||||
</template>
|
||||
|
||||
<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">
|
||||
<PlayIcon />
|
||||
<span>{{ primaryActionText }}</span>
|
||||
<span>{{ startActionText }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -77,9 +77,13 @@ import { useServerPowerAction } from './use-server-power-action'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
startLabel?: string
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
size: 'large',
|
||||
startLabel: 'Start',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -97,6 +101,11 @@ const {
|
||||
disabled: computed(() => props.disabled),
|
||||
})
|
||||
|
||||
const size = computed(() => props.size)
|
||||
const startActionText = computed(() =>
|
||||
primaryActionText.value === 'Start' ? props.startLabel : primaryActionText.value,
|
||||
)
|
||||
|
||||
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
|
||||
{
|
||||
id: 'stop',
|
||||
|
||||
@@ -18,46 +18,28 @@
|
||||
<SettingsIcon />
|
||||
Configuring server...
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium">
|
||||
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" />
|
||||
{{ formatLoaderLabel(props.server.loader) }} {{ props.server.mc_version }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
props.server?.loader &&
|
||||
props.server?.net?.domain &&
|
||||
!userPreferences.hideSubdomainLabel
|
||||
"
|
||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="props.server?.net?.domain && !userPreferences.hideSubdomainLabel"
|
||||
v-tooltip="'Copy server address'"
|
||||
class="flex cursor-pointer items-center gap-2 font-medium hover:underline text-nowrap"
|
||||
@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 v-else class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<template v-for="(item, index) in headerStats" :key="item.id">
|
||||
<div v-if="index > 0" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
|
||||
<button
|
||||
v-if="item.copyable"
|
||||
v-tooltip="'Copy server address'"
|
||||
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"
|
||||
type="button"
|
||||
@click="copyServerAddress"
|
||||
>
|
||||
<component :is="item.icon" class="flex size-5 shrink-0" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</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" />
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="showProject && headerStats.length > 0" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
|
||||
<div
|
||||
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
|
||||
<Avatar
|
||||
@@ -81,8 +63,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } 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 type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components'
|
||||
@@ -102,12 +85,20 @@ type ServerProjectSummary = {
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
type HeaderStat = {
|
||||
id: string
|
||||
label: string
|
||||
icon: Component
|
||||
copyable?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
server: Archon.Servers.v0.Server | null | undefined
|
||||
serverImage?: string | null
|
||||
serverProject?: ServerProjectSummary | null
|
||||
serverProjectLink?: string
|
||||
activeWorldName?: string | null
|
||||
uptimeSeconds?: number
|
||||
showUptime?: boolean
|
||||
backHref?: string
|
||||
@@ -118,6 +109,7 @@ const props = withDefaults(
|
||||
serverImage: null,
|
||||
serverProject: null,
|
||||
serverProjectLink: '',
|
||||
activeWorldName: null,
|
||||
uptimeSeconds: 0,
|
||||
showUptime: true,
|
||||
backHref: '/hosting/manage',
|
||||
@@ -158,6 +150,57 @@ const formattedUptime = computed(() => {
|
||||
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 serverProjectLink = computed(() => {
|
||||
@@ -171,8 +214,8 @@ const serverProjectLink = computed(() => {
|
||||
})
|
||||
|
||||
function copyServerAddress() {
|
||||
if (!props.server?.net?.domain) return
|
||||
navigator.clipboard.writeText(`${props.server.net.domain}.modrinth.gg`)
|
||||
if (!serverAddress.value) return
|
||||
navigator.clipboard.writeText(serverAddress.value)
|
||||
addNotification({
|
||||
title: 'Server address copied',
|
||||
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 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 lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
|
||||
|
||||
@@ -227,6 +227,7 @@ const resetServerDisabledTooltip = computed(() => {
|
||||
})
|
||||
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
|
||||
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
|
||||
const contentListQueryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
|
||||
|
||||
function showResetServerModal() {
|
||||
if (resetServerDisabled.value) return
|
||||
@@ -237,13 +238,13 @@ async function invalidateServerState() {
|
||||
debug('invalidateServerState: starting')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: contentListQueryKey.value }),
|
||||
])
|
||||
debug('invalidateServerState: complete')
|
||||
}
|
||||
|
||||
const addonsQuery = useQuery({
|
||||
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
|
||||
queryKey: contentListQueryKey,
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
@@ -670,7 +671,7 @@ provideInstallationSettings({
|
||||
const previousData = addonsQuery.data.value
|
||||
if (previousData) {
|
||||
debug('unlinkModpack: optimistically removing modpack from cache')
|
||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], {
|
||||
queryClient.setQueryData(contentListQueryKey.value, {
|
||||
...previousData,
|
||||
modpack: null,
|
||||
})
|
||||
@@ -682,7 +683,7 @@ provideInstallationSettings({
|
||||
} catch (err) {
|
||||
debug('unlinkModpack: failed, reverting cache', err)
|
||||
if (previousData) {
|
||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData)
|
||||
queryClient.setQueryData(contentListQueryKey.value, previousData)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
@@ -695,7 +696,7 @@ provideInstallationSettings({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
queryKey: contentListQueryKey.value,
|
||||
}),
|
||||
])
|
||||
debug('unlinkModpack: invalidation complete')
|
||||
|
||||
@@ -317,8 +317,10 @@ const { canUseAdvancedSettings, canUsePowerActions, permissionDeniedMessage } =
|
||||
const advancedActionTooltip = computed(() =>
|
||||
canUseAdvancedSettings.value ? undefined : permissionDeniedMessage.value,
|
||||
)
|
||||
const filesTabLink = computed(
|
||||
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
|
||||
const filesTabLink = computed(() =>
|
||||
worldId.value
|
||||
? `/hosting/manage/${encodeURIComponent(serverId)}/worlds/${encodeURIComponent(worldId.value)}/files?path=/&editing=server.properties`
|
||||
: `/hosting/manage/${encodeURIComponent(serverId)}/worlds`,
|
||||
)
|
||||
const serverSettings = injectServerSettings(null)
|
||||
|
||||
|
||||
@@ -272,7 +272,11 @@ async function finalizeSetup() {
|
||||
client.archon.servers_v1.endIntro(serverId).then(() => {
|
||||
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 */
|
||||
|
||||
@@ -362,7 +362,7 @@ const filterPillOptions = computed<FilterPillOption[]>(() => [
|
||||
])
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const { server, worldId, busyReasons } = injectModrinthServerContext()
|
||||
const { server, serverId, worldId, busyReasons } = injectModrinthServerContext()
|
||||
|
||||
const props = defineProps<{
|
||||
isServerRunning: boolean
|
||||
@@ -371,9 +371,7 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
const BACKUP_HIGHLIGHT_DURATION_MS = 5_000
|
||||
|
||||
defineEmits(['onDownload'])
|
||||
|
||||
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 { useIntervalFn } from '@vueuse/core'
|
||||
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 { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||
@@ -115,7 +115,7 @@ const messages = defineMessages({
|
||||
})
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
||||
const { server, serverId, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
||||
injectModrinthServerContext()
|
||||
const contentUploadSession = useUploadSessionUpload({
|
||||
client,
|
||||
@@ -127,10 +127,8 @@ const contentUploadSession = useUploadSessionUpload({
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||
const { canSetup, permissionDeniedMessage } = useServerPermissions()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const serverId = route.params.id as string
|
||||
|
||||
const type = computed(() => {
|
||||
const loader = server.value?.loader?.toLowerCase()
|
||||
@@ -139,8 +137,15 @@ const type = computed(() => {
|
||||
return 'mod'
|
||||
})
|
||||
|
||||
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
|
||||
const modpackContentQueryKey = computed(() => ['content', 'list', 'v1', serverId, 'modpack'])
|
||||
const queryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
|
||||
const modpackContentQueryKey = computed(() => [
|
||||
'content',
|
||||
'list',
|
||||
'v1',
|
||||
serverId,
|
||||
worldId.value,
|
||||
'modpack',
|
||||
])
|
||||
|
||||
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
|
||||
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
|
||||
|
||||
@@ -92,7 +92,17 @@
|
||||
</template>
|
||||
</ErrorInformationCard>
|
||||
</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
|
||||
v-else-if="serverData"
|
||||
data-pyro-server-manager-root
|
||||
@@ -115,6 +125,7 @@
|
||||
:server="serverData"
|
||||
:server-image="serverImage"
|
||||
:server-project="serverProject"
|
||||
:active-world-name="activeWorldName"
|
||||
:uptime-seconds="showUptime ? uptimeSeconds : undefined"
|
||||
>
|
||||
<template #actions>
|
||||
@@ -305,8 +316,8 @@
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="showAdvancedDebugInfo"
|
||||
class="relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
|
||||
v-if="showAdvancedDebugInfo && !isWorldDetailRoute"
|
||||
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">
|
||||
{{ formatMessage(messages.serverDataTitle) }}
|
||||
@@ -334,12 +345,9 @@
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { getNodeWebSocketUrl, ModrinthApiError } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DatabaseBackupIcon,
|
||||
FileIcon,
|
||||
FolderOpenIcon,
|
||||
IssuesIcon,
|
||||
LayoutTemplateIcon,
|
||||
LoaderCircleIcon,
|
||||
@@ -624,22 +632,10 @@ const messages = defineMessages({
|
||||
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',
|
||||
},
|
||||
errorDismissingNotice: {
|
||||
id: 'servers.manage.notice.dismiss-error',
|
||||
defaultMessage: 'Error dismissing notice',
|
||||
@@ -773,11 +769,29 @@ const { data: serverFull } = useQuery({
|
||||
})
|
||||
|
||||
const worldId = computed(() => {
|
||||
const routeWorldId = getRouteParam(route.params.world_id)
|
||||
if (routeWorldId) return routeWorldId
|
||||
if (!serverFull.value) return null
|
||||
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
|
||||
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(
|
||||
computed(() => props.serverId),
|
||||
worldId,
|
||||
@@ -1032,37 +1046,19 @@ watch(serverData, (data) => {
|
||||
const navLinks = computed<Tab[]>(() => [
|
||||
{
|
||||
label: formatMessage(messages.overviewNav),
|
||||
href: `/hosting/manage/${props.serverId}`,
|
||||
href: `/hosting/manage/${encodeURIComponent(props.serverId)}`,
|
||||
icon: LayoutTemplateIcon,
|
||||
subpages: [],
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.contentNav),
|
||||
href: `/hosting/manage/${props.serverId}/content`,
|
||||
icon: BoxesIcon,
|
||||
subpages: ['mods', 'datapacks'],
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.worldsNav),
|
||||
href: `/hosting/manage/${props.serverId}/worlds`,
|
||||
href: `/hosting/manage/${encodeURIComponent(props.serverId)}/worlds`,
|
||||
icon: WorldIcon,
|
||||
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',
|
||||
href: `/hosting/manage/${props.serverId}/access`,
|
||||
href: `/hosting/manage/${encodeURIComponent(props.serverId)}/access`,
|
||||
icon: UsersIcon,
|
||||
subpages: [],
|
||||
},
|
||||
@@ -1219,7 +1215,7 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
|
||||
}
|
||||
|
||||
const handleNewMod = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
|
||||
}
|
||||
|
||||
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
|
||||
@@ -1305,7 +1301,7 @@ const onReinstall = async (
|
||||
|
||||
debug('[root.vue] onReinstall: triggering immediate invalidation')
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
|
||||
}
|
||||
|
||||
const onReinstallFailed = () => {
|
||||
@@ -1354,7 +1350,7 @@ async function invalidateAfterInstall() {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['servers', 'startup', 'v1', props.serverId],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] }),
|
||||
])
|
||||
} catch (err: unknown) {
|
||||
console.error('Error refreshing data after installation:', err)
|
||||
@@ -1478,7 +1474,10 @@ const copyServerDebugInfo = () => {
|
||||
}
|
||||
|
||||
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.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"
|
||||
@create="handleCreateWorld"
|
||||
@edit="handleEditWorld"
|
||||
@settings="handleEditWorld"
|
||||
@settings="handleWorldSettings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,6 +27,7 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
@@ -84,6 +85,7 @@ const client = injectModrinthClient()
|
||||
const { serverId, server, isServerRunning } = injectModrinthServerContext()
|
||||
const { openServerSettings } = injectServerSettingsModal()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
|
||||
const worldsQuery = useQuery({
|
||||
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' })
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ServersManageOverviewPage } from './hosting/manage/overview.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'
|
||||
|
||||
@@ -25,7 +25,7 @@ const meta = {
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
onMounted(() => {
|
||||
router.replace('/hosting/manage/demo-server/content')
|
||||
router.replace('/hosting/manage/demo-server/worlds/demo-world')
|
||||
})
|
||||
|
||||
const server = ref({
|
||||
|
||||
Reference in New Issue
Block a user