fix navtabs issues and unify project lists (#7245)

* fix navtabs issues and unify project lists

* move page navtab logic to component
This commit is contained in:
Prospector
2026-08-20 21:32:32 +00:00
committed by GitHub
parent bcd7133251
commit 087752312b
19 changed files with 645 additions and 519 deletions
+160 -97
View File
@@ -1,68 +1,88 @@
<template>
<nav
<div
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'drop-shadow-xl border border-solid border-surface-4': mode === 'navigation' }"
:class="pageNav ? '-mx-6 -mt-2 mb-1 overflow-x-auto px-6 py-2' : 'contents'"
v-bind="pageNav ? $attrs : {}"
>
<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>
<nav
ref="scrollContainer"
class="relative flex w-fit rounded-full bg-bg-raised p-1 text-xs sm:text-sm font-bold"
:class="{
'card-shadow border border-solid border-surface-4': mode === 'navigation',
'tab-color-delayed': colorChangeDelayed,
}"
v-bind="pageNav ? {} : $attrs"
>
<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="tab-color hidden sm:block size-5"
:class="getIconClasses(index)"
/>
<span class="tab-color text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
</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="tab-color size-5"
:class="getIconClasses(index)"
/>
<span class="tab-color text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
</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>
<!-- Animated slider background -->
<div
v-if="sliderReady && currentActiveIndex !== -1"
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
:class="[
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:style="sliderStyle"
aria-hidden="true"
/>
</nav>
v-if="sliderReady && currentActiveIndex !== -1"
class="pointer-events-none absolute rounded-full"
:class="[
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:style="sliderStyle"
aria-hidden="true"
/>
</nav>
</div>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
defineOptions({ inheritAttrs: false })
interface Tab {
label: string
href: string
@@ -79,11 +99,13 @@ const props = withDefaults(
query?: string
mode?: 'navigation' | 'local'
activeIndex?: number
pageNav?: boolean
}>(),
{
mode: 'navigation',
query: undefined,
activeIndex: undefined,
pageNav: false,
},
)
@@ -95,21 +117,18 @@ const emit = defineEmits<{
const scrollContainer = ref<HTMLElement | null>(null)
const tabLinkElements = ref<HTMLElement[]>()
// Slider pos state
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const sliderLeft = ref(0)
const sliderTop = ref(0)
const sliderRight = ref(0)
const sliderBottom = ref(0)
// active tab state
const currentActiveIndex = ref(-1)
const subpageSelected = ref(false)
// SSR state
const sliderReady = ref(false)
const transitionsEnabled = ref(false)
const colorChangeDelayed = ref(false)
// Stagger delays for the trailing edges of the slider animation
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
@@ -119,7 +138,6 @@ const sliderStyle = computed(() => ({
top: `${sliderTop.value}px`,
right: `${sliderRight.value}px`,
bottom: `${sliderBottom.value}px`,
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
}))
const leftDelay = computed(() => sliderDelays.value.left)
@@ -208,34 +226,29 @@ function getTabElement(index: number): HTMLElement | null {
return element
}
function positionSlider() {
function getSliderPosition() {
const el = getTabElement(currentActiveIndex.value)
if (!el?.offsetParent) return
const parent = scrollContainer.value
if (!el || !parent) return null
const parent = el.offsetParent as HTMLElement
const newPosition = {
return {
left: el.offsetLeft,
top: el.offsetTop,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
right: parent.clientWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.clientHeight - el.offsetTop - el.offsetHeight,
}
}
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (!sliderReady.value || isInitialPosition) {
sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right
sliderTop.value = newPosition.top
sliderBottom.value = newPosition.bottom
sliderReady.value = true
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
animateSliderTo(newPosition)
}
function applySliderPosition(newPosition: {
left: number
top: number
right: number
bottom: number
}) {
sliderLeft.value = newPosition.left
sliderTop.value = newPosition.top
sliderRight.value = newPosition.right
sliderBottom.value = newPosition.bottom
}
function animateSliderTo(newPosition: {
@@ -244,7 +257,7 @@ function animateSliderTo(newPosition: {
right: number
bottom: number
}) {
const STAGGER_DELAY = '200ms'
const STAGGER_DELAY = '175ms'
sliderDelays.value = {
left: newPosition.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
@@ -253,23 +266,54 @@ function animateSliderTo(newPosition: {
bottom: newPosition.top < sliderTop.value ? STAGGER_DELAY : '0ms',
}
sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right
sliderTop.value = newPosition.top
sliderBottom.value = newPosition.bottom
applySliderPosition(newPosition)
}
function positionSlider(animate = true) {
const newPosition = getSliderPosition()
if (!newPosition) {
return
}
if (!sliderReady.value) {
applySliderPosition(newPosition)
sliderReady.value = true
nextTick(() => {
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
})
return
}
if (!animate) {
transitionsEnabled.value = false
applySliderPosition(newPosition)
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
return
}
animateSliderTo(newPosition)
}
function resetSliderPosition() {
if (!sliderReady.value || currentActiveIndex.value === -1) {
return
}
positionSlider(false)
}
async function updateActiveTab() {
await nextTick()
const { index, isSubpage } = computeActiveIndex()
colorChangeDelayed.value = sliderReady.value && index !== currentActiveIndex.value
currentActiveIndex.value = index
subpageSelected.value = isSubpage
if (index !== -1) {
positionSlider()
} else {
sliderLeft.value = 0
sliderRight.value = 0
}
}
@@ -277,7 +321,19 @@ const initialActive = computeActiveIndex()
currentActiveIndex.value = initialActive.index
subpageSelected.value = initialActive.isSubpage
onMounted(updateActiveTab)
let resizeObserver: ResizeObserver | undefined
onMounted(() => {
updateActiveTab()
resizeObserver = new ResizeObserver(resetSliderPosition)
if (scrollContainer.value) {
resizeObserver.observe(scrollContainer.value)
}
})
onUnmounted(() => {
resizeObserver?.disconnect()
})
watch(
() => [route.path, route.query],
@@ -310,10 +366,17 @@ watch(
<style scoped>
.navtabs-transition {
transition:
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
left 80ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
right 80ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
top 80ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 80ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay);
}
.tab-color {
transition: color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}
.tab-color-delayed .tab-color {
transition-delay: 100ms;
}
</style>
@@ -0,0 +1,101 @@
<template>
<ProjectCardList :layout="layout">
<template v-for="project in projects" :key="project.id">
<ProjectCard
v-if="isProjectServer(project)"
:link="getProjectPagePath(project, linkMode)"
:title="project.name"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="getProjectCardTags(project)"
:server-online-players="project.minecraft_java_server?.ping?.data?.players_online ?? 0"
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
:server-region="project.minecraft_server?.region"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:server-modpack-content="getServerModpackContent(project, openModpackProject)"
:status="showStatus ? project.status : undefined"
:max-tags="2"
:layout="cardLayout"
is-server-project
exclude-loaders
>
<template v-if="$slots.actions" #actions>
<slot name="actions" :project="project" />
</template>
</ProjectCard>
<ProjectCard
v-else
:link="getProjectPagePath(project, linkMode)"
:title="project.name"
:icon-url="project.icon_url"
:date-updated="project.updated"
:downloads="project.downloads"
:summary="project.summary"
:tags="getProjectCardTags(project)"
:all-tags="getProjectCardAllTags(project)"
:followers="project.followers"
:banner="project.gallery?.find((image) => image.featured)?.url"
:color="project.color"
:environment="project.environment?.[0]"
:layout="cardLayout"
:status="showStatus ? project.status : undefined"
>
<template v-if="$slots.actions" #actions>
<slot name="actions" :project="project" />
</template>
</ProjectCard>
</template>
</ProjectCardList>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { PROJECT_DEP_MARKER_QUERY } from '#ui/providers'
import {
getProjectCardAllTags,
getProjectCardTags,
getProjectPagePath,
getServerModpackContent,
isProjectServer,
type ProjectLinkMode,
} from '#ui/utils/v3-projects'
import ProjectCard from './card/ProjectCard.vue'
import ProjectCardList from './ProjectCardList.vue'
const props = withDefaults(
defineProps<{
projects: Labrinth.Projects.v3.Project[]
layout?: 'list' | 'grid' | 'gallery'
linkMode?: ProjectLinkMode
showStatus?: boolean
}>(),
{
layout: 'list',
linkMode: 'website',
showStatus: false,
},
)
defineSlots<{
actions?: (props: { project: Labrinth.Projects.v3.Project }) => unknown
}>()
const router = useRouter()
const cardLayout = computed(() => (props.layout === 'list' ? 'list' : 'grid'))
function openModpackProject(projectId: string): void {
const path = `/project/${projectId}`
if (props.linkMode === 'app') {
void router.push(path)
return
}
void router.push({
path,
query: { ...PROJECT_DEP_MARKER_QUERY },
})
}
</script>
@@ -7,6 +7,7 @@ export { default as ProjectCard } from './card/ProjectCard.vue'
export { default as ProjectBackgroundGradient } from './ProjectBackgroundGradient.vue'
export { default as ProjectCardList } from './ProjectCardList.vue'
export { default as ProjectCombobox } from './ProjectCombobox.vue'
export { default as ProjectList } from './ProjectList.vue'
export { default as ProjectPageDescription } from './ProjectPageDescription.vue'
export { default as ProjectPageHeader } from './ProjectPageHeader.vue'
export { default as ProjectPageVersions } from './ProjectPageVersions.vue'
@@ -221,144 +221,117 @@
</UserPageHeader>
</template>
<div class="flex flex-col gap-4">
<div v-if="navLinks.length > 2" class="max-w-full overflow-x-auto">
<NavTabs :links="navLinks" replace />
</div>
<NavTabs v-if="navLinks.length > 2" :links="navLinks" replace page-nav />
<div class="flex flex-col gap-3">
<ProjectCardList
v-if="selectedProjectType !== 'collection' && filteredProjects.length > 0"
:layout="displayMode"
>
<ProjectCard
v-for="project in filteredProjects"
:key="project.id"
:link="projectLink(project)"
:title="project.title"
:icon-url="project.icon_url"
:date-updated="project.updated"
:downloads="project.downloads"
:summary="project.description"
:tags="[...project.categories, ...project.loaders]"
:all-tags="[
...project.categories,
...project.loaders,
...project.additional_categories,
]"
:followers="project.followers"
:banner="project.gallery?.find((image) => image.featured)?.url"
:color="project.color"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:layout="displayMode === 'list' ? 'list' : 'grid'"
:status="project.status"
>
<template v-if="$slots['project-actions']" #actions>
<slot name="project-actions" :project="project" />
</template>
</ProjectCard>
</ProjectCardList>
<div class="flex flex-col gap-3">
<ProjectList
v-if="selectedProjectType !== 'collection' && filteredProjects.length > 0"
:projects="filteredProjects"
:layout="displayMode"
:link-mode="projectLinkMode"
show-status
>
<template v-if="$slots['project-actions']" #actions="{ project }">
<slot name="project-actions" :project="project" />
</template>
</ProjectList>
<EmptyState
v-if="showProjectsEmptyState"
type="empty"
:heading="formatMessage(messages.profileNoProjectsLabel)"
:description="
isSelf ? formatMessage(messages.profileNoProjectsAuthDescription) : undefined
"
<EmptyState
v-if="showProjectsEmptyState"
type="empty"
:heading="formatMessage(messages.profileNoProjectsLabel)"
:description="
isSelf ? formatMessage(messages.profileNoProjectsAuthDescription) : undefined
"
>
<template v-if="isSelf" #actions>
<Button type="colored" color="brand" native-type="button" @click="createProject">
{{ formatMessage(messages.createProjectButton) }}
</Button>
</template>
</EmptyState>
<ProjectCardList
v-if="selectedProjectType === null || selectedProjectType === 'collection'"
layout="grid"
>
<SmartClickable
v-for="collection in sortedCollections"
:key="collection.id"
class="h-full w-full"
>
<template v-if="isSelf" #actions>
<Button type="colored" color="brand" native-type="button" @click="createProject">
{{ formatMessage(messages.createProjectButton) }}
</Button>
<template #clickable>
<AutoLink
:to="collectionLink(collection.id)"
class="no-click-animation custom-focus-indicator rounded-xl no-outline"
/>
</template>
</EmptyState>
<ProjectCardList
v-if="selectedProjectType === null || selectedProjectType === 'collection'"
layout="grid"
>
<SmartClickable
v-for="collection in sortedCollections"
:key="collection.id"
class="h-full w-full"
<div
class="smart-clickable:outline-on-focus smart-clickable:highlight-on-hover flex h-full w-full flex-col gap-4 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-4 bg-surface-3 p-4 text-left transition-all"
>
<template #clickable>
<AutoLink
:to="collectionLink(collection.id)"
class="no-click-animation custom-focus-indicator rounded-xl no-outline"
/>
</template>
<div
class="smart-clickable:outline-on-focus smart-clickable:highlight-on-hover flex h-full w-full flex-col gap-4 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-4 bg-surface-3 p-4 text-left transition-all"
>
<div class="grid grid-cols-[auto_1fr] gap-4">
<Avatar :src="collection.icon_url" size="64px" no-shadow />
<div class="flex min-w-0 flex-col gap-2">
<h2
class="smart-clickable:underline-on-hover m-0 truncate text-lg font-semibold text-contrast"
>
{{ collection.name }}
</h2>
<div class="flex items-center gap-1">
<LibraryIcon aria-hidden="true" />
{{ formatMessage(messages.collectionLabel) }}
</div>
</div>
</div>
<div class="grow text-primary">
{{ collection.description }}
</div>
<div class="mt-auto flex flex-wrap items-center gap-4">
<div class="grid grid-cols-[auto_1fr] gap-4">
<Avatar :src="collection.icon_url" size="64px" no-shadow />
<div class="flex min-w-0 flex-col gap-2">
<h2
class="smart-clickable:underline-on-hover m-0 truncate text-lg font-semibold text-contrast"
>
{{ collection.name }}
</h2>
<div class="flex items-center gap-1">
<BoxIcon />
{{
formatMessage(messages.collectionProjectsCount, {
count: collection.projects.length,
})
}}
</div>
<div class="flex items-center gap-1">
<template v-if="collection.status === 'listed'">
<GlobeIcon />
{{ formatMessage(commonMessages.publicLabel) }}
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
{{ formatMessage(commonMessages.unlistedLabel) }}
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
{{ formatMessage(commonMessages.privateLabel) }}
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
{{ formatMessage(commonMessages.rejectedLabel) }}
</template>
<LibraryIcon aria-hidden="true" />
{{ formatMessage(messages.collectionLabel) }}
</div>
</div>
</div>
</SmartClickable>
</ProjectCardList>
<div class="grow text-primary">
{{ collection.description }}
</div>
<div class="mt-auto flex flex-wrap items-center gap-4">
<div class="flex items-center gap-1">
<BoxIcon />
{{
formatMessage(messages.collectionProjectsCount, {
count: collection.projects.length,
})
}}
</div>
<div class="flex items-center gap-1">
<template v-if="collection.status === 'listed'">
<GlobeIcon />
{{ formatMessage(commonMessages.publicLabel) }}
</template>
<template v-else-if="collection.status === 'unlisted'">
<LinkIcon />
{{ formatMessage(commonMessages.unlistedLabel) }}
</template>
<template v-else-if="collection.status === 'private'">
<LockIcon />
{{ formatMessage(commonMessages.privateLabel) }}
</template>
<template v-else-if="collection.status === 'rejected'">
<XIcon />
{{ formatMessage(commonMessages.rejectedLabel) }}
</template>
</div>
</div>
</div>
</SmartClickable>
</ProjectCardList>
<EmptyState
v-if="showCollectionsEmptyState"
type="empty"
:heading="formatMessage(messages.profileNoCollectionsLabel)"
:description="
isSelf ? formatMessage(messages.profileNoCollectionsAuthDescription) : undefined
"
>
<template v-if="isSelf" #actions>
<Button type="colored" color="brand" native-type="button" @click="createCollection">
{{ formatMessage(messages.createCollectionButton) }}
</Button>
</template>
</EmptyState>
</div>
<EmptyState
v-if="showCollectionsEmptyState"
type="empty"
:heading="formatMessage(messages.profileNoCollectionsLabel)"
:description="
isSelf ? formatMessage(messages.profileNoCollectionsAuthDescription) : undefined
"
>
<template v-if="isSelf" #actions>
<Button type="colored" color="brand" native-type="button" @click="createCollection">
{{ formatMessage(messages.createCollectionButton) }}
</Button>
</template>
</EmptyState>
</div>
<template #sidebar>
@@ -430,6 +403,7 @@ import {
XIcon,
} from '@modrinth/assets'
import {
getPrimaryProjectType,
isModrinthUser as checkIsModrinthUser,
isOfficialAccount as checkIsOfficialAccount,
UserBadge,
@@ -448,8 +422,8 @@ import NavTabs from '#ui/components/base/NavTabs.vue'
import SmartClickable from '#ui/components/base/SmartClickable.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import NormalPage from '#ui/components/page/NormalPage.vue'
import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
import ProjectList from '#ui/components/project/ProjectList.vue'
import UserBadges from '#ui/components/user/UserBadges.vue'
import UserPageHeader from '#ui/components/user/UserPageHeader.vue'
import { defineMessages, useVIntl } from '#ui/composables'
@@ -458,27 +432,25 @@ import {
injectModrinthClient,
injectNotificationManager,
injectPageContext,
injectTags,
} from '#ui/providers'
import { commonMessages, getProjectTypeTitleMessage, sortProjectTypes } from '#ui/utils'
import {
catalogProjectTypes,
commonMessages,
filterProjectsByType,
getProjectTypeTitleMessage,
parseProjectTypeRouteParam,
sortProjectTypes,
} from '#ui/utils'
import EditUserModal from './components/edit-user-modal.vue'
import { blockedUsersQueryKey, injectUserProfile } from './providers'
import {
hasActivePride26Midas,
hasPride26Badge,
projectUserSorting,
resolveProjectType,
} from './utils'
import { hasActivePride26Midas, hasPride26Badge, projectUserSorting } from './utils'
type DisplayMode = 'list' | 'grid' | 'gallery'
type ModalRef = {
show: () => void
hide: () => void
}
type ResolvedProject = Labrinth.Projects.v2.Project & {
resolvedProjectType: string
}
type EarlyAdopterProjectType =
| 'modpack'
| 'resourcepack'
@@ -517,7 +489,6 @@ const props = withDefaults(
const userProfile = injectUserProfile()
const auth = injectAuth()
const tags = injectTags(null)
const pageContext = injectPageContext()
const notificationManager = injectNotificationManager()
const client = injectModrinthClient()
@@ -696,28 +667,33 @@ const messages = defineMessages({
},
})
const canLoadProfile = computed(() => props.userId.length > 0)
const userQuery = useQuery({
queryKey: computed(() => ['user', props.userId]),
queryFn: () => userProfile.getUser(props.userId),
enabled: computed(() => Boolean(props.userId)),
enabled: canLoadProfile,
placeholderData: (previousData) => previousData,
staleTime: 30_000,
})
const projectsQuery = useQuery({
queryKey: computed(() => ['user', props.userId, 'projects']),
queryFn: () => userProfile.getProjects(props.userId),
enabled: computed(() => Boolean(props.userId)),
enabled: canLoadProfile,
placeholderData: (previousData) => previousData,
staleTime: 30_000,
})
const organizationsQuery = useQuery({
queryKey: computed(() => ['user', props.userId, 'organizations']),
queryFn: () => userProfile.getOrganizations(props.userId),
enabled: computed(() => Boolean(props.userId)),
enabled: canLoadProfile,
placeholderData: (previousData) => previousData,
staleTime: 30_000,
})
const collectionsQuery = useQuery({
queryKey: computed(() => ['user', props.userId, 'collections']),
queryFn: () => userProfile.getCollections(props.userId),
enabled: computed(() => Boolean(props.userId)),
enabled: canLoadProfile,
placeholderData: (previousData) => previousData,
staleTime: 30_000,
})
const blockedUsersQuery = useQuery({
@@ -728,12 +704,7 @@ const blockedUsersQuery = useQuery({
})
const user = computed(() => userQuery.data.value)
const projects = computed<ResolvedProject[]>(() =>
(projectsQuery.data.value ?? []).map((project) => ({
...project,
resolvedProjectType: resolveProjectType(project, tags?.loaders.value ?? []),
})),
)
const projects = computed(() => projectsQuery.data.value ?? [])
watch(
() => projectsQuery.data.value,
(projects) => {
@@ -755,20 +726,11 @@ const isBlocked = computed(() =>
user.value ? (blockedUsersQuery.data.value ?? []).includes(user.value.id) : false,
)
const selectedProjectType = computed(() => {
const projectType = props.projectType
if (!projectType) return null
if (projectType === 'collections' || projectType === 'collection') return 'collection'
return projectType.endsWith('s') ? projectType.slice(0, -1) : projectType
})
const selectedProjectType = computed(() => parseProjectTypeRouteParam(props.projectType))
const filteredProjects = computed(() => {
const selected = selectedProjectType.value
return projects.value
.filter((project) => !selected || project.resolvedProjectType === selected)
.slice()
.sort(projectUserSorting)
})
const filteredProjects = computed(() =>
filterProjectsByType(projects.value, selectedProjectType.value).slice().sort(projectUserSorting),
)
const sortedOrganizations = computed(() =>
organizations.value.slice().sort((first, second) => first.name.localeCompare(second.name)),
@@ -782,9 +744,8 @@ const sortedCollections = computed(() =>
)
const projectTypes = computed(() => {
const types = new Set(projects.value.map((project) => project.resolvedProjectType))
if (collections.value.length > 0) types.add('collection')
types.delete('project')
const types = catalogProjectTypes(projects.value)
if (collections.value.length > 0) types.push('collection')
return sortProjectTypes(types)
})
@@ -819,7 +780,7 @@ const profileHeaderSummary = computed(() => {
const earliestProjectByType = computed(() => {
const earliest = {} as Record<EarlyAdopterProjectType, Date>
for (const project of projects.value) {
const projectType = project.resolvedProjectType as EarlyAdopterProjectType
const projectType = getPrimaryProjectType(project) as EarlyAdopterProjectType
const published = new Date(project.published)
if (!earliest[projectType] || published < earliest[projectType]) {
earliest[projectType] = published
@@ -881,13 +842,6 @@ function openPath(path: string): void {
}
}
function projectLink(project: ResolvedProject): string | (() => void) {
if (props.projectLinkMode === 'app') {
return `/project/${project.id}`
}
return `/${project.resolvedProjectType}/${project.slug || project.id}`
}
function organizationLink(slug: string): string | (() => void) {
return linkTarget(`/organization/${encodeURIComponent(slug)}`)
}
@@ -4,7 +4,7 @@ import { createContext } from '#ui/providers/create-context'
export interface UserProfileContext {
getUser: (userId: string) => Promise<Labrinth.Users.v3.User>
getProjects: (userId: string) => Promise<Labrinth.Projects.v2.Project[]>
getProjects: (userId: string) => Promise<Labrinth.Projects.v3.Project[]>
getOrganizations: (userId: string) => Promise<Labrinth.Organizations.v3.Organization[]>
getCollections: (userId: string) => Promise<Labrinth.Collections.Collection[]>
patchUser: (
@@ -17,7 +17,7 @@ const projectStatusPriority: Record<Labrinth.Projects.v2.ProjectStatus, ProjectS
}
function getProjectSortValue(
project: Labrinth.Projects.v2.Project,
project: Labrinth.Projects.v3.Project,
sorting: ProjectSorting,
): number {
switch (sorting) {
@@ -31,8 +31,8 @@ function getProjectSortValue(
}
export function projectUserSorting(
first: Labrinth.Projects.v2.Project,
second: Labrinth.Projects.v2.Project,
first: Labrinth.Projects.v3.Project,
second: Labrinth.Projects.v3.Project,
): number {
const firstPriority = projectStatusPriority[first.status] ?? projectStatusPriority.unknown
const secondPriority = projectStatusPriority[second.status] ?? projectStatusPriority.unknown
@@ -50,25 +50,6 @@ export function projectUserSorting(
)
}
export function resolveProjectType(
project: Labrinth.Projects.v2.Project,
loaders: Labrinth.Tags.v2.Loader[],
): string {
if (project.project_type !== 'mod') {
return project.project_type
}
const projectLoaders = new Set(project.loaders)
const supportsType = (type: string) =>
loaders.some(
(loader) => projectLoaders.has(loader.name) && loader.supported_project_types.includes(type),
)
if (supportsType('datapack')) return 'datapack'
if (supportsType('plugin')) return 'plugin'
return 'mod'
}
const PRIDE_26_MIDAS_DURATION_MS = 30 * 24 * 60 * 60 * 1000
export function hasPride26Badge(user?: Labrinth.Users.v3.User | null): boolean {
@@ -228,13 +228,15 @@
<ServerOnboardingPanelPage v-if="isOnboarding" :browse-modpacks="handleBrowseModpacks" />
<template v-else>
<div
data-pyro-navigation
class="server-stagger-item isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
:class="containedLayout ? 'shrink-0' : ''"
:style="{ '--si': 1 }"
>
<NavTabs :links="navLinks" replace />
<div class="server-stagger-item -mb-3">
<NavTabs
:links="navLinks"
replace
page-nav
data-pyro-navigation
:class="containedLayout ? 'shrink-0' : ''"
:style="{ '--si': 1 }"
/>
</div>
<div
+1
View File
@@ -828,6 +828,7 @@ export const financialMessages = defineMessages({
const PROJECT_TYPE_ALIASES: Record<string, string> = {
shaderpack: 'shader',
minecraft_java_server: 'server',
}
export function normalizeProjectType(type: string): string {
+1
View File
@@ -15,6 +15,7 @@ export * from './server-search'
export * from './tag-messages'
export * from './truncate'
export * from './use-anchored-teleport'
export * from './v3-projects'
export * from './version-compatibility'
export * from './vue-children'
export * from './webgl/skin-rendering'
+98
View File
@@ -0,0 +1,98 @@
import type { Labrinth } from '@modrinth/api-client'
import { getPrimaryProjectType } from '@modrinth/utils'
import { normalizeProjectType } from './common-messages'
import { sortProjectTypes } from './project-types'
export type ProjectLinkMode = 'website' | 'app'
export type ServerModpackContent = {
name: string
icon?: string
onclick?: () => void
showCustomModpackTooltip?: boolean
}
export function isProjectServer(project: Labrinth.Projects.v3.Project): boolean {
return project.minecraft_server != null
}
export function getProjectCardTags(project: Labrinth.Projects.v3.Project): string[] {
if (isProjectServer(project)) {
return project.categories
}
return [...project.categories, ...project.loaders, ...(project.mrpack_loaders ?? [])]
}
export function getProjectCardAllTags(project: Labrinth.Projects.v3.Project): string[] {
return [...getProjectCardTags(project), ...project.additional_categories]
}
export function getProjectPagePath(
project: Labrinth.Projects.v3.Project,
linkMode: ProjectLinkMode = 'website',
): string {
if (linkMode === 'app') {
return `/project/${project.id}`
}
if (isProjectServer(project)) {
return `/server/${project.slug || project.id}`
}
return `/${getPrimaryProjectType(project)}/${project.slug || project.id}`
}
export function getServerModpackContent(
project: Labrinth.Projects.v3.Project,
onNavigate?: (projectId: string) => void,
): ServerModpackContent | undefined {
const content = project.minecraft_java_server?.content
if (content?.kind !== 'modpack') {
return undefined
}
const { project_name, project_icon, project_id } = content
if (!project_name) {
return undefined
}
return {
name: project_name,
icon: project_icon,
onclick:
project_id && project_id !== project.id && onNavigate
? () => onNavigate(project_id)
: undefined,
showCustomModpackTooltip: project_id === project.id,
}
}
export function catalogProjectTypes(projects: Labrinth.Projects.v3.Project[]): string[] {
const types = new Set(projects.map((project) => getPrimaryProjectType(project)))
types.delete('project')
return sortProjectTypes(types)
}
export function parseProjectTypeRouteParam(param: unknown): string | null {
if (param == null) {
return null
}
const type = Array.isArray(param) ? param[0] : param
if (typeof type !== 'string' || type.length === 0) {
return null
}
if (type === 'collection' || type === 'collections') {
return 'collection'
}
const singular = type.endsWith('s') ? type.slice(0, -1) : type
return normalizeProjectType(singular)
}
export function filterProjectsByType(
projects: Labrinth.Projects.v3.Project[],
projectType: string | null,
): Labrinth.Projects.v3.Project[] {
if (!projectType) {
return projects
}
return projects.filter((project) => getPrimaryProjectType(project) === projectType)
}