fix: header issues

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:22 +01:00
parent 8fca23e66a
commit b72c319569
6 changed files with 257 additions and 74 deletions
+41 -8
View File
@@ -28,18 +28,31 @@
<template #stats> <template #stats>
<div class="flex items-center flex-wrap gap-2"> <div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance"> <template v-if="!isServerInstance">
<div class="flex items-center gap-2 capitalize font-medium"> <div class="flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap">
{{ instance.loader }} {{ instance.game_version }} <Gamepad2Icon class="flex size-5 shrink-0" aria-hidden="true" />
<span class="truncate">{{ instance.game_version }}</span>
</div>
<BulletDivider />
<div class="flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap">
<ServerLoaderIcon
v-if="loaderDisplayName"
:loader="loaderDisplayName"
class="flex size-5 shrink-0"
aria-hidden="true"
/>
<span class="truncate">{{ loaderLabel }}</span>
</div> </div>
<template v-if="showInstancePlayTime"> <template v-if="showInstancePlayTime">
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div> <BulletDivider />
<div class="flex items-center gap-2 font-medium"> <div class="flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap">
<template v-if="timePlayed > 0"> <TimerIcon class="flex size-5 shrink-0" aria-hidden="true" />
{{ timePlayedHumanized }} <span class="truncate">
</template> {{ playtimeLabel }}
<template v-else> Never played </template> </span>
</div> </div>
</template> </template>
</template> </template>
@@ -287,16 +300,21 @@ import {
ServerIcon, ServerIcon,
SettingsIcon, SettingsIcon,
StopCircleIcon, StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TerminalSquareIcon, TerminalSquareIcon,
TimerIcon,
UpdatedIcon, UpdatedIcon,
UserPlusIcon, UserPlusIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { import {
Avatar, Avatar,
BulletDivider,
ButtonStyled, ButtonStyled,
ContentPageHeader, ContentPageHeader,
formatLoaderLabel,
injectNotificationManager, injectNotificationManager,
LoaderIcon as ServerLoaderIcon,
NavTabs, NavTabs,
OverflowMenu, OverflowMenu,
ServerOnlinePlayers, ServerOnlinePlayers,
@@ -305,6 +323,7 @@ import {
ServerRegion, ServerRegion,
useLoadingBarToken, useLoadingBarToken,
} from '@modrinth/ui' } from '@modrinth/ui'
import type { Loaders } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query' import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core' import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -725,6 +744,16 @@ const timePlayed = computed(() => {
: 0 : 0
}) })
const loaderDisplayName = computed(() =>
instance.value ? (formatLoaderLabel(instance.value.loader) as Loaders) : null,
)
const loaderLabel = computed(() =>
instance.value && loaderDisplayName.value
? [loaderDisplayName.value, instance.value.loader_version].filter(Boolean).join(' ')
: '',
)
const timePlayedHumanized = computed(() => { const timePlayedHumanized = computed(() => {
const duration = dayjs.duration(timePlayed.value, 'seconds') const duration = dayjs.duration(timePlayed.value, 'seconds')
const hours = Math.floor(duration.asHours()) const hours = Math.floor(duration.asHours())
@@ -741,6 +770,10 @@ const timePlayedHumanized = computed(() => {
return seconds + ' second' + (seconds > 1 ? 's' : '') return seconds + ' second' + (seconds > 1 ? 's' : '')
}) })
const playtimeLabel = computed(() =>
timePlayed.value > 0 ? timePlayedHumanized.value : 'Never played',
)
onUnmounted(() => { onUnmounted(() => {
unlistenProcesses() unlistenProcesses()
unlistenInstances() unlistenInstances()
@@ -4,7 +4,7 @@
<template #icon> <template #icon>
<ServerIcon <ServerIcon
:image="headerImage" :image="headerImage"
:class="isNuxt ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl'" :class="isNuxt ? 'size-15 !rounded-2xl' : 'size-15 !rounded-xl'"
/> />
</template> </template>
<template #title> <template #title>
@@ -0,0 +1,127 @@
<template>
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
<ContentPageHeader :class="props.headerClass">
<template #icon>
<div class="flex size-16 shrink-0 items-center justify-center">
<ButtonStyled circular size="large">
<button
type="button"
:aria-label="props.backLabel"
@click="router.push(props.backHref)"
>
<LeftArrowIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
<template #title>
{{ props.name || props.fallbackName }}
</template>
<template #stats>
<div v-if="headerMetadata.length" class="flex min-w-0 flex-wrap items-center gap-2">
<template v-for="(item, index) in headerMetadata" :key="item.id">
<BulletDivider v-if="index > 0" />
<div 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"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</div>
</template>
<script setup lang="ts">
import { NuxtModrinthClient } from '@modrinth/api-client'
import { LeftArrowIcon, TagCategoryGamepad2Icon as Gamepad2Icon, TimerIcon } from '@modrinth/assets'
import { type Component, computed } from 'vue'
import { useRouter } from 'vue-router'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ContentPageHeader from '#ui/components/base/ContentPageHeader.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { injectModrinthClient } from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
type MetadataItem = {
id: string
label: string
icon: Component
iconProps?: Record<string, unknown>
}
const props = withDefaults(
defineProps<{
name?: string | null
metadataItems?: MetadataItem[]
gameVersion?: string | null
loader?: string | null
loaderVersion?: string | null
lastActive?: string | null
backHref: string
backLabel: string
fallbackName?: string
headerClass?: string
}>(),
{
name: null,
metadataItems: () => [],
gameVersion: null,
loader: null,
loaderVersion: null,
lastActive: null,
fallbackName: 'World',
headerClass: '',
},
)
const client = injectModrinthClient()
const router = useRouter()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const loaderLabel = computed(() => {
if (!props.loader) return null
const label = formatLoaderLabel(props.loader.toLowerCase())
return [label, props.loaderVersion].filter(Boolean).join(' ')
})
const headerMetadata = computed<MetadataItem[]>(() => {
if (props.metadataItems.length) return props.metadataItems
const items: MetadataItem[] = []
if (props.gameVersion) {
items.push({
id: 'game-version',
label: props.gameVersion,
icon: Gamepad2Icon,
})
}
if (props.loader && loaderLabel.value) {
items.push({
id: 'loader',
label: loaderLabel.value,
icon: LoaderIcon,
iconProps: {
loader: formatLoaderLabel(props.loader.toLowerCase()),
},
})
}
if (props.lastActive) {
items.push({
id: 'last-active',
label: props.lastActive,
icon: TimerIcon,
})
}
return items
})
</script>
@@ -1,2 +1,3 @@
export { default as PanelServerActionButton } from './PanelServerActionButton.vue' export { default as PanelServerActionButton } from './PanelServerActionButton.vue'
export { default as ServerManageHeader } from './ServerManageHeader.vue' export { default as ServerManageHeader } from './ServerManageHeader.vue'
export { default as WorldManageHeader } from './WorldManageHeader.vue'
@@ -1,42 +1,27 @@
<template> <template>
<div class="flex min-h-[36rem] flex-col gap-6 text-primary"> <div class="flex min-h-[36rem] flex-col gap-4 text-primary">
<div class="flex flex-col gap-2"> <WorldManageHeader
<RouterLink :name="worldName"
:to="instancesPath" :game-version="gameVersion"
class="flex w-fit items-center gap-1 text-base font-medium text-blue hover:underline" :loader="loader"
> :loader-version="loaderVersion"
<ChevronLeftIcon class="size-4" aria-hidden="true" /> :last-active="lastActiveLabel"
{{ formatMessage(messages.allInstances) }} :back-href="instancesPath"
</RouterLink> :back-label="formatMessage(messages.allInstances)"
:fallback-name="formatMessage(messages.worldFallbackName)"
<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"> <template #actions>
<h1 class="m-0 truncate text-2xl font-semibold leading-8 text-contrast"> <PanelServerActionButton size="large" start-label="Start instance" />
{{ worldName }} <ButtonStyled size="large" circular>
</h1> <button
<div class="flex flex-wrap items-center gap-2 text-base font-medium text-secondary"> v-tooltip="formatMessage(messages.instanceSettings)"
<template v-for="(item, index) in worldMetadata" :key="item"> @click="openServerSettings({ tabId: 'installation' })"
<span>{{ item }}</span> >
<BulletDivider v-if="index < worldMetadata.length - 1" /> <SettingsIcon aria-hidden="true" />
</template> </button>
</div> </ButtonStyled>
</div> </template>
</WorldManageHeader>
<div class="flex shrink-0 items-center gap-2">
<PanelServerActionButton size="large" start-label="Start instance" />
<ButtonStyled size="large" circular>
<button
v-tooltip="formatMessage(messages.instanceSettings)"
@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 /> <NavTabs :links="worldTabLinks" replace />
@@ -46,21 +31,14 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Archon } from '@modrinth/api-client' import type { Archon } from '@modrinth/api-client'
import { import { BoxesIcon, DatabaseBackupIcon, FolderOpenIcon, SettingsIcon } from '@modrinth/assets'
BoxesIcon,
ChevronLeftIcon,
DatabaseBackupIcon,
FolderOpenIcon,
SettingsIcon,
} from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query' import { useQuery } from '@tanstack/vue-query'
import { computed, watch } from 'vue' import { computed, watch } from 'vue'
import { RouterLink, useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue' import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NavTabs from '#ui/components/base/NavTabs.vue' import NavTabs from '#ui/components/base/NavTabs.vue'
import { PanelServerActionButton } from '#ui/components/servers/server-header' import { PanelServerActionButton, WorldManageHeader } from '#ui/components/servers/server-header'
import { useRelativeTime } from '#ui/composables' import { useRelativeTime } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n' import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { import {
@@ -68,7 +46,6 @@ import {
injectModrinthServerContext, injectModrinthServerContext,
injectServerSettingsModal, injectServerSettingsModal,
} from '#ui/providers' } from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
interface Tab { interface Tab {
label: string label: string
@@ -138,24 +115,18 @@ const worldName = computed(
() => currentWorld.value?.name ?? server.value?.name ?? formatMessage(messages.worldFallbackName), () => currentWorld.value?.name ?? server.value?.name ?? formatMessage(messages.worldFallbackName),
) )
const worldMetadata = computed(() => const gameVersion = computed(() => {
[gameVersionLabel.value, loaderLabel.value, lastActiveLabel.value].filter( const version = currentWorld.value?.content?.game_version ?? server.value?.mc_version
(item): item is string => !!item, return version ?? null
), })
const loader = computed(
() => currentWorld.value?.content?.modloader ?? server.value?.loader ?? null,
) )
const gameVersionLabel = computed(() => { const loaderVersion = computed(
const version = currentWorld.value?.content?.game_version ?? server.value?.mc_version () => currentWorld.value?.content?.modloader_version ?? server.value?.loader_version ?? null,
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 lastActiveLabel = computed(() => {
const latestBackup = currentWorld.value ? latestDate(currentWorld.value.backups) : null const latestBackup = currentWorld.value ? latestDate(currentWorld.value.backups) : null
@@ -0,0 +1,51 @@
import { SettingsIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import WorldManageHeader from '../../components/servers/server-header/WorldManageHeader.vue'
const meta = {
title: 'Servers/WorldManageHeader',
component: WorldManageHeader,
parameters: {
layout: 'padded',
},
decorators: [
(story) => ({
components: { story },
template: '<div style="max-width: 920px;"><story /></div>',
}),
],
} satisfies Meta<typeof WorldManageHeader>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
name: 'My World',
gameVersion: '1.20.1',
loader: 'fabric',
loaderVersion: '0.19.2',
lastActive: 'Last active 2 weeks ago',
backHref: '/hosting/manage/demo-server/instances',
backLabel: 'All instances',
},
render: (args) => ({
components: { ButtonStyled, SettingsIcon, WorldManageHeader },
setup() {
return { args }
},
template: `
<WorldManageHeader v-bind="args">
<template #actions>
<ButtonStyled size="large" circular>
<button type="button" aria-label="Instance settings">
<SettingsIcon aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</WorldManageHeader>
`,
}),
}