feat: header migration pt 2

This commit is contained in:
Calum H. (IMB11)
2026-06-26 17:09:22 +01:00
parent 69ba34a8ad
commit e3c50d19ed
16 changed files with 906 additions and 532 deletions
+31 -1
View File
@@ -10,7 +10,9 @@ import {
} from '@modrinth/assets'
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
import {
BrowseInstallHeader,
BrowsePageLayout,
BrowseSidebar,
commonMessages,
CreationFlowModal,
defineMessages,
@@ -21,8 +23,10 @@ import {
preferencesDiffer,
provideBrowseManager,
requestInstall,
SelectedProjectsFloatingBar,
useBrowseSearch,
useDebugLogger,
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
@@ -79,6 +83,7 @@ const {
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContextWorldName,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
@@ -412,6 +417,10 @@ const messages = defineMessages({
id: 'app.browse.back-to-instance',
defaultMessage: 'Back to instance',
},
worldFallbackName: {
id: 'app.browse.server.world-fallback-name',
defaultMessage: 'Instance',
},
serverInstanceContentWarning: {
id: 'app.browse.server-instance-content-warning',
defaultMessage:
@@ -565,8 +574,9 @@ const selectableProjectTypes = computed(() => {
const installContext = computed(() => {
if (isServerContext.value && serverContextServerData.value) {
return {
name: serverContextServerData.value.name,
name: serverContextWorldName.value ?? formatMessage(messages.worldFallbackName),
loader: serverContextServerData.value.loader ?? '',
loaderVersion: serverContextServerData.value.loader_version ?? '',
gameVersion: serverContextServerData.value.mc_version ?? '',
serverId: serverIdQuery.value,
upstream: serverContextServerData.value.upstream,
@@ -606,6 +616,11 @@ const installContext = computed(() => {
}
return null
})
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'BrowseInstallHeader',
)
const installingProjectIds = ref<Set<string>>(new Set())
@@ -1092,6 +1107,17 @@ provideBrowseManager({
<template>
<div class="flex flex-col gap-3 p-6">
<div
v-if="installContext"
ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader />
</div>
<SelectedProjectsFloatingBar v-if="installContext" />
<BrowsePageLayout>
<template #after>
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
@@ -1118,5 +1144,9 @@ provideBrowseManager({
@browse-modpacks="() => {}"
@create="handleServerModpackFlowCreate"
/>
<Teleport to="#sidebar-teleport-target">
<BrowseSidebar />
</Teleport>
</div>
</template>
@@ -53,6 +53,7 @@ export interface ServerInstallContentContext {
isSetupServerContext: ComputedRef<boolean>
effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContextWorldName: ComputedRef<string | null>
serverContentProjectIds: Ref<Set<string>>
queuedServerInstallProjectIds: ComputedRef<Set<string>>
queuedServerInstallCount: ComputedRef<number>
@@ -226,6 +227,7 @@ export function createServerInstallContent(opts: {
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
const serverContextServerFull = ref<Archon.Servers.v1.ServerFull | null>(null)
const serverContentProjectIds = ref<Set<string>>(new Set())
const serverContentInstallKeys = ref<Set<string>>(new Set())
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
@@ -243,6 +245,18 @@ export function createServerInstallContent(opts: {
const isInstallingQueuedServerInstalls = ref(false)
const queuedInstallProgress = ref({ completed: 0, total: 0 })
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
const serverContextWorld = computed(() => {
const serverFull = serverContextServerFull.value
if (!serverFull) return null
const worldId = effectiveServerWorldId.value
if (worldId) {
return serverFull.worlds.find((world) => world.id === worldId) ?? null
}
return serverFull.worlds.find((world) => world.is_active) ?? serverFull.worlds[0] ?? null
})
const serverContextWorldName = computed(() => serverContextWorld.value?.name ?? null)
const serverBackUrl = computed(() => {
const sid = serverIdQuery.value
if (!sid) return '/hosting/manage'
@@ -266,9 +280,19 @@ export function createServerInstallContent(opts: {
return 'Installing content'
})
async function getServerContextServerFull(serverId: string) {
if (serverContextServerFull.value?.id === serverId) {
return serverContextServerFull.value
}
const server = await client.archon.servers_v1.get(serverId)
serverContextServerFull.value = server
return server
}
async function resolveServerContextWorldId(serverId: string) {
try {
const server = await client.archon.servers_v1.get(serverId)
const server = await getServerContextServerFull(serverId)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
} catch (err) {
@@ -304,6 +328,11 @@ export function createServerInstallContent(opts: {
} catch (err) {
handleError(err as Error)
}
try {
await getServerContextServerFull(sid)
} catch (err) {
handleError(err as Error)
}
let resolvedWorldId = effectiveServerWorldId.value
if (!resolvedWorldId) {
@@ -323,6 +352,7 @@ export function createServerInstallContent(opts: {
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
if (!sid) {
serverContextServerData.value = null
serverContextServerFull.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
setQueuedServerInstallPlans(new Map())
@@ -330,6 +360,8 @@ export function createServerInstallContent(opts: {
}
if (sid !== prevSid) {
serverContextWorldId.value = worldIdQuery.value
serverContextServerFull.value = null
serverContentProjectIds.value = new Set()
serverContentInstallKeys.value = new Set()
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
@@ -338,6 +370,15 @@ export function createServerInstallContent(opts: {
} catch (err) {
handleError(err as Error)
}
try {
const serverFull = await getServerContextServerFull(sid)
if (!worldIdQuery.value) {
const activeWorld = serverFull.worlds.find((world) => world.is_active)
serverContextWorldId.value = activeWorld?.id ?? serverFull.worlds[0]?.id ?? null
}
} catch (err) {
handleError(err as Error)
}
}
if (wid !== prevWid) {
@@ -586,6 +627,7 @@ export function createServerInstallContent(opts: {
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContextWorldName,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
@@ -83,6 +83,10 @@ const messages = defineMessages({
id: 'discover.install.heading.reset-modpack',
defaultMessage: 'Selecting modpack to install after reset',
},
worldFallbackName: {
id: 'discover.install.world-fallback-name',
defaultMessage: 'Instance',
},
})
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
@@ -145,6 +149,11 @@ export function useServerInstallContent({
return enabled
}),
})
const { data: serverFullData } = useQuery({
queryKey: computed(() => ['servers', 'v1', 'detail', currentServerId.value ?? ''] as const),
queryFn: () => client.archon.servers_v1.get(currentServerId.value!),
enabled: computed(() => !!currentServerId.value),
})
watch(serverData, (val) =>
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
@@ -699,13 +708,26 @@ export function useServerInstallContent({
? formatMessage(messages.resetModpackHeading)
: formatMessage(commonMessages.installingContentLabel),
)
const currentWorld = computed(() => {
const full = serverFullData.value
if (!full) return null
const worldId = currentWorldId.value
if (worldId) {
return full.worlds.find((world) => world.id === worldId) ?? null
}
return full.worlds.find((world) => world.is_active) ?? full.worlds[0] ?? null
})
const installContext = computed(() => {
if (!serverData.value) return null
return {
name: serverData.value.name,
loader: serverData.value.loader ?? '',
gameVersion: serverData.value.mc_version ?? '',
name: currentWorld.value?.name ?? formatMessage(messages.worldFallbackName),
loader: currentWorld.value?.content?.modloader ?? serverData.value.loader ?? '',
loaderVersion:
currentWorld.value?.content?.modloader_version ?? serverData.value.loader_version ?? '',
gameVersion: currentWorld.value?.content?.game_version ?? serverData.value.mc_version ?? '',
serverId: currentServerId.value,
upstream: serverData.value.upstream,
iconSrc: serverIcon.value,
+7 -10
View File
@@ -4,7 +4,6 @@ import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const route = useRoute()
const allowTabChanging = computed(() => !route.query.sid)
@@ -48,15 +47,13 @@ const selectableProjectTypes = [
]
</script>
<template>
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
<section class="normal-page__header mb-4 flex flex-col gap-4">
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"
replace
class="hidden md:flex"
/>
</section>
<div class="mx-auto box-border flex w-full max-w-[1280px] flex-col gap-4 px-6 pb-6">
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"
replace
class="hidden md:flex"
/>
<NuxtPage />
</div>
</template>
+105 -45
View File
@@ -13,7 +13,9 @@ import {
} from '@modrinth/assets'
import type { CardAction } from '@modrinth/ui'
import {
BrowseInstallHeader,
BrowsePageLayout,
BrowseSidebar,
commonMessages,
CreationFlowModal,
defineMessages,
@@ -21,8 +23,10 @@ import {
injectModrinthClient,
PROJECT_DEP_MARKER_QUERY,
provideBrowseManager,
SelectedProjectsFloatingBar,
useBrowseSearch,
useDebugLogger,
useStickyObserver,
useVIntl,
} from '@modrinth/ui'
import { cycleValue } from '@modrinth/utils'
@@ -169,6 +173,11 @@ const {
onboardingModalRef,
debug,
})
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
stickyInstallHeaderRef,
'DiscoverInstallHeader',
)
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
const content = project.minecraft_java_server?.content
@@ -192,49 +201,66 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
return undefined
}
async function search(requestParams: string) {
type DiscoverProjectSearchHit = Labrinth.Search.v2.ResultSearchProject & {
version_id?: string | null
}
function mapV3ProjectHit(hit: Labrinth.Search.v3.ResultSearchProject): DiscoverProjectSearchHit {
return {
...hit,
project_type: hit.project_types[0] ?? projectTypeId.value,
title: hit.name,
description: hit.summary,
versions: hit.version_id ? [hit.version_id] : [],
latest_version: hit.version_id,
icon_url: hit.icon_url ?? '',
client_side: 'unknown',
server_side: 'unknown',
}
}
async function fetchSearch(requestParams: string) {
debug('search() called', {
requestParams: requestParams.substring(0, 100),
isServer: isServerType.value,
projectTypeId: projectTypeId.value,
})
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (isServerType.value) {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
}
const url = `${base}search${requestParams}`
debug('search() fetching:', url.substring(0, 120))
const raw = await $fetch<Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults>(
url,
{
headers: withLabrinthCanaryHeader(),
},
)
const raw = await client.request<Labrinth.Search.v3.SearchResults>('/search', {
api: 'labrinth',
version: 3,
method: 'GET',
params: Object.fromEntries(new URLSearchParams(requestParams.replace(/^\?/, ''))),
headers: withLabrinthCanaryHeader(),
})
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
if ('hits_per_page' in raw) {
// v3 response (servers)
if (isServerType.value) {
return {
projectHits: [],
serverHits: raw.hits as Labrinth.Search.v3.ResultSearchProject[],
serverHits: raw.hits,
total_hits: raw.total_hits,
per_page: raw.hits_per_page,
}
}
return {
projectHits: raw.hits as Labrinth.Search.v2.ResultSearchProject[],
projectHits: raw.hits.map(mapV3ProjectHit),
serverHits: [],
total_hits: raw.total_hits,
per_page: raw.limit,
per_page: raw.hits_per_page,
}
}
async function search(requestParams: string) {
return await queryClient.ensureQueryData({
queryKey: ['discover', 'search', 'v3', requestParams],
queryFn: () => fetchSearch(requestParams),
staleTime: 30_000,
})
}
function getCardActions(
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
currentProjectType: string,
@@ -404,7 +430,7 @@ watch(
)
debug('calling initial refreshSearch')
searchState.refreshSearch()
await searchState.refreshSearch()
const ogTitle = computed(() =>
searchState.query.value
@@ -484,29 +510,63 @@ provideBrowseManager({
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<BrowsePageLayout>
<template #sidebar-prepend>
<AdPlaceholder v-if="!auth.user && !serverData" />
</template>
<template #display-mode-icon>
<GridIcon v-if="resultsDisplayMode === 'grid'" />
<ImageIcon v-else-if="resultsDisplayMode === 'gallery'" />
<ListIcon v-else />
</template>
<template #content-after>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</template>
</BrowsePageLayout>
<div
v-if="installContext"
ref="stickyInstallHeaderRef"
class="sticky top-0 z-20 -mx-6 border-0 border-solid border-divider bg-surface-1 px-6 pt-4"
:class="[isInstallHeaderStuck ? 'border-t' : '']"
>
<BrowseInstallHeader divider bottom-padding />
</div>
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
<div
class="grid min-w-0 gap-3"
:class="
cosmetics.rightSearchLayout
? 'lg:grid-cols-[minmax(0,1fr)_18.75rem]'
: 'lg:grid-cols-[18.75rem_minmax(0,1fr)]'
"
>
<section
class="flex min-w-0 flex-col gap-3"
:class="cosmetics.rightSearchLayout ? 'lg:order-1' : 'lg:order-2'"
>
<BrowsePageLayout>
<template #display-mode-icon>
<GridIcon v-if="resultsDisplayMode === 'grid'" />
<ImageIcon v-else-if="resultsDisplayMode === 'gallery'" />
<ListIcon v-else />
</template>
</BrowsePageLayout>
</section>
<aside
class="min-w-0"
:class="cosmetics.rightSearchLayout ? 'lg:order-2' : 'lg:order-1'"
:aria-label="formatMessage(commonMessages.filtersLabel)"
>
<BrowseSidebar>
<template #prepend>
<AdPlaceholder v-if="!auth.user && !serverData" />
</template>
</BrowseSidebar>
</aside>
</div>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</template>
<style lang="scss" scoped>
.search-background {