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