feat: header migration pt 2

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:22 +01:00
parent 69ba34a8ad
commit e3c50d19ed
16 changed files with 906 additions and 532 deletions
@@ -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'
+154 -31
View File
@@ -6,40 +6,138 @@
:class="{ 'drop-shadow-xl border border-solid border-surface-4': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
<template v-for="(link, index) in filteredLinks" :key="link.href">
<Tooltip
v-if="link.prompt"
theme="dismissable-prompt"
:triggers="[]"
:shown="link.prompt.shown"
:auto-hide="false"
:placement="link.prompt.placement ?? 'bottom'"
>
<RouterLink
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
@click="dismissPrompt(link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
<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">
{{ link.prompt.title }}
</h3>
<ButtonStyled size="small" circular>
<button v-tooltip="link.prompt.dismissLabel" @click="link.prompt.onDismiss?.()">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ link.prompt.description }}
</p>
</div>
</template>
</Tooltip>
<RouterLink
v-else
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
</template>
</template>
<template v-else>
<div
v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="emit('tabClick', index, link)"
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
<template v-for="(link, index) in filteredLinks" :key="link.href">
<Tooltip
v-if="link.prompt"
theme="dismissable-prompt"
:triggers="[]"
:shown="link.prompt.shown"
:auto-hide="false"
:placement="link.prompt.placement ?? 'bottom'"
>
<div
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="handleLocalTabClick(index, link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
<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">
{{ link.prompt.title }}
</h3>
<ButtonStyled size="small" circular>
<button v-tooltip="link.prompt.dismissLabel" @click="link.prompt.onDismiss?.()">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ link.prompt.description }}
</p>
</div>
</template>
</Tooltip>
<div
v-else
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="handleLocalTabClick(index, link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="getIconClasses(index)"
/>
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
</template>
</template>
<!-- Animated slider background -->
@@ -57,12 +155,25 @@
</template>
<script setup lang="ts">
import { XIcon } from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import ButtonStyled from './ButtonStyled.vue'
const route = useRoute()
interface TabPrompt {
title: string
description: string
dismissLabel?: string
shown?: boolean
placement?: string
onDismiss?: () => void
}
interface Tab {
label: string
href: string
@@ -70,6 +181,7 @@ interface Tab {
icon?: Component
subpages?: string[]
onHover?: () => void
prompt?: TabPrompt
}
const props = withDefaults(
@@ -131,6 +243,17 @@ const isActiveAndNotSubpage = computed(
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
)
function dismissPrompt(link: Tab) {
if (link.prompt?.shown) {
link.prompt.onDismiss?.()
}
}
function handleLocalTabClick(index: number, link: Tab) {
dismissPrompt(link)
emit('tabClick', index, link)
}
function getSSRFallbackClasses(index: number) {
if (sliderReady.value) return {}
if (currentActiveIndex.value !== index) return {}
+60 -37
View File
@@ -154,45 +154,58 @@
</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"
<template v-for="action in props.actions" :key="action.id">
<JoinedButtons
v-if="action.joinedActions?.length"
:actions="action.joinedActions"
:color="joinedActionColor(action.color)"
:size="action.size ?? 'large'"
:disabled="action.disabled"
:aria-label="actionLabel(action)"
@click="action.onClick"
:primary-disabled="action.primaryDisabled"
:dropdown-disabled="action.dropdownDisabled"
:primary-muted="action.primaryMuted"
/>
<ButtonStyled
v-else
:color="action.color ?? 'standard'"
:size="action.size ?? 'large'"
:type="action.type ?? 'standard'"
:circular="action.circular ?? action.labelHidden ?? false"
>
<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>
<AutoLink
v-if="action.to"
v-tooltip="action.tooltip"
:to="action.to"
:aria-label="actionLabel(action)"
>
<component
:is="action.icon"
v-if="action.icon"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</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"
:class="action.iconClass"
aria-hidden="true"
v-bind="action.iconProps"
/>
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
</button>
</ButtonStyled>
</template>
</div>
</div>
@@ -265,6 +278,7 @@ import AutoLink from './AutoLink.vue'
import Avatar from './Avatar.vue'
import BulletDivider from './BulletDivider.vue'
import ButtonStyled from './ButtonStyled.vue'
import JoinedButtons, { type JoinedButtonAction } from './JoinedButtons.vue'
type PageHeaderTarget = string | RouteLocationRaw
type ButtonColor =
@@ -328,6 +342,7 @@ type PageHeaderAction = {
label: string
icon?: Component
iconProps?: Record<string, unknown>
iconClass?: string
tooltip?: string
ariaLabel?: string
to?: PageHeaderTarget
@@ -338,6 +353,10 @@ type PageHeaderAction = {
color?: ButtonColor
size?: ButtonSize
type?: ButtonType
joinedActions?: JoinedButtonAction[]
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
}
const props = withDefaults(
@@ -396,4 +415,8 @@ function metadataClass(item: PageHeaderMetadataItem, interactive = false) {
function actionLabel(action: PageHeaderAction) {
return action.ariaLabel ?? action.tooltip ?? action.label
}
function joinedActionColor(color?: ButtonColor) {
return color === 'medal-promo' ? 'standard' : color
}
</script>
@@ -1,42 +1,12 @@
<template>
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
<ContentPageHeader :class="props.headerClass">
<template #icon>
<div class="flex size-16 shrink-0 items-center justify-center">
<ButtonStyled circular size="large">
<button
type="button"
:aria-label="props.backLabel"
@click="router.push(props.backHref)"
>
<LeftArrowIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</template>
<template #title>
{{ props.name || props.fallbackName }}
</template>
<template #stats>
<div v-if="headerMetadata.length" class="flex min-w-0 flex-wrap items-center gap-2">
<template v-for="(item, index) in headerMetadata" :key="item.id">
<BulletDivider v-if="index > 0" />
<div class="flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap">
<component
:is="item.icon"
class="flex size-5 shrink-0"
aria-hidden="true"
v-bind="item.iconProps"
/>
<span class="truncate">{{ item.label }}</span>
</div>
</template>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
<PageHeader
:header="props.name || props.fallbackName"
:leading="leadingItems"
:metadata="headerMetadata"
:actions="props.actions"
:header-class="props.headerClass"
/>
</div>
</template>
@@ -46,9 +16,7 @@ import { LeftArrowIcon, TagCategoryGamepad2Icon as Gamepad2Icon, TimerIcon } fro
import { type Component, computed } from 'vue'
import { useRouter } from 'vue-router'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ContentPageHeader from '#ui/components/base/ContentPageHeader.vue'
import PageHeader from '#ui/components/base/PageHeader.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { injectModrinthClient } from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
@@ -59,6 +27,33 @@ type MetadataItem = {
icon: Component
iconProps?: Record<string, unknown>
}
type HeaderAction = {
id: string
label: string
icon?: Component
iconProps?: Record<string, unknown>
iconClass?: string
tooltip?: string
ariaLabel?: string
onClick?: () => void | Promise<void>
disabled?: boolean
labelHidden?: boolean
circular?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
size?: 'standard' | 'large' | 'small'
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
joinedActions?: {
id: string
label: string
icon?: Component
action: () => void
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
hoverFilled?: boolean
}[]
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
}
const props = withDefaults(
defineProps<{
@@ -72,6 +67,7 @@ const props = withDefaults(
backLabel: string
fallbackName?: string
headerClass?: string
actions?: HeaderAction[]
}>(),
{
name: null,
@@ -82,12 +78,23 @@ const props = withDefaults(
lastActive: null,
fallbackName: 'World',
headerClass: '',
actions: () => [],
},
)
const client = injectModrinthClient()
const router = useRouter()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const leadingItems = computed(() => [
{
id: 'back',
type: 'button' as const,
icon: LeftArrowIcon,
ariaLabel: props.backLabel,
tooltip: props.backLabel,
onClick: () => router.push(props.backHref),
},
])
const loaderLabel = computed(() => {
if (!props.loader) return null
const label = formatLoaderLabel(props.loader.toLowerCase())
@@ -19,6 +19,8 @@ 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 = {
@@ -57,7 +59,6 @@ const leadingItems = computed(() => {
ariaLabel: context.backLabel,
tooltip: context.backLabel,
onClick: handleBack,
wrapperClass: 'flex size-12 shrink-0 items-center justify-center',
},
...(iconSrc.value
? [
@@ -95,12 +96,13 @@ const metadataItems = computed(() => {
})
}
if (context.loader) {
const loaderLabel = formatLoaderLabel(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: loaderLabel },
iconProps: { loader: loaderName },
class: '!text-primary',
})
}
@@ -163,8 +165,8 @@ async function handleSelectedProjectsLeaveResult(
:header="installContext.name"
:leading="leadingItems"
:metadata="metadataItems"
:divider="false"
:bottom-padding="false"
:divider="props.divider ?? false"
:bottom-padding="props.bottomPadding ?? false"
main-class="items-center"
title-class="leading-8"
truncate-title
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { computed, ref, toValue } from 'vue'
import { computed, toValue } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
@@ -13,23 +13,14 @@ import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { useStickyObserver } from '#ui/composables/sticky-observer'
import { commonMessages, formatProjectTypeSentence } from '#ui/utils/common-messages'
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()
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'BrowseInstallHeader',
)
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
ctx.effectiveSortTypes.value.map((st) => ({
@@ -45,21 +36,6 @@ 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',
@@ -85,308 +61,242 @@ const messages = defineMessages({
</script>
<template>
<div v-if="ctx.installContext?.value" ref="stickyInstallHeaderRef" :class="installHeaderClass">
<BrowseInstallHeader />
</div>
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value" />
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<template v-if="ctx.variant === 'web'">
<aside
class="normal-page__sidebar browse-page-layout__sidebar"
:aria-label="formatMessage(commonMessages.filtersLabel)"
<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)"
>
<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>
<template #prefix>
<span class="font-semibold text-primary">{{
formatMessage(commonMessages.sortByLabel)
}}</span>
</template>
</Combobox>
<component :is="contentFrameTag" :class="contentFrameClass">
<div :class="contentInnerClass">
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<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>
<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 v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
<ButtonStyled>
<button @click="ctx.filtersMenuOpen.value = true">
{{ formatMessage(messages.filterResults) }}
</button>
</ButtonStyled>
</div>
</component>
<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="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>
@@ -21,6 +21,7 @@ export interface BrowseSelectedProject {
export interface BrowseInstallContext {
name: string
loader: string
loaderVersion?: string | null
gameVersion: string
serverId?: string | null
upstream?: { project_id?: string | null } | null
@@ -406,6 +406,14 @@ interface Tab {
href: string
icon?: object
subpages?: string[]
prompt?: {
title: string
description: string
dismissLabel?: string
shown?: boolean
placement?: string
onDismiss?: () => void
}
}
const props = withDefaults(
@@ -478,6 +486,21 @@ const settingsHintMessages = defineMessages({
},
})
const instancesHintMessages = defineMessages({
title: {
id: 'servers.manage.instances-hint.title',
defaultMessage: 'New: Server Instances!',
},
description: {
id: 'servers.manage.instances-hint.description',
defaultMessage: 'Your server state has been converted into an instance.',
},
dismiss: {
id: 'servers.manage.instances-hint.dismiss',
defaultMessage: "Don't show again",
},
})
const messages = defineMessages({
serverPreparingTitle: {
id: 'servers.manage.status.preparing.title',
@@ -723,6 +746,14 @@ function dismissSettingsHint() {
settingsHintDismissed.value = true
}
const INSTANCES_HINT_KEY = 'server-panel-instances-hint-dismissed'
const instancesHintDismissed = useStorage(INSTANCES_HINT_KEY, false)
const showInstancesHint = ref(!instancesHintDismissed.value)
function dismissInstancesHint() {
showInstancesHint.value = false
instancesHintDismissed.value = true
}
const serverSettingsModal = ref<InstanceType<typeof ServerSettingsModal> | null>(null)
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
@@ -1043,6 +1074,14 @@ const navLinks = computed<Tab[]>(() => [
href: `/hosting/manage/${encodeURIComponent(props.serverId)}/instances`,
icon: GlobeIcon,
subpages: [],
prompt: {
title: formatMessage(instancesHintMessages.title),
description: formatMessage(instancesHintMessages.description),
dismissLabel: formatMessage(instancesHintMessages.dismiss),
shown: showInstancesHint.value,
placement: 'bottom',
onDismiss: dismissInstancesHint,
},
},
{
label: 'Access',
@@ -9,19 +9,8 @@
:back-href="instancesPath"
:back-label="formatMessage(messages.allInstances)"
:fallback-name="formatMessage(messages.worldFallbackName)"
>
<template #actions>
<PanelServerActionButton size="large" start-label="Start instance" />
<ButtonStyled size="large" circular>
<button
v-tooltip="formatMessage(messages.instanceSettings)"
@click="openServerSettings({ tabId: 'installation' })"
>
<SettingsIcon aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</WorldManageHeader>
:actions="headerActions"
/>
<NavTabs :links="worldTabLinks" replace />
@@ -31,14 +20,24 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { BoxesIcon, DatabaseBackupIcon, FolderOpenIcon, SettingsIcon } from '@modrinth/assets'
import {
BoxesIcon,
DatabaseBackupIcon,
FolderOpenIcon,
LoaderCircleIcon,
PlayIcon,
SettingsIcon,
SlashIcon,
StopCircleIcon,
UpdatedIcon,
} from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NavTabs from '#ui/components/base/NavTabs.vue'
import { PanelServerActionButton, WorldManageHeader } from '#ui/components/servers/server-header'
import { WorldManageHeader } from '#ui/components/servers/server-header'
import { useServerPowerAction } from '#ui/components/servers/server-header/use-server-power-action'
import { useRelativeTime } from '#ui/composables'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import {
@@ -91,6 +90,17 @@ const { openServerSettings } = injectServerSettingsModal()
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const router = useRouter()
const {
isInstalling,
isStopping,
showRestartButton,
busyTooltip,
canTakeAction,
canKill,
primaryActionText,
initiateAction,
handlePrimaryAction,
} = useServerPowerAction()
const { data: serverFull } = useQuery({
queryKey: computed(() => ['servers', 'v1', 'detail', serverId]),
@@ -112,7 +122,7 @@ const currentWorld = computed(() => {
})
const worldName = computed(
() => currentWorld.value?.name ?? server.value?.name ?? formatMessage(messages.worldFallbackName),
() => currentWorld.value?.name ?? formatMessage(messages.worldFallbackName),
)
const gameVersion = computed(() => {
@@ -140,6 +150,93 @@ const lastActiveLabel = computed(() => {
? formatMessage(messages.lastActive, { time: formatRelativeTime(lastActiveAt) })
: null
})
const startActionText = computed(() =>
primaryActionText.value === 'Start' ? 'Start instance' : primaryActionText.value,
)
const stopSplitActions = computed(() => [
{
id: 'stop',
label: isStopping.value ? 'Stopping' : 'Stop',
icon: StopCircleIcon,
action: () => initiateAction('Stop'),
},
{
id: 'kill_server',
label: 'Kill server',
icon: SlashIcon,
action: () => initiateAction('Kill'),
},
])
const powerActions = computed(() => {
if (isInstalling.value) {
return [
{
id: 'installing',
label: 'Installing...',
icon: LoaderCircleIcon,
iconClass: 'animate-spin',
color: 'brand' as const,
disabled: true,
},
]
}
if (showRestartButton.value) {
return [
{
id: 'restart',
label: primaryActionText.value,
icon: UpdatedIcon,
color: 'orange' as const,
tooltip: busyTooltip.value,
disabled: !canTakeAction.value,
onClick: handlePrimaryAction,
},
{
id: 'stop',
label: 'Stop instance',
color: 'red' as const,
joinedActions: stopSplitActions.value,
primaryDisabled: !canTakeAction.value,
dropdownDisabled: !canKill.value,
},
]
}
if (isStopping.value) {
return [
{
id: 'stop',
label: 'Stop instance',
color: 'red' as const,
joinedActions: stopSplitActions.value,
primaryDisabled: true,
dropdownDisabled: !canKill.value,
primaryMuted: true,
},
]
}
return [
{
id: 'start',
label: startActionText.value,
icon: PlayIcon,
color: 'brand' as const,
tooltip: busyTooltip.value,
disabled: !canTakeAction.value,
onClick: handlePrimaryAction,
},
]
})
const headerActions = computed(() => [
...powerActions.value,
{
id: 'settings',
label: formatMessage(messages.instanceSettings),
icon: SettingsIcon,
labelHidden: true,
tooltip: formatMessage(messages.instanceSettings),
onClick: () => openServerSettings({ tabId: 'installation' }),
},
])
const worldTabLinks = computed<Tab[]>(() => [
{
@@ -5,6 +5,8 @@ import {
MoreVerticalIcon,
PlayIcon,
SettingsIcon,
SlashIcon,
StopCircleIcon,
TagCategoryGamepad2Icon as Gamepad2Icon,
TimerIcon,
} from '@modrinth/assets'
@@ -227,6 +229,25 @@ export const ServerPanelInstanceHeader: Story = {
color: 'brand',
onClick: noop,
},
{
id: 'stop',
label: 'Stop instance',
color: 'red',
joinedActions: [
{
id: 'stop',
label: 'Stop',
icon: StopCircleIcon,
action: noop,
},
{
id: 'kill_server',
label: 'Kill server',
icon: SlashIcon,
action: noop,
},
],
},
{
id: 'settings',
label: 'Instance settings',
@@ -1,7 +1,6 @@
import { SettingsIcon } from '@modrinth/assets'
import { PlayIcon, SettingsIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import WorldManageHeader from '../../components/servers/server-header/WorldManageHeader.vue'
const meta = {
@@ -30,22 +29,22 @@ export const Default: Story = {
lastActive: 'Last active 2 weeks ago',
backHref: '/hosting/manage/demo-server/instances',
backLabel: 'All instances',
actions: [
{
id: 'start',
label: 'Start instance',
icon: PlayIcon,
color: 'brand',
onClick: () => undefined,
},
{
id: 'settings',
label: 'Instance settings',
icon: SettingsIcon,
labelHidden: true,
tooltip: 'Instance settings',
onClick: () => undefined,
},
],
},
render: (args) => ({
components: { ButtonStyled, SettingsIcon, WorldManageHeader },
setup() {
return { args }
},
template: `
<WorldManageHeader v-bind="args">
<template #actions>
<ButtonStyled size="large" circular>
<button type="button" aria-label="Instance settings">
<SettingsIcon aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</WorldManageHeader>
`,
}),
}