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,45 +0,0 @@
<template>
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
<div class="flex flex-wrap items-start gap-4 max-md:flex-col">
<div class="flex min-w-0 flex-1 gap-4">
<slot name="icon" />
<div class="flex min-w-0 flex-col gap-2 justify-center">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
</div>
<p
v-if="$slots.summary"
class="m-0 max-w-[44rem] empty:hidden"
:class="[disableLineClamp ? '' : 'line-clamp-2']"
>
<slot name="summary" />
</p>
</div>
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
<slot name="stats" />
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 items-center">
<slot name="actions" />
</div>
</div>
<div v-if="$slots.stats" class="flex justify-between md:hidden">
<div class="flex flex-wrap gap-3 empty:hidden">
<slot name="stats" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
disableLineClamp?: boolean
}
const { disableLineClamp } = defineProps<Props>()
</script>
@@ -33,7 +33,8 @@ import { DropdownIcon } from '@modrinth/assets'
import type { Component } from 'vue'
import { computed } from 'vue'
import { ButtonStyled, OverflowMenu } from '../index'
import ButtonStyled from './ButtonStyled.vue'
import OverflowMenu from './OverflowMenu.vue'
// TODO: This should be moved to a shared types file.
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
@@ -1,11 +0,0 @@
<template>
<div class="grid grid-cols-[min-content_1fr_auto] gap-4">
<slot name="icon" />
<div class="flex flex-col gap-1.5">
<slot name="title" />
<slot name="summary" />
<slot name="stats" />
</div>
<slot name="actions" />
</div>
</template>
@@ -2,10 +2,13 @@
<div data-pyro-telepopover-wrapper class="relative">
<button
ref="triggerRef"
v-tooltip="tooltip"
class="teleport-overflow-menu-trigger"
:class="btnClass"
:aria-expanded="isOpen"
:aria-haspopup="true"
:aria-label="ariaLabel"
:disabled="disabled"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@@ -38,9 +41,14 @@
:key="isDivider(option) ? `divider-${index}` : option.id"
>
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
<ButtonStyled
v-else
type="transparent"
role="menuitem"
:color="optionButtonColor(option)"
>
<button
v-if="typeof option.action === 'function'"
v-if="typeof option.action === 'function' || option.disabled"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
@@ -49,41 +57,45 @@
v-tooltip="option.tooltip"
:disabled="option.disabled"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-label="option.ariaLabel ?? option.label ?? option.id"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@click="(event) => handleItemClick(option, index, event)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.id }}
{{ option.label ?? option.id }}
</slot>
</button>
<AutoLink
v-else-if="typeof option.action === 'string'"
v-else-if="optionLink(option)"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
}
"
:to="option.action"
:to="optionLink(option)"
:target="option.external ? '_blank' : undefined"
:rel="option.external ? 'noopener noreferrer' : undefined"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-label="option.ariaLabel ?? option.label ?? option.id"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@click="(event) => handleItemClick(option, index, event)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.id }}
{{ option.label ?? option.id }}
</slot>
</AutoLink>
<span v-else>
<slot :name="option.id">
<component :is="option.icon" v-if="option.icon" class="size-5" />
{{ option.id }}
{{ option.label ?? option.id }}
</slot>
</span>
</ButtonStyled>
@@ -95,29 +107,49 @@
</template>
<script setup lang="ts">
import { AutoLink, ButtonStyled } from '@modrinth/ui'
import { onClickOutside, useElementHover } from '@vueuse/core'
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
interface Option {
import AutoLink from './AutoLink.vue'
import ButtonStyled from './ButtonStyled.vue'
type OptionColor =
| 'standard'
| 'brand'
| 'primary'
| 'danger'
| 'secondary'
| 'highlight'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
export interface Option {
id: string
label?: string
icon?: Component
action?: (() => void) | string
action?: ((event?: MouseEvent) => void) | string
link?: string
external?: boolean
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
color?: OptionColor
disabled?: boolean
tooltip?: string
ariaLabel?: string
remainOnClick?: boolean
}
type Divider = {
export type Divider = {
divider?: boolean
shown?: boolean
}
type Item = Option | Divider
export type Item = Option | Divider
function isDivider(item: Item): item is Divider {
return (item as Divider).divider
return !!(item as Divider).divider
}
const props = withDefaults(
@@ -125,10 +157,16 @@ const props = withDefaults(
options: Item[]
hoverable?: boolean
btnClass?: string | string[] | Record<string, boolean>
disabled?: boolean
tooltip?: string
ariaLabel?: string
}>(),
{
hoverable: false,
btnClass: undefined,
disabled: false,
tooltip: undefined,
ariaLabel: undefined,
},
)
@@ -191,6 +229,7 @@ const calculateMenuPosition = () => {
const toggleMenu = (event: MouseEvent) => {
event.stopPropagation()
if (props.disabled) return
if (!props.hoverable) {
if (isOpen.value) {
closeMenu()
@@ -201,6 +240,7 @@ const toggleMenu = (event: MouseEvent) => {
}
const openMenu = () => {
if (props.disabled) return
isOpen.value = true
emit('open')
disableBodyScroll()
@@ -218,15 +258,18 @@ const closeMenu = () => {
document.removeEventListener('mousemove', handleMouseMove)
}
const selectOption = (option: Option) => {
const selectOption = (option: Option, event?: MouseEvent) => {
emit('select', option)
if (typeof option.action === 'function') {
option.action()
option.action(event)
}
if (!option.remainOnClick) {
closeMenu()
}
closeMenu()
}
const handleMouseDown = (event: MouseEvent) => {
if (props.disabled) return
event.preventDefault()
isMouseDown.value = true
}
@@ -270,10 +313,10 @@ const handleMouseMove = (event: MouseEvent) => {
}
}
const handleItemClick = (option: Option, index: number) => {
const handleItemClick = (option: Option, index: number, event?: MouseEvent) => {
if (option.disabled) return
selectedIndex.value = index
selectOption(option)
selectOption(option, event)
}
const handleMouseOver = (index: number) => {
@@ -432,4 +475,23 @@ onClickOutside(menuRef, (event) => {
closeMenu()
}
})
function optionLink(option: Option) {
if (typeof option.action === 'string') return option.action
return option.link
}
function optionButtonColor(option: Option) {
switch (option.color) {
case 'primary':
return 'brand'
case 'danger':
return 'red'
case 'secondary':
case 'highlight':
return 'standard'
default:
return option.color ?? 'standard'
}
}
</script>
+22 -1
View File
@@ -19,7 +19,6 @@ export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export type { ComboboxOption } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
export { default as ContentPageHeader } from './ContentPageHeader.vue'
export { default as CopyCode } from './CopyCode.vue'
export { default as DatePicker } from './DatePicker.vue'
export { default as DoubleIcon } from './DoubleIcon.vue'
@@ -65,6 +64,23 @@ export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'
export { default as Page } from './Page.vue'
export { default as PageHeader } from './page-header/index.vue'
export { default as PageHeaderMetadata } from './page-header/metadata/index.vue'
export { default as PageHeaderMetadataItem } from './page-header/metadata/page-header-metadata-item.vue'
export { default as PageHeaderMetadataNumberItem } from './page-header/metadata/page-header-metadata-number-item.vue'
export { default as PageHeaderMetadataTagsItem } from './page-header/metadata/page-header-metadata-tags-item.vue'
export { default as PageHeaderMetadataTimeItem } from './page-header/metadata/page-header-metadata-time-item.vue'
export { default as PageHeaderActions } from './page-header/page-header-actions.vue'
export { default as PageHeaderBadgeItem } from './page-header/page-header-badge-item.vue'
export type {
PageHeaderClass,
PageHeaderClickHandler,
PageHeaderIconProps,
PageHeaderInteractiveProps,
PageHeaderMetadataItemProps,
PageHeaderProps,
PageHeaderTarget,
} from './page-header/types'
export { default as Pagination } from './Pagination.vue'
export { default as PopoutMenu } from './PopoutMenu.vue'
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
@@ -89,6 +105,11 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagItem } from './TagItem.vue'
export { default as TagTagItem } from './TagTagItem.vue'
export type {
Item as TeleportOverflowMenuItem,
Option as TeleportOverflowMenuOption,
} from './TeleportOverflowMenu.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
export type {
TimeFrameLastUnit,
TimeFrameLastUnitOption,
@@ -0,0 +1,67 @@
<template>
<div class="flex flex-col gap-2" :class="rootClass">
<div class="flex flex-wrap items-start gap-4 max-md:flex-col" :class="rowClass">
<div class="flex min-w-0 flex-1 gap-4" :class="mainClass">
<div v-if="$slots.leading" class="flex shrink-0 items-center gap-4">
<slot name="leading" />
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center gap-2">
<div class="flex flex-col justify-center gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<h1
class="m-0 min-w-0 max-w-full text-2xl font-semibold leading-none text-contrast"
:class="titleClassValue"
>
{{ title }}
</h1>
<slot name="badges" />
</div>
<p v-if="hasSummary" class="m-0 max-w-[44rem]" :class="summaryClass">
<slot name="summary">{{ summary }}</slot>
</p>
</div>
<div v-if="$slots.metadata" class="max-md:hidden">
<slot name="metadata" />
</div>
</div>
</div>
<slot name="actions" />
</div>
<div v-if="$slots.metadata" class="flex justify-between md:hidden">
<slot name="metadata" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, useSlots } from 'vue'
import type { PageHeaderProps } from './types'
const props = withDefaults(defineProps<PageHeaderProps>(), {
summary: null,
headerClass: '',
rowClass: '',
mainClass: '',
titleClass: '',
truncateTitle: false,
divider: true,
bottomPadding: true,
disableLineClamp: false,
})
const slots = useSlots()
const rootClass = computed(() => [
props.divider ? 'border-0 border-b border-solid border-divider' : '',
props.bottomPadding ? 'pb-4' : '',
props.headerClass,
])
const titleClassValue = computed(() => [props.truncateTitle ? 'truncate' : '', props.titleClass])
const summaryClass = computed(() => (props.disableLineClamp ? '' : 'line-clamp-2'))
const hasSummary = computed(() => !!props.summary || !!slots.summary)
</script>
@@ -0,0 +1,91 @@
<template>
<div
ref="metadata"
class="page-header-metadata flex min-w-0 flex-wrap items-center gap-x-2 gap-y-2"
>
<slot />
</div>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const metadata = ref<HTMLElement | null>(null)
let resizeObserver: ResizeObserver | null = null
let mutationObserver: MutationObserver | null = null
const observedItems = new Set<Element>()
function getItems() {
return Array.from(
metadata.value?.querySelectorAll<HTMLElement>('[data-page-header-metadata-item]') ?? [],
)
}
function observeItems() {
if (!resizeObserver) return
for (const item of getItems()) {
if (observedItems.has(item)) continue
resizeObserver.observe(item)
observedItems.add(item)
}
}
function updateRowStarts() {
const root = metadata.value
if (!root) return
const isRtl = getComputedStyle(root).direction === 'rtl'
let previousOffset: number | null = null
for (const item of getItems()) {
const offset = item.offsetLeft
const startsRow =
previousOffset === null ||
(isRtl ? offset >= previousOffset - 1 : offset <= previousOffset + 1)
item.toggleAttribute('data-page-header-metadata-row-start', startsRow)
previousOffset = offset
}
}
function scheduleUpdate() {
void nextTick(() => {
observeItems()
updateRowStarts()
})
}
onMounted(() => {
scheduleUpdate()
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(scheduleUpdate)
if (metadata.value) {
resizeObserver.observe(metadata.value)
}
observeItems()
}
if (typeof MutationObserver !== 'undefined' && metadata.value) {
mutationObserver = new MutationObserver(scheduleUpdate)
mutationObserver.observe(metadata.value, { childList: true, subtree: true })
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
mutationObserver?.disconnect()
observedItems.clear()
})
</script>
<style scoped>
.page-header-metadata
:deep([data-page-header-metadata-item]:first-child .page-header-metadata-item-divider),
.page-header-metadata
:deep([data-page-header-metadata-row-start] .page-header-metadata-item-divider) {
display: none;
}
</style>
@@ -0,0 +1,89 @@
<template>
<div :class="rootClass" data-page-header-metadata-item v-bind="$attrs">
<BulletDivider class="page-header-metadata-item-divider shrink-0" />
<AutoLink
v-if="to && !disabled"
v-tooltip="tooltip"
:to="to"
:aria-label="ariaLabel"
:link-class="contentClassString"
>
<component
:is="icon"
v-if="icon"
:class="iconClass ?? defaultIconClass"
aria-hidden="true"
v-bind="iconProps"
/>
<slot />
</AutoLink>
<button
v-else-if="action || disabled"
v-tooltip="tooltip"
type="button"
:disabled="disabled"
:aria-label="ariaLabel"
:class="contentClass"
@click="handleClick"
>
<component
:is="icon"
v-if="icon"
:class="iconClass ?? defaultIconClass"
aria-hidden="true"
v-bind="iconProps"
/>
<slot />
</button>
<div v-else v-tooltip="tooltip" :aria-label="ariaLabel" :class="contentClass">
<component
:is="icon"
v-if="icon"
:class="iconClass ?? defaultIconClass"
aria-hidden="true"
v-bind="iconProps"
/>
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AutoLink from '../../AutoLink.vue'
import BulletDivider from '../../BulletDivider.vue'
import type { PageHeaderMetadataItemProps } from '../types'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<PageHeaderMetadataItemProps>(), {
icon: undefined,
iconProps: undefined,
iconClass: undefined,
tooltip: undefined,
ariaLabel: undefined,
to: undefined,
action: undefined,
disabled: false,
})
const defaultIconClass = 'block size-5 shrink-0 text-current'
const baseClass =
'flex min-w-0 items-center gap-2 font-medium leading-none text-secondary text-nowrap'
const contentBaseClass = 'inline-flex min-w-0 items-center gap-2 text-inherit'
const interactiveClass = 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline'
const rootClass = computed(() => [baseClass, props.disabled ? 'cursor-not-allowed opacity-60' : ''])
const contentClass = computed(() => [
contentBaseClass,
props.to || props.action ? interactiveClass : '',
])
const contentClassString = computed(() => contentClass.value.filter(Boolean).join(' '))
function handleClick(event: MouseEvent) {
void props.action?.(event)
}
</script>
@@ -0,0 +1,61 @@
<template>
<PageHeaderMetadataItem
:icon="icon"
:icon-props="iconProps"
:icon-class="iconClass"
:tooltip="resolvedTooltip"
:aria-label="ariaLabel"
:to="to"
:action="action"
:disabled="disabled"
>
<span class="inline-flex items-baseline gap-1">
<span>{{ formattedValue }}</span>
<span v-if="label">{{ label }}</span>
</span>
</PageHeaderMetadataItem>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PageHeaderMetadataItemProps } from '../types'
import PageHeaderMetadataItem from './page-header-metadata-item.vue'
const props = withDefaults(
defineProps<
PageHeaderMetadataItemProps & {
value: number
label?: string
locale?: string
compact?: boolean
}
>(),
{
label: '',
locale: undefined,
compact: true,
icon: undefined,
iconProps: undefined,
iconClass: undefined,
tooltip: undefined,
ariaLabel: undefined,
to: undefined,
action: undefined,
disabled: false,
},
)
const formattedValue = computed(() =>
new Intl.NumberFormat(props.locale, {
notation: props.compact ? 'compact' : 'standard',
maximumFractionDigits: props.compact ? 1 : 0,
}).format(props.value),
)
const fullValue = computed(() => new Intl.NumberFormat(props.locale).format(props.value))
const resolvedTooltip = computed(() => {
if (props.tooltip) return props.tooltip
if (!props.compact || formattedValue.value === fullValue.value) return undefined
return [fullValue.value, props.label].filter(Boolean).join(' ')
})
</script>
@@ -0,0 +1,33 @@
<template>
<PageHeaderMetadataItem
class="!text-wrap"
:icon="icon"
:icon-props="iconProps"
:icon-class="iconClass"
:tooltip="tooltip"
:aria-label="ariaLabel"
:to="to"
:action="action"
:disabled="disabled"
>
<div class="flex min-w-0 flex-wrap gap-2">
<slot />
</div>
</PageHeaderMetadataItem>
</template>
<script setup lang="ts">
import type { PageHeaderMetadataItemProps } from '../types'
import PageHeaderMetadataItem from './page-header-metadata-item.vue'
withDefaults(defineProps<PageHeaderMetadataItemProps>(), {
icon: undefined,
iconProps: undefined,
iconClass: undefined,
tooltip: undefined,
ariaLabel: undefined,
to: undefined,
action: undefined,
disabled: false,
})
</script>
@@ -0,0 +1,66 @@
<template>
<PageHeaderMetadataItem
:icon="icon"
:icon-props="iconProps"
:icon-class="iconClass"
:tooltip="resolvedTooltip"
:aria-label="ariaLabel"
:to="to"
:action="action"
:disabled="disabled"
>
<span>{{ displayLabel }}</span>
</PageHeaderMetadataItem>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useFormatDateTime, useRelativeTime } from '../../../../composables'
import type { PageHeaderMetadataItemProps } from '../types'
import PageHeaderMetadataItem from './page-header-metadata-item.vue'
const props = withDefaults(
defineProps<
PageHeaderMetadataItemProps & {
date: string | number | Date
label?: string
relative?: boolean
}
>(),
{
label: '',
relative: true,
icon: undefined,
iconProps: undefined,
iconClass: undefined,
tooltip: undefined,
ariaLabel: undefined,
to: undefined,
action: undefined,
disabled: false,
},
)
const formatDateTime = useFormatDateTime({
dateStyle: 'medium',
timeStyle: 'short',
})
const formatRelativeTime = useRelativeTime()
const parsedDate = computed(() => {
const date = props.date instanceof Date ? props.date : new Date(props.date)
return Number.isNaN(date.getTime()) ? null : date
})
const absoluteDate = computed(() => {
if (!parsedDate.value) return ''
return formatDateTime(parsedDate.value)
})
const formattedDate = computed(() => {
if (!parsedDate.value) return ''
if (!props.relative) return absoluteDate.value
return formatRelativeTime(parsedDate.value)
})
const resolvedTooltip = computed(() => props.tooltip ?? (absoluteDate.value || undefined))
const displayLabel = computed(() => [props.label, formattedDate.value].filter(Boolean).join(' '))
</script>
@@ -0,0 +1,5 @@
<template>
<div class="flex flex-wrap items-center gap-2">
<slot />
</div>
</template>
@@ -0,0 +1,82 @@
<template>
<AutoLink
v-if="to && !disabled"
v-tooltip="tooltip"
:to="to"
:aria-label="ariaLabel"
:link-class="badgeClassString"
v-bind="$attrs"
>
<component
:is="icon"
v-if="icon"
:class="iconClass ?? defaultIconClass"
aria-hidden="true"
v-bind="iconProps"
/>
<slot />
</AutoLink>
<button
v-else-if="action || disabled"
v-tooltip="tooltip"
type="button"
:disabled="disabled"
:aria-label="ariaLabel"
:class="badgeClass"
v-bind="$attrs"
@click="handleClick"
>
<component
:is="icon"
v-if="icon"
:class="iconClass ?? defaultIconClass"
aria-hidden="true"
v-bind="iconProps"
/>
<slot />
</button>
<div v-else v-tooltip="tooltip" :aria-label="ariaLabel" :class="badgeClass" v-bind="$attrs">
<component
:is="icon"
v-if="icon"
:class="iconClass ?? defaultIconClass"
aria-hidden="true"
v-bind="iconProps"
/>
<slot />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AutoLink from '../AutoLink.vue'
import type { PageHeaderIconProps, PageHeaderInteractiveProps } from './types'
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<PageHeaderIconProps & PageHeaderInteractiveProps>(), {
icon: undefined,
iconProps: undefined,
iconClass: undefined,
tooltip: undefined,
ariaLabel: undefined,
to: undefined,
action: undefined,
disabled: false,
})
const badgeClass = computed(() => [
'inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-button-bg px-2 py-1 text-sm font-semibold leading-none text-secondary text-nowrap',
props.to || props.action ? 'm-0 cursor-pointer hover:underline' : '',
props.disabled ? 'cursor-not-allowed opacity-60' : '',
])
const badgeClassString = computed(() => badgeClass.value.filter(Boolean).join(' '))
const defaultIconClass = 'block size-4 shrink-0 text-current'
function handleClick(event: MouseEvent) {
void props.action?.(event)
}
</script>
@@ -0,0 +1,36 @@
import type { Component, HTMLAttributes } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type PageHeaderTarget = string | RouteLocationRaw
export type PageHeaderClass = HTMLAttributes['class']
export type PageHeaderClickHandler = (event: MouseEvent) => void | Promise<void>
export type PageHeaderIconProps = {
icon?: Component
iconProps?: Record<string, unknown>
iconClass?: PageHeaderClass
}
export type PageHeaderInteractiveProps = {
tooltip?: string
ariaLabel?: string
to?: PageHeaderTarget
action?: PageHeaderClickHandler
disabled?: boolean
}
export type PageHeaderMetadataItemProps = PageHeaderIconProps & PageHeaderInteractiveProps
export type PageHeaderProps = {
title: string
summary?: string | null
headerClass?: PageHeaderClass
rowClass?: PageHeaderClass
mainClass?: PageHeaderClass
titleClass?: PageHeaderClass
truncateTitle?: boolean
divider?: boolean
bottomPadding?: boolean
disableLineClamp?: boolean
}
@@ -1,115 +0,0 @@
<template>
<ContentPageHeader>
<template #icon>
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
</template>
<template #title>
{{ project.title }}
</template>
<template #title-suffix>
<ProjectStatusBadge v-if="member || project.status !== 'approved'" :status="project.status" />
</template>
<template #summary>
{{ project.description }}
</template>
<template #stats>
<div class="flex items-center gap-3 flex-wrap gap-y-0">
<template v-if="isServerProject">
<ServerDetails
v-if="projectV3?.status !== 'draft'"
:online-players="playersOnline"
:status-online="statusOnline"
:recent-plays="javaServer?.verified_plays_2w ?? 0"
/>
</template>
<template v-else>
<div
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectDownloads, {
count: project.downloads,
}),
)
"
class="flex items-center gap-2 font-semibold cursor-help"
>
<DownloadIcon class="h-6 w-6 text-secondary" />
{{ formatCompactNumber(project.downloads) }}
</div>
<div
v-tooltip="
capitalizeString(
formatMessage(commonMessages.projectFollowers, {
count: project.followers,
}),
)
"
class="flex items-center gap-2 cursor-help"
:class="{ 'md:border-r': project.categories.length > 0 }"
>
<HeartIcon class="h-6 w-6 text-secondary" />
<span class="font-semibold">
{{ formatCompactNumber(project.followers) }}
</span>
</div>
</template>
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
<div class="flex flex-wrap gap-2">
<TagItem
v-for="(category, index) in project.categories"
:key="index"
:action="() => router.push(`${searchUrl}?f=categories:${category}`)"
>
<FormattedTag :tag="category" />
</TagItem>
</div>
</div>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import { capitalizeString, type Project } from '@modrinth/utils'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useCompactNumber, useVIntl } from '../../composables'
import { commonMessages } from '../../utils'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import FormattedTag from '../base/FormattedTag.vue'
import TagItem from '../base/TagItem.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
import ServerDetails from './server/ServerDetails.vue'
const router = useRouter()
const { formatMessage } = useVIntl()
const { formatCompactNumber } = useCompactNumber()
const props = withDefaults(
defineProps<{
project: Project
member?: boolean
projectV3?: Labrinth.Projects.v3.Project | null
ping?: number
}>(),
{
member: false,
},
)
const searchUrl = computed(
() => `/discover/${isServerProject.value ? 'servers' : `${props.project.project_type}s`}`,
)
const isServerProject = computed(() => !!props.projectV3?.minecraft_server)
const javaServer = computed(() => props.projectV3?.minecraft_java_server)
const javaServerPingData = computed(() => props.projectV3?.minecraft_java_server?.ping?.data)
const playersOnline = computed(() => javaServerPingData.value?.players_online ?? 0)
const statusOnline = computed(() => !!javaServerPingData.value)
</script>
@@ -0,0 +1,117 @@
<template>
<PageHeader
:title="project.title"
:summary="project.description"
@contextmenu="emit('contextmenu', $event)"
>
<template #leading>
<Avatar :src="project.icon_url" :alt="project.title" :tint-by="project.id" size="96px" />
</template>
<template v-if="showStatusBadge" #badges>
<ProjectStatusBadge :status="project.status" />
</template>
<template #metadata>
<PageHeaderMetadata>
<template v-if="projectV3?.minecraft_server != null">
<ServerDetails
v-if="projectV3?.status !== 'draft'"
:online-players="projectV3?.minecraft_java_server?.ping?.data?.players_online ?? 0"
:status-online="!!projectV3?.minecraft_java_server?.ping?.data"
:recent-plays="projectV3?.minecraft_java_server?.verified_plays_2w ?? 0"
/>
</template>
<template v-else>
<PageHeaderMetadataNumberItem
:icon="DownloadIcon"
:value="project.downloads"
:label="formatMessage(messages.downloadsStat, { count: project.downloads })"
:tooltip="formatNumber(project.downloads)"
/>
<PageHeaderMetadataNumberItem
:icon="HeartIcon"
:value="project.followers"
:label="formatMessage(messages.followersStat, { count: project.followers })"
:tooltip="formatNumber(project.followers)"
/>
</template>
<PageHeaderMetadataTagsItem v-if="project.categories.length > 0" class="hidden md:flex">
<TagItem
v-for="category in project.categories"
:key="category"
:action="() => emit('category', category)"
>
<FormattedTag :tag="category" />
</TagItem>
</PageHeaderMetadataTagsItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<slot name="actions" />
</PageHeaderActions>
</template>
</PageHeader>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
import { defineMessages, useFormatNumber, useVIntl } from '../../composables'
import Avatar from '../base/Avatar.vue'
import FormattedTag from '../base/FormattedTag.vue'
import PageHeader from '../base/page-header/index.vue'
import PageHeaderMetadata from '../base/page-header/metadata/index.vue'
import PageHeaderMetadataNumberItem from '../base/page-header/metadata/page-header-metadata-number-item.vue'
import PageHeaderMetadataTagsItem from '../base/page-header/metadata/page-header-metadata-tags-item.vue'
import PageHeaderActions from '../base/page-header/page-header-actions.vue'
import TagItem from '../base/TagItem.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
import ServerDetails from './server/ServerDetails.vue'
type HeaderProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'title' | 'description' | 'status' | 'downloads' | 'followers' | 'categories'
> & {
icon_url?: string | null
}
type HeaderProjectV3 = Pick<
Labrinth.Projects.v3.Project,
'status' | 'minecraft_server' | 'minecraft_java_server'
>
withDefaults(
defineProps<{
project: HeaderProject
projectV3?: HeaderProjectV3 | null
showStatusBadge?: boolean
}>(),
{
projectV3: null,
showStatusBadge: false,
},
)
const emit = defineEmits<{
category: [category: string]
contextmenu: [event: MouseEvent]
}>()
const messages = defineMessages({
downloadsStat: {
id: 'project.stats.downloads-label',
defaultMessage: '{count, plural, one {download} other {downloads}}',
},
followersStat: {
id: 'project.stats.followers-label',
defaultMessage: '{count, plural, one {follower} other {followers}}',
},
})
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
</script>
+1 -1
View File
@@ -6,8 +6,8 @@ export { default as ProjectCard } from './card/ProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectCardList } from './ProjectCardList.vue'
export { default as ProjectCombobox } from './ProjectCombobox.vue'
export { default as ProjectHeader } from './ProjectHeader.vue'
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
export { default as ProjectPageHeader } from './ProjectPageHeader.vue'
export { default as ProjectPageVersions } from './ProjectPageVersions.vue'
export { default as ProjectSidebarCompatibility } from './ProjectSidebarCompatibility.vue'
export { default as ProjectSidebarCreators } from './ProjectSidebarCreators.vue'
@@ -1,65 +0,0 @@
<template>
<div class="contents">
<ButtonStyled circular type="transparent" size="large">
<TeleportOverflowMenu :options="menuOptions">
<MoreVerticalIcon aria-hidden="true" />
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #copy-id>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
<span>Copy ID</span>
</template>
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, MoreVerticalIcon, ServerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ButtonStyled } from '#ui/components'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { injectModrinthServerContext } from '#ui/providers'
const props = withDefaults(
defineProps<{
disabled?: boolean
showCopyIdAction?: boolean
showDebugInfo?: boolean
uptimeSeconds?: number
}>(),
{
disabled: false,
showCopyIdAction: false,
showDebugInfo: false,
uptimeSeconds: 0,
},
)
const router = useRouter()
const { serverId } = injectModrinthServerContext()
const menuOptions = computed(() => [
{
id: 'allServers',
label: 'All servers',
icon: ServerIcon,
action: () => router.push('/hosting/manage'),
},
{
id: 'copy-id',
label: 'Copy ID',
icon: ClipboardCopyIcon,
action: () => copyId(),
shown: props.showCopyIdAction,
},
])
async function copyId() {
await navigator.clipboard.writeText(serverId)
}
</script>
@@ -1,182 +0,0 @@
<template>
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
<ContentPageHeader :class="props.headerClass">
<template #icon>
<ServerIcon
:image="headerImage"
:class="isNuxt ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl'"
/>
</template>
<template #title>
{{ props.server?.name || 'Server' }}
</template>
<template #stats>
<div
v-if="props.server?.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<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-if="showProject"
class="flex items-center gap-1.5 font-medium text-primary text-nowrap"
>
Linked to
<Avatar
:src="props.serverProject?.icon_url ?? undefined"
:alt="props.serverProject?.title ?? ''"
size="24px"
/>
<AutoLink :to="serverProjectLink" class="truncate text-primary hover:underline">
{{ props.serverProject?.title }}
</AutoLink>
</div>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</div>
</template>
<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 { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
import LoaderIcon from '../icons/LoaderIcon.vue'
type ServerProjectSummary = {
id: string
slug?: string | null
title: string
icon_url?: string | null
}
const props = withDefaults(
defineProps<{
server: Archon.Servers.v0.Server | null | undefined
serverImage?: string | null
serverProject?: ServerProjectSummary | null
serverProjectLink?: string
uptimeSeconds?: number
showUptime?: boolean
backHref?: string
breadcrumbClass?: string
headerClass?: string
}>(),
{
serverImage: null,
serverProject: null,
serverProjectLink: '',
uptimeSeconds: 0,
showUptime: true,
backHref: '/hosting/manage',
breadcrumbClass: 'breadcrumb goto-link flex w-fit items-center',
headerClass: '',
},
)
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { serverId } = injectModrinthServerContext()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
hideSubdomainLabel: false,
})
const headerImage = computed(() => {
if (props.server?.is_medal) {
return 'https://cdn-raw.modrinth.com/medal_icon.webp'
}
return props.serverImage ?? undefined
})
const showUptime = computed(() => props.showUptime && (props.uptimeSeconds ?? 0) > 0)
const formattedUptime = computed(() => {
const uptime = props.uptimeSeconds ?? 0
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()
})
const showProject = computed(() => !!props.serverProject)
const serverProjectLink = computed(() => {
if (props.serverProjectLink) {
return props.serverProjectLink
}
if (!props.serverProject) {
return ''
}
return `/project/${props.serverProject.slug ?? props.serverProject.id}`
})
function copyServerAddress() {
if (!props.server?.net?.domain) return
navigator.clipboard.writeText(`${props.server.net.domain}.modrinth.gg`)
addNotification({
title: 'Server address copied',
text: "Your server's address has been copied to your clipboard.",
type: 'success',
})
}
</script>
@@ -1,3 +1 @@
export { default as PanelServerActionButton } from './PanelServerActionButton.vue'
export { default as PanelServerOverflowMenu } from './PanelServerOverflowMenu.vue'
export { default as ServerManageHeader } from './ServerManageHeader.vue'