mirror of
https://github.com/modrinth/code.git
synced 2026-08-25 00:55:25 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+61
@@ -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>
|
||||
+33
@@ -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>
|
||||
+66
@@ -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>
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
Reference in New Issue
Block a user