feat: PageHeader migration start

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:22 +01:00
parent b72c319569
commit 69ba34a8ad
7 changed files with 1038 additions and 378 deletions
+395 -7
View File
@@ -1,11 +1,399 @@
<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
class="flex flex-col gap-2"
:class="[
props.divider ? 'border-0 border-b border-solid border-divider' : '',
props.bottomPadding ? 'pb-4' : '',
props.headerClass,
]"
>
<div class="flex flex-wrap items-start gap-4 max-md:flex-col" :class="props.rowClass">
<div class="flex min-w-0 flex-1 gap-4" :class="props.mainClass">
<div v-if="leadingItems.length" class="contents">
<template v-for="(leadingItem, index) in leadingItems" :key="leadingItem.id ?? index">
<div
v-if="leadingItem.type === 'button'"
:class="
leadingWrapperClass(
leadingItem,
'flex size-16 shrink-0 items-center justify-center',
)
"
>
<ButtonStyled
circular
:color="leadingItem.color ?? 'standard'"
:size="leadingItem.size ?? 'large'"
:type="leadingItem.buttonType ?? 'standard'"
>
<AutoLink
v-if="leadingItem.to"
v-tooltip="leadingItem.tooltip"
:to="leadingItem.to"
:aria-label="leadingLabel(leadingItem)"
>
<component
:is="leadingItem.icon"
v-if="leadingItem.icon"
aria-hidden="true"
v-bind="leadingItem.iconProps"
/>
</AutoLink>
<button
v-else
v-tooltip="leadingItem.tooltip"
type="button"
:aria-label="leadingLabel(leadingItem)"
@click="leadingItem.onClick"
>
<component
:is="leadingItem.icon"
v-if="leadingItem.icon"
aria-hidden="true"
v-bind="leadingItem.iconProps"
/>
</button>
</ButtonStyled>
</div>
<Avatar
v-else-if="leadingItem.type === 'avatar'"
:src="leadingItem.src"
:alt="leadingItem.alt ?? ''"
:size="leadingItem.avatarSize ?? '64px'"
:tint-by="leadingItem.tintBy ?? null"
:circle="leadingItem.circle ?? false"
:no-shadow="leadingItem.noShadow ?? false"
:raised="leadingItem.raised ?? false"
:loading="leadingItem.loading ?? 'eager'"
:class="leadingItem.class"
/>
<component
:is="leadingItem.component"
v-else-if="leadingItem.component"
:class="leadingItem.class"
v-bind="leadingItem.componentProps"
/>
</template>
</div>
<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 min-w-0 max-w-full text-2xl font-semibold leading-none text-contrast"
:class="[props.truncateTitle ? 'truncate' : '', props.titleClass]"
>
{{ props.header }}
</h1>
</div>
<p
v-if="props.summary"
class="m-0 max-w-[44rem] empty:hidden"
:class="[props.disableLineClamp ? '' : 'line-clamp-2']"
>
{{ props.summary }}
</p>
</div>
<div v-if="props.metadata.length" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
<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" />
<AutoLink
v-if="item.to"
v-tooltip="item.tooltip"
:to="item.to"
:aria-label="item.ariaLabel ?? item.label"
:class="metadataClass(item, true)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</AutoLink>
<button
v-else-if="item.onClick"
v-tooltip="item.tooltip"
type="button"
:aria-label="item.ariaLabel ?? item.label"
:class="metadataClass(item, true)"
@click="item.onClick"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</button>
<div
v-else
v-tooltip="item.tooltip"
:aria-label="item.ariaLabel"
:class="metadataClass(item)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</div>
</div>
<div v-if="props.actions.length" class="flex flex-wrap gap-2 items-center">
<ButtonStyled
v-for="action in props.actions"
:key="action.id"
:color="action.color ?? 'standard'"
:size="action.size ?? 'large'"
:type="action.type ?? 'standard'"
:circular="action.circular ?? action.labelHidden ?? false"
>
<AutoLink
v-if="action.to"
v-tooltip="action.tooltip"
:to="action.to"
:aria-label="actionLabel(action)"
>
<component
:is="action.icon"
v-if="action.icon"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</AutoLink>
<button
v-else
v-tooltip="action.tooltip"
type="button"
:disabled="action.disabled"
:aria-label="actionLabel(action)"
@click="action.onClick"
>
<component
:is="action.icon"
v-if="action.icon"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</button>
</ButtonStyled>
</div>
</div>
<div v-if="props.metadata.length" class="flex justify-between md:hidden">
<div class="flex flex-wrap gap-3 empty:hidden">
<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" />
<AutoLink
v-if="item.to"
v-tooltip="item.tooltip"
:to="item.to"
:aria-label="item.ariaLabel ?? item.label"
:class="metadataClass(item, true)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</AutoLink>
<button
v-else-if="item.onClick"
v-tooltip="item.tooltip"
type="button"
:aria-label="item.ariaLabel ?? item.label"
:class="metadataClass(item, true)"
@click="item.onClick"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</button>
<div
v-else
v-tooltip="item.tooltip"
:aria-label="item.ariaLabel"
:class="metadataClass(item)"
>
<component
:is="item.icon"
v-if="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</div>
</div>
</div>
<slot name="actions" />
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AutoLink from './AutoLink.vue'
import Avatar from './Avatar.vue'
import BulletDivider from './BulletDivider.vue'
import ButtonStyled from './ButtonStyled.vue'
type PageHeaderTarget = string | RouteLocationRaw
type ButtonColor =
| 'standard'
| 'brand'
| 'red'
| 'orange'
| 'green'
| 'blue'
| 'purple'
| 'medal-promo'
type ButtonSize = 'standard' | 'large' | 'small'
type ButtonType =
| 'standard'
| 'outlined'
| 'transparent'
| 'highlight'
| 'highlight-colored-text'
| 'chip'
type PageHeaderLeading = {
id?: string
type: 'avatar' | 'button' | 'component'
icon?: Component
iconProps?: Record<string, unknown>
component?: Component
componentProps?: Record<string, unknown>
src?: string | null
alt?: string
tintBy?: string | null
avatarSize?: string
circle?: boolean
noShadow?: boolean
raised?: boolean
loading?: 'eager' | 'lazy'
class?: string
wrapperClass?: string
to?: PageHeaderTarget
onClick?: () => void | Promise<void>
tooltip?: string
ariaLabel?: string
color?: ButtonColor
size?: ButtonSize
buttonType?: ButtonType
}
type PageHeaderMetadataItem = {
id: string
label: string
icon?: Component
iconProps?: Record<string, unknown>
tooltip?: string
ariaLabel?: string
to?: PageHeaderTarget
onClick?: () => void | Promise<void>
class?: string
}
type PageHeaderAction = {
id: string
label: string
icon?: Component
iconProps?: Record<string, unknown>
tooltip?: string
ariaLabel?: string
to?: PageHeaderTarget
onClick?: () => void | Promise<void>
disabled?: boolean
labelHidden?: boolean
circular?: boolean
color?: ButtonColor
size?: ButtonSize
type?: ButtonType
}
const props = withDefaults(
defineProps<{
header: string
summary?: string | null
leading?: PageHeaderLeading | PageHeaderLeading[] | null
metadata?: PageHeaderMetadataItem[]
actions?: PageHeaderAction[]
headerClass?: string
rowClass?: string
mainClass?: string
titleClass?: string
truncateTitle?: boolean
divider?: boolean
bottomPadding?: boolean
disableLineClamp?: boolean
}>(),
{
summary: null,
leading: null,
metadata: () => [],
actions: () => [],
headerClass: '',
rowClass: '',
mainClass: '',
titleClass: '',
truncateTitle: false,
divider: true,
bottomPadding: true,
disableLineClamp: false,
},
)
const leadingItems = computed(() => {
if (!props.leading) return []
return Array.isArray(props.leading) ? props.leading : [props.leading]
})
function leadingLabel(item: PageHeaderLeading) {
return item.ariaLabel ?? item.tooltip ?? 'Header action'
}
function leadingWrapperClass(item: PageHeaderLeading, defaultClass: string) {
return [item.wrapperClass ?? defaultClass]
}
function metadataClass(item: PageHeaderMetadataItem, interactive = false) {
return [
'flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap',
interactive ? 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline' : '',
item.class,
]
}
function actionLabel(action: PageHeaderAction) {
return action.ariaLabel ?? action.tooltip ?? action.label
}
</script>
+1
View File
@@ -63,6 +63,7 @@ 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 './PageHeader.vue'
export { default as Pagination } from './Pagination.vue'
export { default as PopoutMenu } from './PopoutMenu.vue'
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
@@ -1,11 +1,12 @@
<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/PageHeader.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { useServerImage } from '#ui/composables/servers/use-server-image.ts'
import { formatLoaderLabel } from '#ui/utils/loaders'
@@ -20,6 +21,13 @@ const props = defineProps<{
installContext?: BrowseInstallContext | null
}>()
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 +45,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,
wrapperClass: 'flex size-12 shrink-0 items-center justify-center',
},
...(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: context.gameVersion,
icon: Gamepad2Icon,
class: '!text-primary',
})
}
if (context.loader) {
const loaderLabel = formatLoaderLabel(context.loader)
items.push({
id: 'loader',
label: loaderLabel,
icon: LoaderIcon,
iconProps: { loader: loaderLabel },
class: '!text-primary',
})
}
return items
})
const selectedCount = computed(() => installContext.value?.selectedProjects?.length ?? 0)
@@ -99,37 +159,16 @@ 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>
<PageHeader
:header="installContext.name"
:leading="leadingItems"
:metadata="metadataItems"
:divider="false"
:bottom-padding="false"
main-class="items-center"
title-class="leading-8"
truncate-title
/>
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
{{ installContext.warning }}
</Admonition>
@@ -20,6 +20,7 @@ import type { SortType } from '#ui/utils/search'
import SelectedProjectsFloatingBar from './components/SelectedProjectsFloatingBar.vue'
import BrowseInstallHeader from './header.vue'
import { injectBrowseManager } from './providers/browse-manager'
import BrowseSidebar from './sidebar.vue'
const ctx = injectBrowseManager()
const { formatMessage } = useVIntl()
@@ -44,6 +45,21 @@ const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
})),
)
const installHeaderClass = computed(() => [
'sticky top-0 z-20 border-0 bg-surface-1',
ctx.variant === 'web'
? 'normal-page__header browse-install-header-bleed mb-4 flex flex-col gap-2 py-3'
: '-mx-6 -mt-6 rounded-tl-[--radius-xl] border-b border-solid p-3 border-surface-5',
ctx.variant !== 'web' && isInstallHeaderStuck.value ? 'border-t' : '',
])
const contentFrameTag = computed(() => (ctx.variant === 'web' ? 'section' : 'div'))
const contentFrameClass = computed(() =>
ctx.variant === 'web' ? 'normal-page__content browse-page-layout__content' : 'contents',
)
const contentInnerClass = computed(() =>
ctx.variant === 'web' ? 'flex flex-col gap-3' : 'contents',
)
const messages = defineMessages({
searchPlaceholder: {
id: 'browse.search.placeholder',
@@ -69,253 +85,308 @@ const messages = defineMessages({
</script>
<template>
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
<div
ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
<div v-if="ctx.installContext?.value" ref="stickyInstallHeaderRef" :class="installHeaderClass">
<BrowseInstallHeader />
</div>
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value" />
<template v-if="ctx.variant === 'web'">
<aside
class="normal-page__sidebar browse-page-layout__sidebar"
:aria-label="formatMessage(commonMessages.filtersLabel)"
>
<BrowseInstallHeader />
</div>
<slot name="sidebar-prepend" />
<BrowseSidebar />
</aside>
</template>
<template v-else>
<Teleport to="#sidebar-teleport-target">
<BrowseSidebar>
<template #prepend>
<slot name="sidebar-prepend" />
</template>
</BrowseSidebar>
</Teleport>
</template>
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<component :is="contentFrameTag" :class="contentFrameClass">
<div :class="contentInnerClass">
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<StyledInput
v-model="ctx.query.value"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="
formatMessage(messages.searchPlaceholder, {
projectType: formatProjectTypeSentence(formatMessage, ctx.projectType.value, 2),
})
"
clearable
wrapper-class="w-full"
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
@clear="ctx.clearSearch()"
/>
<div class="flex flex-wrap items-center gap-2">
<Combobox
:model-value="ctx.effectiveCurrentSortType.value"
:options="sortOptions"
:class="
ctx.variant === 'web'
? '!w-[16rem] min-w-max max-w-full flex-grow md:flex-grow-0'
: '!w-[16rem] min-w-max max-w-full'
"
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">{{
formatMessage(commonMessages.sortByLabel)
}}</span>
</template>
</Combobox>
<Combobox
:model-value="ctx.maxResults.value"
:options="maxResultsOptions"
:class="
ctx.variant === 'web'
? '!w-[9rem] min-w-max max-w-full flex-grow md:flex-grow-0'
: '!w-[9rem] min-w-max max-w-full'
"
:placeholder="formatMessage(commonMessages.viewLabel)"
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">{{ formatMessage(messages.viewPrefix) }}</span>
</template>
</Combobox>
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
<ButtonStyled>
<button @click="ctx.filtersMenuOpen.value = true">
{{ formatMessage(messages.filterResults) }}
</button>
</ButtonStyled>
</div>
<ButtonStyled v-if="ctx.cycleDisplayMode" circular>
<button @click="ctx.cycleDisplayMode!()">
<slot name="display-mode-icon" />
</button>
</ButtonStyled>
<Pagination
:page="ctx.currentPage.value"
:count="ctx.pageCount.value"
:class="ctx.variant === 'web' ? 'mx-auto sm:ml-auto sm:mr-0' : 'ml-auto'"
@switch-page="ctx.setPage"
/>
</div>
<SearchFilterControl
v-if="ctx.isServerType.value"
v-model:selected-filters="ctx.serverCurrentFilters.value"
:filters="ctx.serverFilterTypes.value"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="ctx.currentFilters.value"
:filters="ctx.filters.value.filter((f) => f.display !== 'none')"
:provided-filters="ctx.providedFilters?.value ?? []"
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-message="lockedMessages?.providedBy"
/>
<div class="search">
<section v-if="ctx.loading.value" class="offline">
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
</section>
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
{{ formatMessage(messages.offline) }}
</section>
<section
v-else-if="
ctx.isServerType.value
? ctx.serverHits.value.length === 0
: ctx.projectHits.value.length === 0
"
class="offline"
>
<p>{{ formatMessage(messages.noResults) }}</p>
</section>
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
<template v-if="ctx.isServerType.value">
<ProjectCard
v-for="result in ctx.serverHits.value"
:key="`server-card-${result.project_id}`"
:title="result.name"
:icon-url="result.icon_url || undefined"
:summary="result.summary"
:tags="result.categories"
:link="ctx.getServerProjectLink(result)"
:server-online-players="result.minecraft_java_server?.ping?.data?.players_online ?? 0"
:server-region="result.minecraft_server?.region"
:server-recent-plays="result.minecraft_java_server?.verified_plays_2w ?? 0"
:server-modpack-content="ctx.getServerModpackContent?.(result)"
:server-ping="ctx.serverPings?.value?.[result.project_id]"
:server-status-online="!!result.minecraft_java_server?.ping?.data"
:hide-online-players-label="ctx.variant === 'app'"
:hide-recent-plays-label="ctx.variant === 'app'"
:layout="ctx.effectiveLayout.value"
:max-tags="2"
is-server-project
exclude-loaders
:color="result.color ?? undefined"
:banner="result.featured_gallery ?? undefined"
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
@mouseenter="ctx.onServerProjectHover?.(result)"
@mouseleave="ctx.onProjectHoverEnd?.()"
>
<template v-if="ctx.getCardActions?.(result, ctx.projectType.value)?.length" #actions>
<div class="flex gap-2">
<ButtonStyled
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
:key="action.key"
:color="action.color"
:type="action.type"
:circular="action.circular"
>
<button
v-tooltip="action.tooltip"
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in ctx.projectHits.value"
:key="result.project_id"
:link="ctx.getProjectLink(result)"
:title="result.title"
:icon-url="result.icon_url"
:author="{
name: result.organization == null ? result.author : result.organization,
link:
result.organization_id == null
? ctx.variant === 'web'
? `/user/${result.author_id ?? result.author}`
: `https://modrinth.com/user/${result.author_id ?? result.author}`
: ctx.variant === 'web'
? `/organization/${result.organization_id}`
: `https://modrinth.com/organization/${result.organization_id}`,
}"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="
ctx.effectiveCurrentSortType.value.name === 'newest' ? 'published' : 'updated'
"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="ctx.deprioritizedTags.value"
:exclude-loaders="ctx.excludeLoaders.value"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(ctx.projectType.value)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
}
: undefined
"
:layout="ctx.effectiveLayout.value"
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
@mouseenter="ctx.onProjectHover?.(result)"
@mouseleave="ctx.onProjectHoverEnd?.()"
>
<template v-if="ctx.getCardActions?.(result, ctx.projectType.value)?.length" #actions>
<div class="flex gap-2">
<ButtonStyled
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
:key="action.key"
:color="action.color"
:type="action.type"
:circular="action.circular"
>
<button
v-tooltip="action.tooltip"
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
</ProjectCardList>
<div :class="ctx.variant === 'web' ? 'pagination-after mt-3' : 'flex justify-end mt-3'">
<Pagination
:page="ctx.currentPage.value"
:count="ctx.pageCount.value"
:class="ctx.variant === 'web' ? 'justify-end' : 'pagination-after'"
@switch-page="ctx.setPage"
<StyledInput
v-model="ctx.query.value"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="
formatMessage(messages.searchPlaceholder, {
projectType: formatProjectTypeSentence(formatMessage, ctx.projectType.value, 2),
})
"
clearable
wrapper-class="w-full"
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
@clear="ctx.clearSearch()"
/>
<div class="flex flex-wrap items-center gap-2">
<Combobox
:model-value="ctx.effectiveCurrentSortType.value"
:options="sortOptions"
:class="
ctx.variant === 'web'
? '!w-[16rem] min-w-max max-w-full flex-grow md:flex-grow-0'
: '!w-[16rem] min-w-max max-w-full'
"
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">{{
formatMessage(commonMessages.sortByLabel)
}}</span>
</template>
</Combobox>
<Combobox
:model-value="ctx.maxResults.value"
:options="maxResultsOptions"
:class="
ctx.variant === 'web'
? '!w-[9rem] min-w-max max-w-full flex-grow md:flex-grow-0'
: '!w-[9rem] min-w-max max-w-full'
"
:placeholder="formatMessage(commonMessages.viewLabel)"
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
>
<template #prefix>
<span class="font-semibold text-primary">{{ formatMessage(messages.viewPrefix) }}</span>
</template>
</Combobox>
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
<ButtonStyled>
<button @click="ctx.filtersMenuOpen.value = true">
{{ formatMessage(messages.filterResults) }}
</button>
</ButtonStyled>
</div>
<ButtonStyled v-if="ctx.cycleDisplayMode" circular>
<button @click="ctx.cycleDisplayMode!()">
<slot name="display-mode-icon" />
</button>
</ButtonStyled>
<Pagination
:page="ctx.currentPage.value"
:count="ctx.pageCount.value"
:class="ctx.variant === 'web' ? 'mx-auto sm:ml-auto sm:mr-0' : 'ml-auto'"
@switch-page="ctx.setPage"
/>
</div>
<SearchFilterControl
v-if="ctx.isServerType.value"
v-model:selected-filters="ctx.serverCurrentFilters.value"
:filters="ctx.serverFilterTypes.value"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="ctx.currentFilters.value"
:filters="ctx.filters.value.filter((f) => f.display !== 'none')"
:provided-filters="ctx.providedFilters?.value ?? []"
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
:provided-message="lockedMessages?.providedBy"
/>
<div class="search">
<section v-if="ctx.loading.value" class="offline">
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
</section>
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
{{ formatMessage(messages.offline) }}
</section>
<section
v-else-if="
ctx.isServerType.value
? ctx.serverHits.value.length === 0
: ctx.projectHits.value.length === 0
"
class="offline"
>
<p>{{ formatMessage(messages.noResults) }}</p>
</section>
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
<template v-if="ctx.isServerType.value">
<ProjectCard
v-for="result in ctx.serverHits.value"
:key="`server-card-${result.project_id}`"
:title="result.name"
:icon-url="result.icon_url || undefined"
:summary="result.summary"
:tags="result.categories"
:link="ctx.getServerProjectLink(result)"
:server-online-players="result.minecraft_java_server?.ping?.data?.players_online ?? 0"
:server-region="result.minecraft_server?.region"
:server-recent-plays="result.minecraft_java_server?.verified_plays_2w ?? 0"
:server-modpack-content="ctx.getServerModpackContent?.(result)"
:server-ping="ctx.serverPings?.value?.[result.project_id]"
:server-status-online="!!result.minecraft_java_server?.ping?.data"
:hide-online-players-label="ctx.variant === 'app'"
:hide-recent-plays-label="ctx.variant === 'app'"
:layout="ctx.effectiveLayout.value"
:max-tags="2"
is-server-project
exclude-loaders
:color="result.color ?? undefined"
:banner="result.featured_gallery ?? undefined"
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
@mouseenter="ctx.onServerProjectHover?.(result)"
@mouseleave="ctx.onProjectHoverEnd?.()"
>
<template v-if="ctx.getCardActions?.(result, ctx.projectType.value)?.length" #actions>
<div class="flex gap-2">
<ButtonStyled
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
:key="action.key"
:color="action.color"
:type="action.type"
:circular="action.circular"
>
<button
v-tooltip="action.tooltip"
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in ctx.projectHits.value"
:key="result.project_id"
:link="ctx.getProjectLink(result)"
:title="result.title"
:icon-url="result.icon_url"
:author="{
name: result.organization == null ? result.author : result.organization,
link:
result.organization_id == null
? ctx.variant === 'web'
? `/user/${result.author_id ?? result.author}`
: `https://modrinth.com/user/${result.author_id ?? result.author}`
: ctx.variant === 'web'
? `/organization/${result.organization_id}`
: `https://modrinth.com/organization/${result.organization_id}`,
}"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="
ctx.effectiveCurrentSortType.value.name === 'newest' ? 'published' : 'updated'
"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="ctx.deprioritizedTags.value"
:exclude-loaders="ctx.excludeLoaders.value"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(ctx.projectType.value)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
}
: undefined
"
:layout="ctx.effectiveLayout.value"
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
@mouseenter="ctx.onProjectHover?.(result)"
@mouseleave="ctx.onProjectHoverEnd?.()"
>
<template v-if="ctx.getCardActions?.(result, ctx.projectType.value)?.length" #actions>
<div class="flex gap-2">
<ButtonStyled
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
:key="action.key"
:color="action.color"
:type="action.type"
:circular="action.circular"
>
<button
v-tooltip="action.tooltip"
:disabled="action.disabled"
@click.stop="action.onClick"
>
<component :is="action.icon" :class="action.iconClass" />
<template v-if="!action.circular">{{ action.label }}</template>
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
</ProjectCardList>
<div :class="ctx.variant === 'web' ? 'pagination-after mt-3' : 'flex justify-end mt-3'">
<Pagination
:page="ctx.currentPage.value"
:count="ctx.pageCount.value"
:class="ctx.variant === 'web' ? 'justify-end' : 'pagination-after'"
@switch-page="ctx.setPage"
/>
</div>
</div>
<slot name="content-after" />
</div>
</div>
</component>
<slot name="after" />
</template>
<style scoped>
.browse-install-header-bleed {
grid-column: 1 / -1;
margin-inline: -1.5rem;
padding-inline: 0.75rem !important;
}
.browse-install-header-bleed::after {
content: '';
position: absolute;
right: 50%;
bottom: 0;
width: 100vw;
border-bottom: 1px solid var(--surface-5);
transform: translateX(50%);
}
.browse-page-layout__content {
display: contents;
}
.browse-page-layout__sidebar {
grid-row: 3;
}
@media screen and (min-width: 1024px) {
.browse-page-layout__content {
display: block;
}
.browse-page-layout__sidebar {
display: block;
}
}
</style>
@@ -0,0 +1,240 @@
import {
GlobeIcon,
LeftArrowIcon,
LinkIcon,
MoreVerticalIcon,
PlayIcon,
SettingsIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
} from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import PageHeader from '../../components/base/PageHeader.vue'
import LoaderIcon from '../../components/servers/icons/LoaderIcon.vue'
import ServerIcon from '../../components/servers/icons/ServerIcon.vue'
const noop = () => undefined
const meta = {
title: 'Base/PageHeader Prototype',
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 AppInstanceHeader: Story = {
args: {
header: 'Create: Astral',
leading: {
type: 'avatar',
src: null,
alt: 'Create: Astral',
tintBy: '/Users/calum/Library/Application Support/com.modrinth.theseus/profiles/astral',
},
metadata: [
{
id: 'game-version',
label: '1.20.1',
icon: Gamepad2Icon,
tooltip: 'Minecraft version',
},
{
id: 'loader',
label: 'Fabric 0.16.14',
icon: LoaderIcon,
iconProps: { loader: 'Fabric' },
tooltip: 'Mod loader',
},
{
id: 'playtime',
label: '12 hours',
icon: TimerIcon,
tooltip: 'Total playtime',
},
],
actions: [
{
id: 'play',
label: 'Play',
icon: PlayIcon,
color: 'brand',
onClick: noop,
},
{
id: 'settings',
label: 'Instance settings',
icon: SettingsIcon,
labelHidden: true,
tooltip: 'Instance settings',
onClick: noop,
},
{
id: 'more',
label: 'More actions',
icon: MoreVerticalIcon,
labelHidden: true,
type: 'transparent',
tooltip: 'More actions',
onClick: noop,
},
],
},
}
export const BrowseHeader: Story = {
args: {
header: 'Survival SMP',
leading: [
{
id: 'back',
type: 'button',
icon: LeftArrowIcon,
to: '/instance/my-world',
ariaLabel: 'Back to instance',
tooltip: 'Back to instance',
wrapperClass: 'flex size-12 shrink-0 items-center justify-center',
},
{
id: 'target',
type: 'avatar',
src: null,
alt: 'Survival SMP',
avatarSize: '48px',
tintBy: 'survival-smp',
},
],
metadata: [
{
id: 'heading',
label: 'Installing content',
class: '!text-primary',
},
{
id: 'game-version',
label: '1.20.1',
icon: Gamepad2Icon,
tooltip: 'Minecraft version',
class: '!text-primary',
},
{
id: 'loader',
label: 'Fabric',
icon: LoaderIcon,
iconProps: { loader: 'Fabric' },
tooltip: 'Mod loader',
class: '!text-primary',
},
],
divider: false,
bottomPadding: false,
mainClass: 'items-center',
titleClass: 'leading-8',
truncateTitle: true,
},
}
export const ServerPanelRootHeader: Story = {
args: {
header: 'Survival SMP',
leading: {
type: 'component',
component: ServerIcon,
componentProps: { image: undefined },
class: 'size-15 !rounded-xl',
},
metadata: [
{
id: 'active-world',
label: 'My World',
icon: GlobeIcon,
tooltip: 'Active instance',
},
{
id: 'address',
label: 'play.modrinth.gg',
icon: LinkIcon,
tooltip: 'Copy server address',
onClick: noop,
},
],
actions: [
{
id: 'start',
label: 'Start server',
icon: PlayIcon,
color: 'brand',
onClick: noop,
},
{
id: 'settings',
label: 'Server settings',
icon: SettingsIcon,
labelHidden: true,
tooltip: 'Server settings',
onClick: noop,
},
],
},
}
export const ServerPanelInstanceHeader: Story = {
args: {
header: 'My World',
leading: {
type: 'button',
icon: LeftArrowIcon,
to: '/hosting/manage/demo-server/instances',
ariaLabel: 'All instances',
tooltip: 'All instances',
},
metadata: [
{
id: 'game-version',
label: '1.20.1',
icon: Gamepad2Icon,
tooltip: 'Minecraft version',
},
{
id: 'loader',
label: 'Fabric 0.19.2',
icon: LoaderIcon,
iconProps: { loader: 'Fabric' },
tooltip: 'Mod loader',
},
{
id: 'last-active',
label: 'Last active 2 weeks ago',
icon: TimerIcon,
tooltip: 'Last activity',
},
],
actions: [
{
id: 'start',
label: 'Start instance',
icon: PlayIcon,
color: 'brand',
onClick: noop,
},
{
id: 'settings',
label: 'Instance settings',
icon: SettingsIcon,
labelHidden: true,
tooltip: 'Instance settings',
onClick: noop,
},
],
},
}