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'
@@ -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'),
+6
View File
@@ -3506,6 +3506,12 @@
"project.settings.view.title": {
"defaultMessage": "View"
},
"project.stats.downloads-label": {
"defaultMessage": "{count, plural, one {download} other {downloads}}"
},
"project.stats.followers-label": {
"defaultMessage": "{count, plural, one {follower} other {followers}}"
},
"project.versions.channel.alpha.symbol": {
"defaultMessage": "A"
},
@@ -1,63 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Avatar from '../../components/base/Avatar.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentPageHeader from '../../components/base/ContentPageHeader.vue'
const meta = {
title: 'Base/ContentPageHeader',
component: ContentPageHeader,
} satisfies Meta<typeof ContentPageHeader>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { ContentPageHeader, Avatar, ButtonStyled },
template: `
<ContentPageHeader>
<template #icon>
<Avatar size="64px" />
</template>
<template #title>Project Name</template>
<template #summary>A brief description of the project goes here.</template>
<template #stats>
<span>1.2M downloads</span>
<span>50K followers</span>
</template>
<template #actions>
<ButtonStyled color="brand">
<button>Follow</button>
</ButtonStyled>
</template>
</ContentPageHeader>
`,
}),
}
export const WithTitleSuffix: Story = {
render: () => ({
components: { ContentPageHeader, Avatar, ButtonStyled },
template: `
<ContentPageHeader>
<template #icon>
<Avatar size="64px" />
</template>
<template #title>Featured Project</template>
<template #title-suffix>
<span class="px-2 py-1 bg-brand-highlight text-brand rounded-full text-sm">Featured</span>
</template>
<template #summary>This project has been featured by the Modrinth team.</template>
<template #actions>
<ButtonStyled color="brand">
<button>Download</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button>Share</button>
</ButtonStyled>
</template>
</ContentPageHeader>
`,
}),
}
@@ -0,0 +1,458 @@
import {
AffiliateIcon,
BoxIcon,
CalendarIcon,
ClipboardCopyIcon,
DownloadIcon,
GlobeIcon,
HeartIcon,
LeftArrowIcon,
LinkIcon,
MoreVerticalIcon,
PlayIcon,
SettingsIcon,
SlashIcon,
StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
} from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import AutoLink from '../../components/base/AutoLink.vue'
import Avatar from '../../components/base/Avatar.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import FormattedTag from '../../components/base/FormattedTag.vue'
import JoinedButtons from '../../components/base/JoinedButtons.vue'
import PageHeader from '../../components/base/page-header/index.vue'
import PageHeaderMetadata from '../../components/base/page-header/metadata/index.vue'
import PageHeaderMetadataItem from '../../components/base/page-header/metadata/page-header-metadata-item.vue'
import PageHeaderMetadataNumberItem from '../../components/base/page-header/metadata/page-header-metadata-number-item.vue'
import PageHeaderMetadataTagsItem from '../../components/base/page-header/metadata/page-header-metadata-tags-item.vue'
import PageHeaderMetadataTimeItem from '../../components/base/page-header/metadata/page-header-metadata-time-item.vue'
import PageHeaderActions from '../../components/base/page-header/page-header-actions.vue'
import PageHeaderBadgeItem from '../../components/base/page-header/page-header-badge-item.vue'
import TagItem from '../../components/base/TagItem.vue'
import TeleportOverflowMenu from '../../components/base/TeleportOverflowMenu.vue'
import LoaderIcon from '../../components/servers/icons/LoaderIcon.vue'
import ServerIcon from '../../components/servers/icons/ServerIcon.vue'
const noop = () => undefined
const categories = ['adventure', 'magic', 'technology']
const joinedDate = new Date(Date.now() - 1000 * 60 * 60 * 24 * 365 * 6)
const menuActions = [
{
id: 'open-folder',
label: 'Open folder',
icon: GlobeIcon,
action: noop,
},
{
id: 'copy-id',
label: 'Copy ID',
icon: ClipboardCopyIcon,
action: noop,
},
]
const joinedActions = [
{
id: 'stop',
label: 'Stop',
icon: StopCircleIcon,
action: noop,
},
{
id: 'kill_server',
label: 'Kill server',
icon: SlashIcon,
action: noop,
},
]
const pageHeaderIcons = {
AffiliateIcon,
BoxIcon,
CalendarIcon,
DownloadIcon,
Gamepad2Icon,
GlobeIcon,
HeartIcon,
LeftArrowIcon,
LinkIcon,
MoreVerticalIcon,
PlayIcon,
SettingsIcon,
TimerIcon,
}
const pageHeaderComponents = {
AutoLink,
Avatar,
ButtonStyled,
FormattedTag,
JoinedButtons,
PageHeader,
PageHeaderActions,
PageHeaderBadgeItem,
PageHeaderMetadata,
PageHeaderMetadataItem,
PageHeaderMetadataNumberItem,
PageHeaderMetadataTagsItem,
PageHeaderMetadataTimeItem,
TagItem,
TeleportOverflowMenu,
...pageHeaderIcons,
}
const meta = {
title: 'Base/PageHeader',
component: PageHeader,
parameters: {
layout: 'padded',
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-full"><story /></div>',
}),
],
} satisfies Meta<typeof PageHeader>
export default meta
type Story = StoryObj<typeof meta>
export const ProjectHeader: Story = {
render: () => ({
components: pageHeaderComponents,
setup() {
return {
...pageHeaderIcons,
categories,
menuActions,
noop,
}
},
template: `
<PageHeader title="Fabric API" summary="Lightweight and modular API providing common hooks and intercompatibility measures for Fabric mods.">
<template #leading>
<Avatar src="" alt="Fabric API" size="96px" tint-by="fabric-api" />
</template>
<template #badges>
<PageHeaderBadgeItem :icon="AffiliateIcon">
Featured
</PageHeaderBadgeItem>
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataNumberItem :icon="DownloadIcon" :value="128452395" label="downloads" />
<PageHeaderMetadataNumberItem :icon="HeartIcon" :value="412300" label="followers" />
<PageHeaderMetadataTagsItem>
<TagItem v-for="category in categories" :key="category" :action="noop">
<FormattedTag :tag="category" />
</TagItem>
</PageHeaderMetadataTagsItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="noop">
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu :options="menuActions" aria-label="More actions">
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
`,
}),
}
export const CreatorHeader: Story = {
render: () => ({
components: pageHeaderComponents,
setup() {
return {
...pageHeaderIcons,
joinedDate,
noop,
}
},
template: `
<PageHeader title="Prospector" summary="A Modrinth creator with a handful of popular projects.">
<template #leading>
<Avatar src="" alt="Prospector" size="96px" tint-by="Prospector" circle />
</template>
<template #badges>
<PageHeaderBadgeItem :icon="AffiliateIcon" class="!border-brand-highlight !bg-brand-highlight !text-brand">
Affiliate
</PageHeaderBadgeItem>
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataNumberItem :icon="BoxIcon" :value="12" label="projects" />
<PageHeaderMetadataNumberItem :icon="DownloadIcon" :value="4200000" label="downloads" />
<PageHeaderMetadataTimeItem :icon="CalendarIcon" :date="joinedDate" label="Joined" />
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="noop">
<HeartIcon />
Follow
</button>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
`,
}),
}
export const AppInstanceHeader: Story = {
render: () => ({
components: {
...pageHeaderComponents,
LoaderIcon,
},
setup() {
return {
...pageHeaderIcons,
LoaderIcon,
menuActions,
noop,
}
},
template: `
<PageHeader title="Create: Astral">
<template #leading>
<Avatar src="" alt="Create: Astral" size="64px" tint-by="create-astral" />
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">Minecraft 1.20.1</PageHeaderMetadataItem>
<PageHeaderMetadataItem :icon="LoaderIcon" :icon-props="{ loader: 'Fabric' }" tooltip="Mod loader">
Fabric 0.16.14
</PageHeaderMetadataItem>
<PageHeaderMetadataItem :icon="TimerIcon" tooltip="Total playtime">12 hours</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="noop">
<PlayIcon />
Play
</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button type="button" aria-label="Instance settings" @click="noop">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="large" type="transparent">
<TeleportOverflowMenu :options="menuActions" aria-label="More actions">
<MoreVerticalIcon />
</TeleportOverflowMenu>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
`,
}),
}
export const BrowseHeader: Story = {
render: () => ({
components: {
...pageHeaderComponents,
LoaderIcon,
},
setup() {
return {
...pageHeaderIcons,
LoaderIcon,
noop,
}
},
template: `
<PageHeader title="Survival SMP" :divider="false" :bottom-padding="false" main-class="items-center" title-class="leading-8" truncate-title>
<template #leading>
<ButtonStyled circular size="large">
<button type="button" aria-label="Back to instance" @click="noop">
<LeftArrowIcon />
</button>
</ButtonStyled>
<Avatar src="" alt="Survival SMP" size="48px" tint-by="survival-smp" />
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem class="!text-primary">Installing content</PageHeaderMetadataItem>
<PageHeaderMetadataItem class="!text-primary" :icon="Gamepad2Icon" tooltip="Minecraft version">
Minecraft 1.20.1
</PageHeaderMetadataItem>
<PageHeaderMetadataItem class="!text-primary" :icon="LoaderIcon" :icon-props="{ loader: 'Fabric' }" tooltip="Mod loader">
Fabric
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
</PageHeader>
`,
}),
}
export const ServerPanelRootHeader: Story = {
render: () => ({
components: {
...pageHeaderComponents,
ServerIcon,
},
setup() {
return {
...pageHeaderIcons,
menuActions,
noop,
serverImage: undefined,
}
},
template: `
<PageHeader title="Survival SMP">
<template #leading>
<ServerIcon class="size-16 !rounded-xl" :image="serverImage" />
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem :icon="GlobeIcon" tooltip="Active instance">My World</PageHeaderMetadataItem>
<PageHeaderMetadataItem :icon="LinkIcon" tooltip="Copy server address" :action="noop">
play.modrinth.gg
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="noop">
<PlayIcon />
Start server
</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button type="button" aria-label="Server settings" @click="noop">
<SettingsIcon />
</button>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
`,
}),
}
export const ServerPanelInstanceHeader: Story = {
render: () => ({
components: {
...pageHeaderComponents,
LoaderIcon,
},
setup() {
return {
...pageHeaderIcons,
joinedActions,
LoaderIcon,
noop,
}
},
template: `
<PageHeader title="My World">
<template #leading>
<ButtonStyled circular size="large">
<button type="button" aria-label="All instances" @click="noop">
<LeftArrowIcon />
</button>
</ButtonStyled>
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">Minecraft 1.20.1</PageHeaderMetadataItem>
<PageHeaderMetadataItem :icon="LoaderIcon" :icon-props="{ loader: 'Fabric' }" tooltip="Mod loader">
Fabric 0.19.2
</PageHeaderMetadataItem>
<PageHeaderMetadataItem :icon="TimerIcon" tooltip="Last activity">Last active 2 weeks ago</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template #actions>
<PageHeaderActions>
<ButtonStyled color="brand" size="large">
<button type="button" @click="noop">
<PlayIcon />
Start instance
</button>
</ButtonStyled>
<JoinedButtons :actions="joinedActions" color="red" size="large" />
<ButtonStyled circular size="large">
<button type="button" aria-label="Instance settings" @click="noop">
<SettingsIcon />
</button>
</ButtonStyled>
</PageHeaderActions>
</template>
</PageHeader>
`,
}),
}
export const CustomMetadata: Story = {
render: () => ({
components: pageHeaderComponents,
setup() {
return {
...pageHeaderIcons,
}
},
template: `
<PageHeader
title="Custom Metadata Project"
summary="Custom metadata stays in markup so page-specific components can be composed without expanding PageHeader itself."
>
<template #leading>
<Avatar src="" alt="Custom Metadata Project" size="96px" tint-by="custom-metadata-project" />
</template>
<template #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem>
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 font-semibold">
<DownloadIcon class="h-6 w-6 text-secondary" />
1.2M
</div>
<div class="flex items-center gap-2 font-semibold">
<HeartIcon class="h-6 w-6 text-secondary" />
50K
</div>
</div>
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
</PageHeader>
`,
}),
}