feat: ssr fixes + switch project page to tanstack (#5192)

* feat: ssr fixes

* feat: lazy load non-core data

* feat: ssr timing debugging

* feat: go back to all parallel

* feat: migrate to DI + set up mutators

* feat: remove double get versions request, only call v3

* refactor: [version].vue page to use composition API and typescript

* feat: gallery.vue start

* fix: remove left behind console log

* fix: type issues + gallery

* fix: versionsummary modal + version page direct join

* fix: projectRaw guard

* fix: currentMember val fix

* fix: actualProjectType

* fix: vers summary link same page

* fix: lint

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-01-23 20:12:50 +00:00
committed by GitHub
parent b54fcaa0b1
commit 986a7e6216
33 changed files with 3083 additions and 3305 deletions

View File

@@ -2,78 +2,54 @@
<nav
ref="scrollContainer"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="[mode === 'navigation' ? 'card-shadow' : undefined]"
:class="{ 'card-shadow': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
: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="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span
class="text-nowrap"
:class="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</NuxtLink>
</template>
<template v-else>
<div
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
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="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span
class="text-nowrap"
:class="{
'text-button-textSelected': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
>
<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
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity:
sliderLeft === 4 && sliderLeft === sliderRight ? 0 : currentActiveIndex === -1 ? 0 : 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"
></div>
/>
</nav>
</template>
@@ -89,6 +65,7 @@ interface Tab {
shown?: boolean
icon?: Component
subpages?: string[]
onHover?: () => void
}
const props = withDefaults(
@@ -109,124 +86,194 @@ const emit = defineEmits<{
tabClick: [index: number, tab: Tab]
}>()
// DOM refs
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)
// active tab state
const currentActiveIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
// SSR state
const sliderReady = ref(false) // Slider is positioned and should be visible
const transitionsEnabled = ref(false) // CSS transitions should apply (after first paint)
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
const sliderStyle = computed(() => ({
left: `${sliderLeft.value}px`,
top: `${sliderTop.value}px`,
right: `${sliderRight.value}px`,
bottom: `${sliderBottom.value}px`,
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
}))
const isActiveAndNotSubpage = computed(
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
)
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const tabLinkElements = ref()
function getSSRFallbackClasses(index: number) {
if (sliderReady.value) return {}
if (currentActiveIndex.value !== index) return {}
function pickLink() {
let index = -1
subpageSelected.value = false
return {
'rounded-full': true,
'bg-button-bgSelected': !subpageSelected.value,
'bg-button-bg': subpageSelected.value,
}
}
function getIconClasses(index: number) {
return {
'text-button-textSelected': isActiveAndNotSubpage.value(index),
'text-secondary': !isActiveAndNotSubpage.value(index),
}
}
function getLabelClasses(index: number) {
return {
'text-button-textSelected': isActiveAndNotSubpage.value(index),
'text-contrast': !isActiveAndNotSubpage.value(index),
}
}
function computeActiveIndex(): { index: number; isSubpage: boolean } {
if (props.mode === 'local' && props.activeIndex !== undefined) {
index = Math.min(props.activeIndex, filteredLinks.value.length - 1)
} else {
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
index = i
break
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i
break
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i
subpageSelected.value = true
break
}
return {
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
isSubpage: false,
}
}
currentActiveIndex.value = index
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
const decodedPath = decodeURIComponent(route.path)
if (currentActiveIndex.value !== -1) {
nextTick(() => startAnimation())
// Query-based matching
if (props.query) {
const queryValue = route.query[props.query]
if (queryValue === link.href || (!queryValue && !link.href)) {
return { index: i, isSubpage: false }
}
continue
}
// Exact path match
if (decodedPath === link.href) {
return { index: i, isSubpage: false }
}
// Subpage match
const isSubpageMatch =
decodedPath.includes(link.href) ||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
if (isSubpageMatch) {
return { index: i, isSubpage: true }
}
}
return { index: -1, isSubpage: false }
}
function getTabElement(index: number): HTMLElement | null {
if (!tabLinkElements.value?.[index]) return null
// In navigation mode, elements are NuxtLinks with $el property
// In local mode, elements are plain divs
const element = tabLinkElements.value[index]
return props.mode === 'navigation' ? (element as any).$el : element
}
function positionSlider() {
const el = getTabElement(currentActiveIndex.value)
if (!el?.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newPosition = {
left: el.offsetLeft,
top: el.offsetTop,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
}
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
// Initial positioning: set position instantly, no animation
sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right
sliderTop.value = newPosition.top
sliderBottom.value = newPosition.bottom
sliderReady.value = true
// enable transitions after slider is painted, so future changes animate
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
animateSliderTo(newPosition)
}
}
function animateSliderTo(newPosition: {
left: number
top: number
right: number
bottom: number
}) {
const STAGGER_DELAY = 200
// Horizontal animation - lead with the direction of movement
if (newPosition.left < sliderLeft.value) {
sliderLeft.value = newPosition.left
setTimeout(() => (sliderRight.value = newPosition.right), STAGGER_DELAY)
} else {
sliderRight.value = newPosition.right
setTimeout(() => (sliderLeft.value = newPosition.left), STAGGER_DELAY)
}
// Vertical animation - lead with the direction of movement
if (newPosition.top < sliderTop.value) {
sliderTop.value = newPosition.top
setTimeout(() => (sliderBottom.value = newPosition.bottom), STAGGER_DELAY)
} else {
sliderBottom.value = newPosition.bottom
setTimeout(() => (sliderTop.value = newPosition.top), STAGGER_DELAY)
}
}
function updateActiveTab() {
const { index, isSubpage } = computeActiveIndex()
currentActiveIndex.value = index
subpageSelected.value = isSubpage
if (index !== -1) {
nextTick(positionSlider)
} else {
sliderLeft.value = 0
sliderRight.value = 0
}
}
function startAnimation() {
// In navigation mode, elements are NuxtLinks with $el property
// In local mode, elements are plain divs
const el =
props.mode === 'navigation'
? tabLinkElements.value[currentActiveIndex.value]?.$el
: tabLinkElements.value[currentActiveIndex.value]
const initialActive = computeActiveIndex()
currentActiveIndex.value = initialActive.index
subpageSelected.value = initialActive.isSubpage
if (!el || !el.offsetParent) return
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
if (sliderLeft.value === 4 && sliderRight.value === 4) {
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
} else {
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
}
}
onMounted(() => {
pickLink()
})
onMounted(updateActiveTab)
watch(
() => [route.path, route.query],
() => {
if (props.mode === 'navigation') {
pickLink()
updateActiveTab()
}
},
)
@@ -235,19 +282,12 @@ watch(
() => props.activeIndex,
() => {
if (props.mode === 'local') {
pickLink()
updateActiveTab()
}
},
)
watch(
() => props.links,
() => {
// Re-trigger animation when links change
pickLink()
},
{ deep: true },
)
watch(() => props.links, updateActiveTab, { deep: true })
</script>
<style scoped>

View File

@@ -78,6 +78,7 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
AsteriskIcon,
ChevronRightIcon,
@@ -90,7 +91,6 @@ import {
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
import { nags } from '@modrinth/moderation'
import { ButtonStyled, defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
import type { Project, User, Version } from '@modrinth/utils'
import type { Component } from 'vue'
import { computed } from 'vue'
@@ -98,16 +98,10 @@ interface Tags {
rejectedStatuses: string[]
}
interface Member {
accepted?: boolean
project_role?: string
user?: Partial<User>
}
interface Props {
project: Project
versions?: Version[]
currentMember?: Member | null
project: Labrinth.Projects.v2.Project
versions?: Labrinth.Versions.v2.Version[]
currentMember?: Labrinth.Projects.v3.TeamMember | null
collapsed?: boolean
routeName?: string
tags: Tags
@@ -179,7 +173,7 @@ const emit = defineEmits<{
const nagContext = computed<NagContext>(() => ({
project: props.project,
versions: props.versions,
currentMember: props.currentMember as User,
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName,
tags: props.tags,
submitProject: submitForReview,