mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 03:55:59 +00:00
feat: migrate all headers to use PageHeader shared component.
This commit is contained in:
@@ -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>
|
||||
@@ -85,13 +85,69 @@
|
||||
>
|
||||
{{ props.header }}
|
||||
</h1>
|
||||
<template v-for="badge in props.badges" :key="badge.id">
|
||||
<component
|
||||
:is="badge.component"
|
||||
v-if="badge.component"
|
||||
:class="badge.class"
|
||||
v-bind="badge.componentProps"
|
||||
/>
|
||||
<AutoLink
|
||||
v-else-if="badge.to"
|
||||
v-tooltip="badge.tooltip"
|
||||
:to="badge.to"
|
||||
:aria-label="badge.ariaLabel ?? badge.label"
|
||||
:class="badgeClass(badge, true)"
|
||||
:style="badge.style"
|
||||
>
|
||||
<component
|
||||
:is="badge.icon"
|
||||
v-if="badge.icon"
|
||||
aria-hidden="true"
|
||||
v-bind="badge.iconProps"
|
||||
/>
|
||||
<span>{{ badge.label }}</span>
|
||||
</AutoLink>
|
||||
<button
|
||||
v-else-if="badge.onClick"
|
||||
v-tooltip="badge.tooltip"
|
||||
type="button"
|
||||
:aria-label="badge.ariaLabel ?? badge.label"
|
||||
:class="badgeClass(badge, true)"
|
||||
:style="badge.style"
|
||||
@click="badge.onClick"
|
||||
>
|
||||
<component
|
||||
:is="badge.icon"
|
||||
v-if="badge.icon"
|
||||
aria-hidden="true"
|
||||
v-bind="badge.iconProps"
|
||||
/>
|
||||
<span>{{ badge.label }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip="badge.tooltip"
|
||||
:aria-label="badge.ariaLabel"
|
||||
:class="badgeClass(badge)"
|
||||
:style="badge.style"
|
||||
>
|
||||
<component
|
||||
:is="badge.icon"
|
||||
v-if="badge.icon"
|
||||
aria-hidden="true"
|
||||
v-bind="badge.iconProps"
|
||||
/>
|
||||
<span>{{ badge.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<p
|
||||
v-if="props.summary"
|
||||
v-if="props.summary || $slots.summary"
|
||||
class="m-0 max-w-[44rem] empty:hidden"
|
||||
:class="[props.disableLineClamp ? '' : 'line-clamp-2']"
|
||||
>
|
||||
{{ props.summary }}
|
||||
<slot name="summary">{{ props.summary }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -99,11 +155,14 @@
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<template v-for="(item, index) in props.metadata" :key="item.id">
|
||||
<BulletDivider v-if="index > 0" class="shrink-0" />
|
||||
<div v-if="item.type === 'custom'" :class="item.class">
|
||||
<slot :name="metadataSlotName(item)" :item="item" />
|
||||
</div>
|
||||
<AutoLink
|
||||
v-if="item.to"
|
||||
v-else-if="item.to"
|
||||
v-tooltip="item.tooltip"
|
||||
:to="item.to"
|
||||
:aria-label="item.ariaLabel ?? item.label"
|
||||
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||
:class="metadataClass(item, true)"
|
||||
>
|
||||
<component
|
||||
@@ -119,7 +178,7 @@
|
||||
v-else-if="item.onClick"
|
||||
v-tooltip="item.tooltip"
|
||||
type="button"
|
||||
:aria-label="item.ariaLabel ?? item.label"
|
||||
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||
:class="metadataClass(item, true)"
|
||||
@click="item.onClick"
|
||||
>
|
||||
@@ -155,8 +214,14 @@
|
||||
|
||||
<div v-if="props.actions.length" class="flex flex-wrap gap-2 items-center">
|
||||
<template v-for="action in props.actions" :key="action.id">
|
||||
<component
|
||||
:is="action.component"
|
||||
v-if="action.component"
|
||||
:class="action.class"
|
||||
v-bind="action.componentProps"
|
||||
/>
|
||||
<Tooltip
|
||||
v-if="action.prompt"
|
||||
v-else-if="action.prompt"
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
:shown="action.prompt.shown"
|
||||
@@ -180,12 +245,29 @@
|
||||
:type="action.type ?? 'standard'"
|
||||
:circular="action.circular ?? action.labelHidden ?? false"
|
||||
>
|
||||
<TeleportOverflowMenu
|
||||
v-if="action.menuActions?.length"
|
||||
:options="action.menuActions"
|
||||
:tooltip="action.tooltip"
|
||||
:aria-label="actionLabel(action)"
|
||||
:disabled="action.disabled"
|
||||
@open="dismissActionPrompt(action)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
v-if="action.icon"
|
||||
:class="action.iconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="action.iconProps"
|
||||
/>
|
||||
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||
</TeleportOverflowMenu>
|
||||
<AutoLink
|
||||
v-if="action.to"
|
||||
v-else-if="action.to"
|
||||
v-tooltip="action.tooltip"
|
||||
:to="action.to"
|
||||
:aria-label="actionLabel(action)"
|
||||
@click="dismissActionPrompt(action)"
|
||||
@click="(event) => handleActionClick(action, event)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
@@ -202,7 +284,7 @@
|
||||
type="button"
|
||||
:disabled="action.disabled"
|
||||
:aria-label="actionLabel(action)"
|
||||
@click="handleActionClick(action)"
|
||||
@click="(event) => handleActionClick(action, event)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
@@ -217,8 +299,16 @@
|
||||
<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">
|
||||
<h3
|
||||
class="m-0 flex items-center gap-2 whitespace-nowrap text-base font-bold text-contrast"
|
||||
>
|
||||
{{ action.prompt.title }}
|
||||
<span
|
||||
v-if="action.prompt.badge"
|
||||
class="inline-flex items-center rounded-full border border-solid border-brand-highlight bg-brand-highlight px-2 py-1 text-sm font-semibold leading-none text-brand"
|
||||
>
|
||||
{{ action.prompt.badge }}
|
||||
</span>
|
||||
</h3>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button
|
||||
@@ -232,6 +322,9 @@
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
{{ action.prompt.description }}
|
||||
</p>
|
||||
<p v-if="action.prompt.footer" class="m-0 text-wrap text-sm font-bold text-primary">
|
||||
{{ action.prompt.footer }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
@@ -253,11 +346,28 @@
|
||||
:type="action.type ?? 'standard'"
|
||||
:circular="action.circular ?? action.labelHidden ?? false"
|
||||
>
|
||||
<TeleportOverflowMenu
|
||||
v-if="action.menuActions?.length"
|
||||
:options="action.menuActions"
|
||||
:tooltip="action.tooltip"
|
||||
:aria-label="actionLabel(action)"
|
||||
:disabled="action.disabled"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
v-if="action.icon"
|
||||
:class="action.iconClass"
|
||||
aria-hidden="true"
|
||||
v-bind="action.iconProps"
|
||||
/>
|
||||
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||
</TeleportOverflowMenu>
|
||||
<AutoLink
|
||||
v-if="action.to"
|
||||
v-else-if="action.to"
|
||||
v-tooltip="action.tooltip"
|
||||
:to="action.to"
|
||||
:aria-label="actionLabel(action)"
|
||||
@click="(event) => handleActionClick(action, event)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
@@ -274,7 +384,7 @@
|
||||
type="button"
|
||||
:disabled="action.disabled"
|
||||
:aria-label="actionLabel(action)"
|
||||
@click="handleActionClick(action)"
|
||||
@click="(event) => handleActionClick(action, event)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
@@ -296,11 +406,14 @@
|
||||
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<template v-for="(item, index) in props.metadata" :key="item.id">
|
||||
<BulletDivider v-if="index > 0" class="shrink-0" />
|
||||
<div v-if="item.type === 'custom'" :class="item.class">
|
||||
<slot :name="metadataSlotName(item)" :item="item" />
|
||||
</div>
|
||||
<AutoLink
|
||||
v-if="item.to"
|
||||
v-else-if="item.to"
|
||||
v-tooltip="item.tooltip"
|
||||
:to="item.to"
|
||||
:aria-label="item.ariaLabel ?? item.label"
|
||||
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||
:class="metadataClass(item, true)"
|
||||
>
|
||||
<component
|
||||
@@ -316,7 +429,7 @@
|
||||
v-else-if="item.onClick"
|
||||
v-tooltip="item.tooltip"
|
||||
type="button"
|
||||
:aria-label="item.ariaLabel ?? item.label"
|
||||
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||
:class="metadataClass(item, true)"
|
||||
@click="item.onClick"
|
||||
>
|
||||
@@ -363,6 +476,9 @@ import Avatar from './Avatar.vue'
|
||||
import BulletDivider from './BulletDivider.vue'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import JoinedButtons, { type JoinedButtonAction } from './JoinedButtons.vue'
|
||||
import TeleportOverflowMenu, {
|
||||
type Item as TeleportOverflowMenuItem,
|
||||
} from './TeleportOverflowMenu.vue'
|
||||
|
||||
type PageHeaderTarget = string | RouteLocationRaw
|
||||
type ButtonColor =
|
||||
@@ -401,7 +517,7 @@ type PageHeaderLeading = {
|
||||
class?: string
|
||||
wrapperClass?: string
|
||||
to?: PageHeaderTarget
|
||||
onClick?: () => void | Promise<void>
|
||||
onClick?: (event?: MouseEvent) => void | Promise<void>
|
||||
tooltip?: string
|
||||
ariaLabel?: string
|
||||
color?: ButtonColor
|
||||
@@ -409,9 +525,25 @@ type PageHeaderLeading = {
|
||||
buttonType?: ButtonType
|
||||
}
|
||||
|
||||
type PageHeaderBadge = {
|
||||
id: string
|
||||
label?: string
|
||||
icon?: Component
|
||||
iconProps?: Record<string, unknown>
|
||||
component?: Component
|
||||
componentProps?: Record<string, unknown>
|
||||
tooltip?: string
|
||||
ariaLabel?: string
|
||||
to?: PageHeaderTarget
|
||||
onClick?: () => void | Promise<void>
|
||||
class?: string
|
||||
style?: Record<string, string>
|
||||
}
|
||||
|
||||
type PageHeaderMetadataItem = {
|
||||
id: string
|
||||
label: string
|
||||
type?: 'text' | 'custom'
|
||||
label?: string
|
||||
icon?: Component
|
||||
iconProps?: Record<string, unknown>
|
||||
tooltip?: string
|
||||
@@ -424,6 +556,8 @@ type PageHeaderMetadataItem = {
|
||||
type PageHeaderActionPrompt = {
|
||||
title: string
|
||||
description: string
|
||||
badge?: string
|
||||
footer?: string
|
||||
dismissLabel?: string
|
||||
shown?: boolean
|
||||
placement?: string
|
||||
@@ -433,6 +567,9 @@ type PageHeaderActionPrompt = {
|
||||
type PageHeaderAction = {
|
||||
id: string
|
||||
label: string
|
||||
component?: Component
|
||||
componentProps?: Record<string, unknown>
|
||||
class?: string
|
||||
icon?: Component
|
||||
iconProps?: Record<string, unknown>
|
||||
iconClass?: string
|
||||
@@ -447,6 +584,7 @@ type PageHeaderAction = {
|
||||
size?: ButtonSize
|
||||
type?: ButtonType
|
||||
joinedActions?: JoinedButtonAction[]
|
||||
menuActions?: TeleportOverflowMenuItem[]
|
||||
primaryDisabled?: boolean
|
||||
dropdownDisabled?: boolean
|
||||
primaryMuted?: boolean
|
||||
@@ -458,6 +596,7 @@ const props = withDefaults(
|
||||
header: string
|
||||
summary?: string | null
|
||||
leading?: PageHeaderLeading | PageHeaderLeading[] | null
|
||||
badges?: PageHeaderBadge[]
|
||||
metadata?: PageHeaderMetadataItem[]
|
||||
actions?: PageHeaderAction[]
|
||||
headerClass?: string
|
||||
@@ -472,6 +611,7 @@ const props = withDefaults(
|
||||
{
|
||||
summary: null,
|
||||
leading: null,
|
||||
badges: () => [],
|
||||
metadata: () => [],
|
||||
actions: () => [],
|
||||
headerClass: '',
|
||||
@@ -506,6 +646,18 @@ function metadataClass(item: PageHeaderMetadataItem, interactive = false) {
|
||||
]
|
||||
}
|
||||
|
||||
function metadataSlotName(item: PageHeaderMetadataItem) {
|
||||
return `metadata-${item.id}`
|
||||
}
|
||||
|
||||
function badgeClass(badge: PageHeaderBadge, interactive = false) {
|
||||
return [
|
||||
'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 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
interactive ? 'm-0 cursor-pointer hover:underline' : '',
|
||||
badge.class,
|
||||
]
|
||||
}
|
||||
|
||||
function actionLabel(action: PageHeaderAction) {
|
||||
return action.ariaLabel ?? action.tooltip ?? action.label
|
||||
}
|
||||
@@ -516,9 +668,9 @@ function dismissActionPrompt(action: PageHeaderAction) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleActionClick(action: PageHeaderAction) {
|
||||
function handleActionClick(action: PageHeaderAction, event?: MouseEvent) {
|
||||
dismissActionPrompt(action)
|
||||
void action.onClick?.()
|
||||
void action.onClick?.(event)
|
||||
}
|
||||
|
||||
function joinedActionColor(color?: ButtonColor) {
|
||||
|
||||
@@ -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
|
||||
@@ -51,39 +59,41 @@
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
: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-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 +105,48 @@
|
||||
</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
|
||||
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 +154,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 +226,7 @@ const calculateMenuPosition = () => {
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (props.disabled) return
|
||||
if (!props.hoverable) {
|
||||
if (isOpen.value) {
|
||||
closeMenu()
|
||||
@@ -201,6 +237,7 @@ const toggleMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
disableBodyScroll()
|
||||
@@ -218,15 +255,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 +310,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 +472,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>
|
||||
|
||||
@@ -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'
|
||||
@@ -96,6 +95,11 @@ export type {
|
||||
TimeFramePreset,
|
||||
} from './TimeFramePicker.vue'
|
||||
export { default as TimeFramePicker } from './TimeFramePicker.vue'
|
||||
export type {
|
||||
Item as TeleportOverflowMenuItem,
|
||||
Option as TeleportOverflowMenuOption,
|
||||
} from './TeleportOverflowMenu.vue'
|
||||
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as Toggle } from './Toggle.vue'
|
||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<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>
|
||||
<PageHeader
|
||||
:header="project.title"
|
||||
:summary="project.description"
|
||||
:leading="leadingItem"
|
||||
:badges="headerBadges"
|
||||
:metadata="headerMetadata"
|
||||
:actions="actions"
|
||||
:disable-line-clamp="disableLineClamp"
|
||||
>
|
||||
<template #metadata-project-stats>
|
||||
<div class="flex items-center gap-3 flex-wrap gap-y-0">
|
||||
<template v-if="isServerProject">
|
||||
<ServerDetails
|
||||
@@ -66,24 +62,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</PageHeader>
|
||||
</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 type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
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 type { JoinedButtonAction } from '../base/JoinedButtons.vue'
|
||||
import PageHeader from '../base/PageHeader.vue'
|
||||
import TagItem from '../base/TagItem.vue'
|
||||
import type { Item as TeleportOverflowMenuItem } from '../base/TeleportOverflowMenu.vue'
|
||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||
import ServerDetails from './server/ServerDetails.vue'
|
||||
|
||||
@@ -91,18 +87,77 @@ const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { formatCompactNumber } = useCompactNumber()
|
||||
|
||||
type HeaderAction = {
|
||||
id: string
|
||||
label: string
|
||||
component?: Component
|
||||
componentProps?: Record<string, unknown>
|
||||
class?: string
|
||||
icon?: Component
|
||||
iconProps?: Record<string, unknown>
|
||||
iconClass?: string
|
||||
tooltip?: string
|
||||
ariaLabel?: string
|
||||
to?: string | RouteLocationRaw
|
||||
onClick?: (event?: MouseEvent) => void | Promise<void>
|
||||
disabled?: boolean
|
||||
labelHidden?: boolean
|
||||
circular?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||
size?: 'standard' | 'large' | 'small'
|
||||
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
|
||||
joinedActions?: JoinedButtonAction[]
|
||||
menuActions?: TeleportOverflowMenuItem[]
|
||||
primaryDisabled?: boolean
|
||||
dropdownDisabled?: boolean
|
||||
primaryMuted?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
project: Project
|
||||
member?: boolean
|
||||
projectV3?: Labrinth.Projects.v3.Project | null
|
||||
ping?: number
|
||||
actions?: HeaderAction[]
|
||||
disableLineClamp?: boolean
|
||||
}>(),
|
||||
{
|
||||
member: false,
|
||||
actions: () => [],
|
||||
disableLineClamp: false,
|
||||
},
|
||||
)
|
||||
|
||||
const leadingItem = computed(() => ({
|
||||
type: 'avatar' as const,
|
||||
src: props.project.icon_url,
|
||||
alt: props.project.title,
|
||||
avatarSize: '96px',
|
||||
}))
|
||||
|
||||
const headerBadges = computed(() =>
|
||||
props.member || props.project.status !== 'approved'
|
||||
? [
|
||||
{
|
||||
id: 'status',
|
||||
component: ProjectStatusBadge,
|
||||
componentProps: {
|
||||
status: props.project.status,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
const headerMetadata = [
|
||||
{
|
||||
id: 'project-stats',
|
||||
type: 'custom' as const,
|
||||
class: 'contents',
|
||||
},
|
||||
]
|
||||
|
||||
const searchUrl = computed(
|
||||
() => `/discover/${isServerProject.value ? 'servers' : `${props.project.project_type}s`}`,
|
||||
)
|
||||
|
||||
@@ -746,21 +746,38 @@ const { data: serverFull } = useQuery({
|
||||
queryFn: () => client.archon.servers_v1.get(props.serverId),
|
||||
})
|
||||
|
||||
const routeInstanceId = ref<string | null>(getRouteParam(route.params.instance_id))
|
||||
|
||||
watch(
|
||||
() => [route.path, route.params.id, route.params.instance_id, props.serverId] as const,
|
||||
() => {
|
||||
if (!isRouteForManagedServer()) return
|
||||
routeInstanceId.value = getRouteParam(route.params.instance_id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const worldId = computed(() => {
|
||||
const routeInstanceId = getRouteParam(route.params.instance_id)
|
||||
if (routeInstanceId) return routeInstanceId
|
||||
if (routeInstanceId.value) return routeInstanceId.value
|
||||
if (!serverFull.value) return null
|
||||
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
|
||||
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
|
||||
})
|
||||
|
||||
const isInstanceDetailRoute = computed(() => !!getRouteParam(route.params.instance_id))
|
||||
const isInstanceDetailRoute = computed(() => !!routeInstanceId.value)
|
||||
const activeWorldName = computed(() => {
|
||||
if (!serverFull.value) return null
|
||||
const activeWorld = serverFull.value.worlds.find((world) => world.is_active)
|
||||
return activeWorld?.name ?? serverFull.value.worlds[0]?.name ?? null
|
||||
})
|
||||
|
||||
function isRouteForManagedServer() {
|
||||
const routeServerId = getRouteParam(route.params.id)
|
||||
if (!routeServerId || routeServerId !== props.serverId) return false
|
||||
const basePath = `/hosting/manage/${encodeURIComponent(routeServerId)}`
|
||||
return route.path === basePath || route.path.startsWith(`${basePath}/`)
|
||||
}
|
||||
|
||||
function getRouteParam(param: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(param)) return param[0] ?? null
|
||||
return param ?? null
|
||||
|
||||
@@ -73,7 +73,8 @@ const messages = defineMessages({
|
||||
},
|
||||
instanceInfoFiles: {
|
||||
id: 'servers.manage.instances.info.files',
|
||||
defaultMessage: 'Each instance has its own server files, worlds, installed content, and settings.',
|
||||
defaultMessage:
|
||||
'Each instance has its own server files, worlds, installed content, and settings.',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -4670,6 +4670,15 @@
|
||||
"servers.manage.instance.settings": {
|
||||
"defaultMessage": "Instance settings"
|
||||
},
|
||||
"servers.manage.instances-hint.description": {
|
||||
"defaultMessage": "Your server state has been converted into an instance."
|
||||
},
|
||||
"servers.manage.instances-hint.dismiss": {
|
||||
"defaultMessage": "Don't show again"
|
||||
},
|
||||
"servers.manage.instances-hint.title": {
|
||||
"defaultMessage": "New: Server Instances!"
|
||||
},
|
||||
"servers.manage.instances.card.active": {
|
||||
"defaultMessage": "Active"
|
||||
},
|
||||
@@ -4700,6 +4709,18 @@
|
||||
"servers.manage.instances.card.settings": {
|
||||
"defaultMessage": "Instance settings"
|
||||
},
|
||||
"servers.manage.instances.info.definition": {
|
||||
"defaultMessage": "An instance is a separate server setup."
|
||||
},
|
||||
"servers.manage.instances.info.files": {
|
||||
"defaultMessage": "Each instance has its own server files, worlds, installed content, and settings."
|
||||
},
|
||||
"servers.manage.instances.info.header": {
|
||||
"defaultMessage": "What is an instance?"
|
||||
},
|
||||
"servers.manage.instances.info.switching": {
|
||||
"defaultMessage": "You can switch which instance your server runs."
|
||||
},
|
||||
"servers.manage.instances.slot-name": {
|
||||
"defaultMessage": "Instance #{index}"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import {
|
||||
AffiliateIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
GlobeIcon,
|
||||
HeartIcon,
|
||||
LeftArrowIcon,
|
||||
LinkIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -19,7 +23,7 @@ import ServerIcon from '../../components/servers/icons/ServerIcon.vue'
|
||||
const noop = () => undefined
|
||||
|
||||
const meta = {
|
||||
title: 'Base/PageHeader Prototype',
|
||||
title: 'Base/PageHeader',
|
||||
component: PageHeader,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
@@ -88,6 +92,67 @@ export const AppInstanceHeader: Story = {
|
||||
labelHidden: true,
|
||||
type: 'transparent',
|
||||
tooltip: 'More actions',
|
||||
menuActions: [
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: 'Open folder',
|
||||
icon: GlobeIcon,
|
||||
action: noop,
|
||||
},
|
||||
{
|
||||
id: 'copy-id',
|
||||
label: 'Copy ID',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: noop,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const CreatorHeader: Story = {
|
||||
args: {
|
||||
header: 'Prospector',
|
||||
summary: 'A Modrinth creator with a handful of popular projects.',
|
||||
leading: {
|
||||
type: 'avatar',
|
||||
src: null,
|
||||
alt: 'Prospector',
|
||||
avatarSize: '96px',
|
||||
circle: true,
|
||||
},
|
||||
badges: [
|
||||
{
|
||||
id: 'affiliate',
|
||||
label: 'Affiliate',
|
||||
icon: AffiliateIcon,
|
||||
class: 'border-brand-highlight bg-brand-highlight text-brand',
|
||||
},
|
||||
],
|
||||
metadata: [
|
||||
{
|
||||
id: 'projects',
|
||||
label: '12 projects',
|
||||
icon: Gamepad2Icon,
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
label: '4.2M downloads',
|
||||
icon: DownloadIcon,
|
||||
},
|
||||
{
|
||||
id: 'followers',
|
||||
label: '82K followers',
|
||||
icon: HeartIcon,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: 'follow',
|
||||
label: 'Follow',
|
||||
icon: HeartIcon,
|
||||
color: 'brand',
|
||||
onClick: noop,
|
||||
},
|
||||
],
|
||||
@@ -259,3 +324,30 @@ export const ServerPanelInstanceHeader: Story = {
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const CustomMetadata: Story = {
|
||||
render: () => ({
|
||||
components: { PageHeader, DownloadIcon, HeartIcon },
|
||||
template: `
|
||||
<PageHeader
|
||||
header="Custom Metadata Project"
|
||||
summary="Custom metadata is reserved for rich stat rows that cannot be represented by icon and label data."
|
||||
:leading="{ type: 'avatar', src: null, alt: 'Custom Metadata Project', avatarSize: '96px' }"
|
||||
:metadata="[{ id: 'project-stats', type: 'custom', class: 'contents' }]"
|
||||
>
|
||||
<template #metadata-project-stats>
|
||||
<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 downloads
|
||||
</div>
|
||||
<div class="flex items-center gap-2 font-semibold">
|
||||
<HeartIcon class="h-6 w-6 text-secondary" />
|
||||
50K followers
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</PageHeader>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user