mirror of
https://github.com/modrinth/code.git
synced 2026-09-02 04:56:52 +00:00
feat: header migration pt 2
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+114
-17
@@ -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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user