refactor: introduce PageHeader component (#6629)

* refactor: introduce PageHeader component

* feat: split up according to component structure guide

* fix: lint

* fix: label inconsistencies

* feat: refactor PageHeader (again)

* refactor: old impls

* fix: changes

* fix: rescan

* fix: prepr

* fix: dedupe

* fix: rev

* fix: lint

* fix: lint

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-20 18:25:12 +00:00
committed by GitHub
co-authored by Prospector
parent 8b6f0ec695
commit 932858b5f5
65 changed files with 3131 additions and 2091 deletions
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { LeftArrowIcon } from '@modrinth/assets'
import { LeftArrowIcon, TagCategoryGamepad2Icon as Gamepad2Icon } from '@modrinth/assets'
import type { Component } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { useServerImage } from '#ui/composables/use-server-image'
import { formatLoaderLabel } from '#ui/utils/loaders'
@@ -18,8 +18,17 @@ const MEDAL_ICON_URL = 'https://cdn-raw.modrinth.com/medal_icon.webp'
const router = useRouter()
const props = defineProps<{
installContext?: BrowseInstallContext | null
divider?: boolean
bottomPadding?: boolean
}>()
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
type BrowseHeaderMetadataItem = {
id: string
label: string
icon?: Component
iconProps?: Record<string, unknown>
class?: string
}
const ctx = injectBrowseManager(null)
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
@@ -37,14 +46,66 @@ const iconSrc = computed(() => {
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
})
const leadingItems = computed(() => {
const context = installContext.value
if (!context) return []
return [
{
id: 'back',
type: 'button' as const,
icon: LeftArrowIcon,
ariaLabel: context.backLabel,
tooltip: context.backLabel,
onClick: handleBack,
},
...(iconSrc.value
? [
{
id: 'icon',
type: 'avatar' as const,
src: iconSrc.value,
alt: context.name,
avatarSize: '48px',
class: 'shrink-0',
},
]
: []),
]
})
const metadataItems = computed(() => {
const context = installContext.value
if (!context) return []
return [
context.heading,
context.gameVersion ? `MC ${context.gameVersion}` : '',
context.loader ? formatLoaderLabel(context.loader) : '',
].filter(Boolean)
const items: BrowseHeaderMetadataItem[] = []
if (context.heading) {
items.push({
id: 'heading',
label: context.heading,
class: '!text-primary',
})
}
if (context.gameVersion) {
items.push({
id: 'game-version',
label: `Minecraft ${context.gameVersion}`,
icon: Gamepad2Icon,
class: '!text-primary',
})
}
if (context.loader) {
const loaderName = formatLoaderLabel(context.loader)
const loaderLabel = [loaderName, context.loaderVersion].filter(Boolean).join(' ')
items.push({
id: 'loader',
label: loaderLabel,
icon: LoaderIcon,
iconProps: { loader: loaderName },
class: '!text-primary',
})
}
return items
})
const selectedCount = computed(() => installContext.value?.selectedProjects?.length ?? 0)
@@ -99,39 +160,15 @@ async function handleSelectedProjectsLeaveResult(
:count="selectedCount"
:installing="isInstallingSelected"
/>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex min-w-0 items-center gap-4">
<ButtonStyled circular size="large">
<button :aria-label="installContext.backLabel" @click="handleBack">
<LeftArrowIcon />
</button>
</ButtonStyled>
<Avatar v-if="iconSrc" :src="iconSrc" size="48px" class="shrink-0" />
<div class="flex min-w-0 flex-col justify-center gap-1">
<h1 class="m-0 truncate text-2xl font-semibold leading-8 text-contrast">
{{ installContext.name }}
</h1>
<div
v-if="metadataItems.length"
class="flex flex-wrap items-center gap-2 text-base font-medium leading-6 text-primary"
>
<template v-for="(item, index) in metadataItems" :key="item">
<span
v-if="index > 0"
class="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"
/>
<span>{{ item }}</span>
</template>
</div>
</div>
</div>
</div>
</div>
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
{{ installContext.warning }}
</Admonition>
<PageHeader
:title="installContext.name"
:leading="leadingItems"
:metadata="metadataItems"
:divider="props.divider ?? false"
:bottom-padding="props.bottomPadding ?? false"
main-class="items-center"
title-class="leading-8"
truncate-title
/>
</template>
</template>
@@ -115,68 +115,19 @@
]"
>
<template v-if="revealState !== 'pending' || isOnboarding">
<ServerManageHeader
<div
v-if="!isOnboarding"
:class="['server-stagger-item', containedLayout ? 'shrink-0' : '']"
class="w-full flex flex-col gap-4"
:class="['server-stagger-item', containedLayout ? 'shrink-0' : '', { 'mt-4': isNuxt }]"
:style="{ '--si': 0 }"
:server="serverData"
:server-image="serverImage"
:server-project="serverProject"
:uptime-seconds="showUptime ? uptimeSeconds : undefined"
>
<template #actions>
<div class="flex gap-2">
<PanelServerActionButton :disabled="!!installError" />
<Tooltip
theme="dismissable-prompt"
:triggers="[]"
:shown="showSettingsHint"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled circular size="large">
<button
v-tooltip="showSettingsHint ? undefined : 'Server settings'"
@click="
() => {
openServerSettingsModal()
dismissSettingsHint()
}
"
>
<SettingsIcon />
</button>
</ButtonStyled>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(settingsHintMessages.title) }}
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(settingsHintMessages.dismiss)"
@click="dismissSettingsHint"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(settingsHintMessages.description) }}
</p>
</div>
</template>
</Tooltip>
<PanelServerOverflowMenu
:disabled="!!installError"
:uptime-seconds="uptimeSeconds"
:show-copy-id-action="showCopyIdAction"
:show-debug-info="showAdvancedDebugInfo"
/>
</div>
</template>
</ServerManageHeader>
<PageHeader
:title="serverData?.name || 'Server'"
:leading="serverHeaderLeading"
:metadata="serverHeaderMetadata"
:actions="serverHeaderActions"
/>
</div>
<ServerOnboardingPanelPage v-if="isOnboarding" :browse-modpacks="handleBrowseModpacks" />
@@ -350,7 +301,7 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError } from '@modrinth/api-client'
import { getNodeWebSocketUrl, ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
@@ -360,34 +311,35 @@ import {
FolderOpenIcon,
IssuesIcon,
LayoutTemplateIcon,
LinkIcon,
LoaderCircleIcon,
LockIcon,
MoreVerticalIcon,
RightArrowIcon,
ServerIcon as ServerAssetIcon,
SettingsIcon,
TimerIcon,
TransferIcon,
TriangleAlertIcon,
UsersIcon,
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useStorage, useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
import { Tooltip } from 'floating-vue'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ErrorInformationCard from '#ui/components/base/ErrorInformationCard.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
import ServerNotice from '#ui/components/base/ServerNotice.vue'
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import ServerIcon from '#ui/components/servers/icons/ServerIcon.vue'
import MedalServerCountdown from '#ui/components/servers/marketing/MedalServerCountdown.vue'
import {
PanelServerActionButton,
PanelServerOverflowMenu,
ServerManageHeader,
} from '#ui/components/servers/server-header'
import { PanelServerActionButton } from '#ui/components/servers/server-header'
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
import {
hasServerPermission,
@@ -508,6 +460,7 @@ const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const constrainWidth = computed(() => props.constrainWidth)
const containedLayout = computed(() => props.layoutMode === 'contained')
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const queryClient = useQueryClient()
const route = useRoute()
const router = useRouter()
@@ -527,6 +480,10 @@ const isOnboarding = computed(() => serverData.value?.flows?.intro)
const SETTINGS_HINT_KEY = 'server-panel-settings-hint-dismissed'
const settingsHintDismissed = useStorage(SETTINGS_HINT_KEY, false)
const showSettingsHint = ref(!settingsHintDismissed.value)
const serverPreferences = useStorage(`pyro-server-${props.serverId}-preferences`, {
hideSubdomainLabel: false,
})
function dismissSettingsHint() {
showSettingsHint.value = false
settingsHintDismissed.value = true
@@ -702,6 +659,166 @@ const {
onStateEvent,
})
const serverHeaderLeading = computed(() => [
{
id: 'server-icon',
type: 'component' as const,
component: ServerIcon,
componentProps: {
image: serverImage.value,
},
class: isNuxt.value ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl',
},
])
const showServerUptime = computed(() => props.showUptime && uptimeSeconds.value > 0)
const formattedUptime = computed(() => formatUptime(uptimeSeconds.value))
const serverProjectLink = computed(() => {
if (!serverProject.value) return ''
return `/project/${serverProject.value.slug ?? serverProject.value.id}`
})
const serverHeaderMetadata = computed(() => {
const server = serverData.value
const items = []
if (server?.flows?.intro) {
items.push({
id: 'intro',
icon: SettingsIcon,
label: 'Configuring server...',
class: 'font-semibold',
})
return items
}
if (server?.loader) {
items.push({
id: 'loader',
icon: LoaderIcon,
iconProps: {
loader: server.loader,
},
label: `${formatLoaderLabel(server.loader)} ${server.mc_version}`,
})
}
if (server?.net?.domain && !serverPreferences.value.hideSubdomainLabel) {
items.push({
id: 'server-address',
icon: LinkIcon,
label: `${server.net.domain}.modrinth.gg`,
tooltip: 'Copy server address',
onClick: copyServerAddress,
})
}
if (showServerUptime.value) {
items.push({
id: 'uptime',
icon: TimerIcon,
label: formattedUptime.value,
})
}
if (serverProject.value) {
items.push({
id: 'linked-project',
label: 'Linked to',
value: serverProject.value.title,
valueClass: 'text-primary',
avatarSrc: serverProject.value.icon_url,
avatarAlt: serverProject.value.title,
to: serverProjectLink.value,
})
}
return items
})
const serverHeaderActions = computed(() => [
{
id: 'server-power',
label: 'Server power',
kind: 'component' as const,
component: PanelServerActionButton,
componentProps: {
disabled: !!installError.value,
},
},
{
id: 'settings',
label: 'Server settings',
icon: SettingsIcon,
labelHidden: true,
circular: true,
tooltip: showSettingsHint.value ? undefined : 'Server settings',
onClick: () => openServerSettingsModal(),
prompt: {
title: formatMessage(settingsHintMessages.title),
description: formatMessage(settingsHintMessages.description),
dismissLabel: formatMessage(settingsHintMessages.dismiss),
shown: showSettingsHint.value,
placement: 'bottom-end',
onDismiss: dismissSettingsHint,
},
},
{
id: 'more',
label: 'More server options',
icon: MoreVerticalIcon,
labelHidden: true,
circular: true,
type: 'transparent' as const,
menuActions: [
{
id: 'all-servers',
label: 'All servers',
icon: ServerAssetIcon,
action: () => router.push('/hosting/manage'),
},
{
id: 'copy-id',
label: 'Copy ID',
icon: CopyIcon,
action: copyServerId,
shown: props.showCopyIdAction,
},
],
},
])
function formatUptime(uptime: number) {
const days = Math.floor(uptime / (24 * 3600))
const hours = Math.floor((uptime % (24 * 3600)) / 3600)
const minutes = Math.floor((uptime % 3600) / 60)
const seconds = uptime % 60
let formatted = ''
if (days > 0) formatted += `${days}d `
if (hours > 0 || days > 0) formatted += `${hours}h `
formatted += `${minutes}m ${seconds}s`
return formatted.trim()
}
function copyServerAddress() {
const domain = serverData.value?.net?.domain
if (!domain) return
void navigator.clipboard.writeText(`${domain}.modrinth.gg`)
addNotification({
title: 'Server address copied',
text: "Your server's address has been copied to your clipboard.",
type: 'success',
})
}
function copyServerId() {
void navigator.clipboard.writeText(props.serverId)
}
const isUploading = computed(() => uploadState.value.isUploading)
const canSetup = computed(() =>
hasServerPermission(serverData.value?.current_user_permissions ?? 0, 'SETUP'),