refactor: breadcrumbs system

This commit is contained in:
Calum H. (IMB11)
2026-07-28 18:44:45 +01:00
parent 82348fe40a
commit 9263e8a57d
19 changed files with 615 additions and 299 deletions
+6
View File
@@ -138,6 +138,10 @@ import {
setAppUpdateActions,
} from '@/providers/app-update.ts'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
createBreadcrumbManager,
provideBreadcrumbManager,
} from '@/providers/breadcrumbs'
import {
provideAppUpdateDownloadProgress,
subscribeToDownloadProgress,
@@ -157,6 +161,8 @@ import { AppPopupNotificationManager } from './providers/app-popup-notifications
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
@@ -15,26 +15,26 @@
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
v-if="index < breadcrumbs.length - 1 && breadcrumb.to"
:to="breadcrumb.to"
class="shrink-0 whitespace-nowrap text-primary"
>
{{ resolveLabel(breadcrumb.name) }}
{{ breadcrumb.label }}
</router-link>
<span
v-else
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
{{ breadcrumb.label }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="w-5 h-5 shrink-0"
/>
</template>
</div>
</div>
@@ -42,34 +42,11 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { injectBreadcrumbManager } from '@/providers/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return name.charAt(0) === '?' ? breadcrumbData.getName(name.slice(1)) : name
}
const { entries: breadcrumbs } = injectBreadcrumbManager()
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
+110 -43
View File
@@ -31,9 +31,9 @@ import {
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import type { Ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
@@ -58,7 +58,10 @@ import {
createServerInstallContent,
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import {
useBreadcrumb,
useRootBreadcrumb,
} from '@/providers/breadcrumbs'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
@@ -71,6 +74,16 @@ const debugLog = useDebugLogger('Browse')
const router = useRouter()
const route = useRoute()
const displayedBrowseRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/browse/')) {
displayedBrowseRoute.value = nextRoute
}
},
{ immediate: true },
)
const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
@@ -111,25 +124,6 @@ const {
markServerProjectInstalled,
} = serverInstallContent
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Category[]>),
get_loaders()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Loader[]>),
get_game_versions()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.GameVersion[]>),
])
const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value ?? [],
loaders: loaders.value ?? [],
categories: categories.value ?? [],
}))
type Instance = {
game_version: string
loader: string
@@ -152,6 +146,92 @@ const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
const breadcrumbLabel = ref(
displayedBrowseRoute.value.query.from === 'worlds'
? 'Discover servers'
: 'Discover content',
)
const instanceBreadcrumb = route.query.i
? useBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedBrowseRoute.value.query.i ?? '')}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
to: () =>
`/instance/${encodeURIComponent(String(displayedBrowseRoute.value.query.i ?? ''))}`,
})
: undefined
const instanceSectionBreadcrumb = instanceBreadcrumb
? useBreadcrumb(
{
slot: 'instance-section',
id: () =>
`instance-section:${String(displayedBrowseRoute.value.query.i ?? '')}:${
displayedBrowseRoute.value.query.from === 'worlds' ? 'worlds' : 'content'
}`,
label: () =>
displayedBrowseRoute.value.query.from === 'worlds' ? 'Worlds' : 'Content',
to: () => {
const instancePath = `/instance/${encodeURIComponent(
String(displayedBrowseRoute.value.query.i ?? ''),
)}`
return displayedBrowseRoute.value.query.from === 'worlds'
? `${instancePath}/worlds`
: instancePath
},
},
{ parent: instanceBreadcrumb },
)
: undefined
const serverBreadcrumbTo = ref(serverBackUrl.value)
watch(serverBackUrl, (value) => {
if (route.path.startsWith('/browse/')) {
serverBreadcrumbTo.value = value
}
})
const serverBreadcrumb = !instanceBreadcrumb && serverIdQuery.value
? useBreadcrumb({
slot: 'server',
id: () => `server:${String(displayedBrowseRoute.value.query.sid ?? '')}`,
label: () =>
serverContextServerData.value?.name ?? formatMessage(commonMessages.loadingLabel),
to: serverBreadcrumbTo,
})
: undefined
const breadcrumbParent = instanceSectionBreadcrumb ?? serverBreadcrumb
const breadcrumbDefinition = {
slot: 'browse',
id: () =>
`browse:${String(displayedBrowseRoute.value.params.projectType ?? '')}:${String(
displayedBrowseRoute.value.query.i ?? '',
)}:${String(displayedBrowseRoute.value.query.sid ?? '')}:${String(
displayedBrowseRoute.value.query.from ?? '',
)}`,
label: breadcrumbLabel,
to: () => displayedBrowseRoute.value.fullPath,
}
const browseBreadcrumb = breadcrumbParent
? useBreadcrumb(breadcrumbDefinition, { parent: breadcrumbParent })
: useRootBreadcrumb(breadcrumbDefinition)
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Category[]>),
get_loaders()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.Loader[]>),
get_game_versions()
.catch(handleError)
.then(ref<Labrinth.Tags.v2.GameVersion[]>),
])
const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value ?? [],
loaders: loaders.value ?? [],
categories: categories.value ?? [],
}))
if (isFromWorlds.value && route.params.projectType !== 'server') {
router.replace({
path: '/browse/server',
@@ -448,28 +528,16 @@ const messages = defineMessages({
},
})
const breadcrumbs = useBreadcrumbs()
const browseTitle = computed(() =>
formatMessage(isFromWorlds.value ? messages.discoverServers : messages.discoverContent),
)
breadcrumbs.setName('BrowseTitle', browseTitle.value)
if (instance.value) {
const instanceLink = `/instance/${encodeURIComponent(instance.value.id)}`
breadcrumbs.setContext({
name: instance.value.name,
link: isFromWorlds.value ? `${instanceLink}/worlds` : instanceLink,
})
} else {
breadcrumbs.setContext(null)
}
onBeforeRouteLeave(() => {
breadcrumbs.setContext({
name: browseTitle.value,
link: `/browse/${projectType.value}`,
query: route.query,
})
})
watch(
browseTitle,
(value) => {
breadcrumbLabel.value = value
},
{ immediate: true },
)
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
@@ -484,8 +552,7 @@ function resetInstanceContext() {
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
breadcrumbs.setName('BrowseTitle', formatMessage(messages.discoverContent))
breadcrumbs.setContext(null)
browseBreadcrumb.reset()
}
watch(
+7 -5
View File
@@ -3,7 +3,6 @@ import { injectNotificationManager } from '@modrinth/ui'
import type { SearchResult } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import RowDisplay from '@/components/RowDisplay.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
@@ -11,13 +10,16 @@ import { get_search_results } from '@/helpers/cache.js'
import { instance_listener } from '@/helpers/events'
import { list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { handleError } = injectNotificationManager()
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Home', link: route.path })
useRootBreadcrumb({
slot: 'root',
id: 'home',
label: 'Home',
to: '/',
})
const instances = ref<GameInstance[]>([])
+9
View File
@@ -4,12 +4,21 @@ import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { config } from '../config'
const stripePublishableKey = (config.stripePublishableKey as string) || ''
const client = injectModrinthClient()
useRootBreadcrumb({
slot: 'root',
id: 'servers',
label: 'Servers',
to: '/hosting/manage/',
})
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
+8
View File
@@ -56,9 +56,17 @@ import {
set_custom_skin_order,
} from '@/helpers/skins.ts'
import { hasPride26Badge } from '@/helpers/user-campaigns.ts'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { handleSevereError } from '@/store/error'
import { useTheming } from '@/store/state'
useRootBreadcrumb({
slot: 'root',
id: 'skins',
label: 'Skin selector',
to: '/skins',
})
type UnlistenFn = () => void
type VirtualSkinSectionListExpose = {
getAddSkinButtonElement: () => HTMLElement | null | undefined
+31 -24
View File
@@ -14,7 +14,7 @@
<script setup lang="ts">
import { provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import {
@@ -24,11 +24,10 @@ import {
get_user_projects,
patch_user,
} from '@/helpers/users'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const route = useRoute()
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const userProfile = provideUserProfile({
getUser: get_user_profile,
getProjects: get_user_projects,
@@ -46,17 +45,43 @@ const projectType = computed(() => {
return Array.isArray(value) ? value[0] : value
})
const { data: user } = useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: () => userProfile.getUser(userId.value),
enabled: false,
staleTime: 30_000,
})
const breadcrumbUserId = ref(userId.value)
const breadcrumbLabel = ref(userId.value)
const breadcrumbTo = ref(route.fullPath)
watch(
[userId, user, () => route.fullPath],
([currentUserId, currentUser, currentPath]) => {
if (route.name !== 'User') return
breadcrumbUserId.value = currentUserId
breadcrumbLabel.value = currentUser?.username ?? currentUserId
breadcrumbTo.value = currentPath
},
{ immediate: true, flush: 'sync' },
)
useRootBreadcrumb({
slot: 'root',
id: () => `user:${breadcrumbUserId.value}`,
label: breadcrumbLabel,
to: breadcrumbTo,
})
async function ensureUserProfileData(id: string): Promise<void> {
if (!id) return
let breadcrumbName = id
try {
const user = await queryClient.ensureQueryData({
await queryClient.ensureQueryData({
queryKey: ['user', id],
queryFn: () => userProfile.getUser(id),
staleTime: 30_000,
})
breadcrumbName = user.username
} catch {
// Let the mounted layout's useQuery surface errors; do not fail route setup.
}
@@ -78,8 +103,6 @@ async function ensureUserProfileData(id: string): Promise<void> {
staleTime: 30_000,
}),
])
breadcrumbs.setName('User', breadcrumbName)
}
onBeforeRouteUpdate(async (to) => {
@@ -88,21 +111,5 @@ onBeforeRouteUpdate(async (to) => {
await ensureUserProfileData(id)
})
breadcrumbs.setName('User', userId.value)
await ensureUserProfileData(userId.value)
const { data: user } = useQuery({
queryKey: computed(() => ['user', userId.value]),
queryFn: () => userProfile.getUser(userId.value),
enabled: false,
staleTime: 30_000,
})
watch(
[userId, user],
([currentUserId, value]) => {
breadcrumbs.setName('User', value?.username ?? currentUserId)
},
{ immediate: true },
)
</script>
+10 -1
View File
@@ -1,4 +1,13 @@
<script setup lang="ts"></script>
<script setup lang="ts">
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
useRootBreadcrumb({
slot: 'root',
id: 'worlds',
label: 'Worlds',
to: '/worlds',
})
</script>
<template>
<div class="p-6 flex flex-col gap-2">Worlds</div>
</template>
@@ -48,15 +48,24 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import {
commonMessages,
injectAuth,
injectModrinthClient,
ServersManageRootLayout,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import {
provideBreadcrumbParent,
useBreadcrumb,
} from '@/providers/breadcrumbs'
import { useTheming } from '@/store/theme'
const route = useRoute()
@@ -65,15 +74,49 @@ const auth = injectAuth()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const { formatMessage } = useVIntl()
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
return Array.isArray(rawId) ? (rawId[0] ?? '') : (rawId ?? '')
})
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,
enabled: false,
})
const breadcrumbServerId = ref(serverId.value)
const breadcrumbLabel = ref(formatMessage(commonMessages.loadingLabel))
watch(
serverId,
(value) => {
if (!route.path.startsWith('/hosting/manage/') || route.name === 'Servers') return
breadcrumbServerId.value = value
breadcrumbLabel.value = formatMessage(commonMessages.loadingLabel)
},
{ flush: 'sync' },
)
watch(
serverData,
(server) => {
if (!route.path.startsWith('/hosting/manage/') || !server?.name) return
breadcrumbLabel.value = server.name
},
{ immediate: true },
)
const serverBreadcrumb = useBreadcrumb({
slot: 'server',
id: () => `server:${breadcrumbServerId.value}`,
label: breadcrumbLabel,
to: () => `/hosting/manage/${encodeURIComponent(breadcrumbServerId.value)}`,
})
provideBreadcrumbParent(serverBreadcrumb)
if (serverId.value) {
try {
await queryClient.ensureQueryData({
@@ -86,26 +129,6 @@ if (serverId.value) {
}
}
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,
enabled: false,
})
watch(
serverData,
(server) => {
if (server?.name) {
breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({
name: server.name,
link: `/hosting/manage/${serverId.value}/content`,
})
}
},
{ immediate: true },
)
watch(
() => auth.user.value,
(user, previousUser) => {
+53 -17
View File
@@ -138,7 +138,14 @@ import {
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { injectAuth, injectNotificationManager, NavTabs, useLoadingBarToken } from '@modrinth/ui'
import {
commonMessages,
injectAuth,
injectNotificationManager,
NavTabs,
useLoadingBarToken,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
@@ -187,7 +194,11 @@ import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import {
provideBreadcrumbParent,
useBreadcrumb,
} from '@/providers/breadcrumbs'
import { useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
@@ -198,10 +209,10 @@ const { playServerProject } = injectServerInstall()
const auth = injectAuth()
const queryClient = useQueryClient()
const route = useRoute()
const { formatMessage } = useVIntl()
const router = useRouter()
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
@@ -215,6 +226,45 @@ window.addEventListener('online', () => {
})
const instance = ref<GameInstance>()
const instanceBreadcrumb = useBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
label: () =>
instance.value?.id === displayedInstanceRoute.value.params.id
? instance.value.name
: formatMessage(commonMessages.loadingLabel),
to: () =>
`/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
})
provideBreadcrumbParent(instanceBreadcrumb)
const instanceSection = computed(() => {
switch (displayedInstanceRoute.value.name) {
case 'InstanceWorlds':
return { id: 'worlds', label: 'Worlds' }
case 'InstanceShare':
return { id: 'share', label: 'Share' }
case 'Files':
return { id: 'files', label: 'Files' }
case 'Logs':
return { id: 'logs', label: 'Logs' }
default:
return { id: 'content', label: 'Content' }
}
})
useBreadcrumb(
{
slot: 'instance-section',
id: () =>
`instance-section:${String(displayedInstanceRoute.value.params.id ?? '')}:${
instanceSection.value.id
}`,
label: () => instanceSection.value.label,
to: () => displayedInstanceRoute.value.fullPath,
},
{ parent: instanceBreadcrumb },
)
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
@@ -503,20 +553,6 @@ watch(
{ immediate: true },
)
if (instance.value) {
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
breadcrumbs.setContext({
name: instance.value.name,
link: displayedInstanceRoute.value.path,
query: displayedInstanceRoute.value.query,
})
}
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
const launchInstance = async (context: string) => {
@@ -7,14 +7,18 @@ import { useRoute } from 'vue-router'
import { NewInstanceImage } from '@/assets/icons'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { useBreadcrumbs } from '@/store/breadcrumbs.js'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
const { handleError } = injectNotificationManager()
const showCreationModal = inject('showCreationModal')
const route = useRoute()
const breadcrumbs = useBreadcrumbs()
breadcrumbs.setRootContext({ name: 'Library', link: route.path })
useRootBreadcrumb({
slot: 'root',
id: 'library',
label: 'Library',
to: '/library',
})
const instances = shallowRef(await list().catch(handleError))
@@ -95,6 +95,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { trackEvent } from '@/helpers/analytics'
import { useBreadcrumb } from '@/providers/breadcrumbs'
const MC_SERVER_BANNER_NAME = '__mc_server_banner__'
@@ -111,6 +112,16 @@ const props = defineProps({
},
})
useBreadcrumb({
slot: 'project-section',
id: () => `project-gallery:${props.project?.id ?? ''}`,
label: 'Gallery',
to: () => ({
name: 'Gallery',
params: { id: props.project?.id ?? '' },
}),
})
const filteredGallery = computed(
() => props.project.gallery?.filter((img) => img.title !== MC_SERVER_BANNER_NAME) ?? [],
)
+33 -4
View File
@@ -298,7 +298,10 @@ import { getServerAddress } from '@/helpers/worlds'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import { createServerInstallContent } from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import {
provideBreadcrumbParent,
useBreadcrumb,
} from '@/providers/breadcrumbs'
import { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
@@ -307,8 +310,17 @@ const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const route = useRoute()
const router = useRouter()
const displayedProjectRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.path.startsWith('/project/')) {
displayedProjectRoute.value = nextRoute
}
},
{ immediate: true },
)
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -331,6 +343,21 @@ const { installingServerProjects, playServerProject, showAddServerToInstanceModa
injectServerInstall()
const installing = ref(false)
const data = shallowRef(null)
const projectBreadcrumbLabel = ref(
String(route.params.id ?? formatMessage(commonMessages.loadingLabel)),
)
const projectBreadcrumb = useBreadcrumb({
slot: 'project',
id: () => `project:${String(displayedProjectRoute.value.params.id ?? '')}`,
label: projectBreadcrumbLabel,
to: () => ({
name: 'Description',
params: { id: displayedProjectRoute.value.params.id },
query: displayedProjectRoute.value.query,
}),
})
provideBreadcrumbParent(projectBreadcrumb)
const versions = shallowRef([])
const members = shallowRef([])
const categories = shallowRef([])
@@ -607,6 +634,9 @@ function reportProject() {
}
async function fetchProjectData() {
projectBreadcrumbLabel.value = String(
route.params.id ?? formatMessage(commonMessages.loadingLabel),
)
const [project, projectV3Result] = await Promise.all([
get_project(route.params.id, 'must_revalidate').catch(handleError),
get_project_v3(route.params.id, 'must_revalidate').catch(handleError),
@@ -619,6 +649,7 @@ async function fetchProjectData() {
}
data.value = project
projectBreadcrumbLabel.value = project.title
;[versions.value, members.value, categories.value, instance.value, instanceProjects.value] =
await Promise.all([
get_version_many(project.versions, 'must_revalidate').catch(handleError),
@@ -647,8 +678,6 @@ async function fetchProjectData() {
isServerProject.value = projectV3.value?.minecraft_server != null
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
breadcrumbs.setName('Project', data.value.title)
fetchDeferredServerData(project)
}
+42 -11
View File
@@ -95,12 +95,12 @@ import {
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons'
import { get_project_many, get_version_many } from '@/helpers/cache.js'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useBreadcrumb } from '@/providers/breadcrumbs'
const { formatMessage } = useVIntl()
@@ -119,8 +119,18 @@ const messages = defineMessages({
},
})
const breadcrumbs = useBreadcrumbs()
const route = useRoute()
const router = useRouter()
const displayedVersionRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.name === 'Version') {
displayedVersionRoute.value = nextRoute
}
},
{ immediate: true },
)
const props = defineProps<{
project: Labrinth.Projects.v2.Project
@@ -133,9 +143,33 @@ const props = defineProps<{
}>()
const version = ref(props.versions.find((version) => version.id === route.params.version))
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
const versionsBreadcrumb = useBreadcrumb({
slot: 'project-section',
id: () => `project-versions:${props.project.id}`,
label: 'Versions',
to: () => ({
name: 'Versions',
params: { id: props.project.id },
query: displayedVersionRoute.value.query,
}),
})
useBreadcrumb(
{
slot: 'project-version',
id: () =>
`version:${props.project.id}:${String(
displayedVersionRoute.value.params.version ?? '',
)}`,
label: () =>
version.value?.name ??
String(
displayedVersionRoute.value.params.version ??
formatMessage(commonMessages.loadingLabel),
),
to: () => displayedVersionRoute.value.fullPath,
},
{ parent: versionsBreadcrumb },
)
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
@@ -203,13 +237,10 @@ async function refreshEnrichment() {
}
watch(
() => props.versions,
[() => props.versions, () => route.params.version],
async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
if (version.value) {
breadcrumbs.setName('Version', version.value.name)
}
await refreshEnrichment()
}
},
@@ -82,11 +82,12 @@ import {
ProjectPageVersions,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SwapIcon } from '@/assets/icons/index.js'
import { get_game_versions, get_loaders } from '@/helpers/tags.js'
import { useBreadcrumb } from '@/providers/breadcrumbs'
import { useTheming } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
@@ -99,7 +100,7 @@ const messages = defineMessages({
},
})
defineProps({
const props = defineProps({
project: {
type: Object,
default: () => {},
@@ -132,6 +133,28 @@ defineProps({
const { handleError } = injectNotificationManager()
const route = useRoute()
const router = useRouter()
const displayedVersionsRoute = shallowRef(router.currentRoute.value)
watch(
() => router.currentRoute.value,
(nextRoute) => {
if (nextRoute.name === 'Versions') {
displayedVersionsRoute.value = nextRoute
}
},
{ immediate: true },
)
useBreadcrumb({
slot: 'project-section',
id: () => `project-versions:${props.project?.id ?? ''}`,
label: 'Versions',
to: () => ({
name: 'Versions',
params: { id: props.project?.id ?? route.params.id },
query: displayedVersionsRoute.value.query,
}),
})
function buildProjectHref(path) {
const params = new URLSearchParams()
@@ -0,0 +1,200 @@
import { createContext } from '@modrinth/ui'
import {
computed,
type MaybeRefOrGetter,
type ComputedRef,
shallowRef,
toValue,
watch,
} from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export interface BreadcrumbDefinition {
slot: string
id: MaybeRefOrGetter<string>
label: MaybeRefOrGetter<string>
to?: MaybeRefOrGetter<RouteLocationRaw | undefined>
}
export interface ResolvedBreadcrumb {
slot: string
id: string
label: string
to?: RouteLocationRaw
}
export interface BreadcrumbHandle {
readonly slot: string
activate: () => void
reset: () => void
pop: () => void
}
export interface BreadcrumbManager {
readonly entries: ComputedRef<ResolvedBreadcrumb[]>
reset: (definition: BreadcrumbDefinition) => BreadcrumbHandle
push: (
definition: BreadcrumbDefinition,
options?: { parent?: BreadcrumbHandle },
) => BreadcrumbHandle
find: (slot: string) => BreadcrumbHandle | undefined
}
interface InternalBreadcrumb {
token: symbol
definition: BreadcrumbDefinition
parentToken?: symbol
parentSlot?: string
root: boolean
handle: BreadcrumbHandle
}
const [injectBreadcrumbManager, provideBreadcrumbManager] = createContext<BreadcrumbManager>(
'root',
'breadcrumbManager',
)
const [injectBreadcrumbParent, provideBreadcrumbParent] = createContext<BreadcrumbHandle>(
'BreadcrumbParent',
'breadcrumbParent',
)
export { injectBreadcrumbManager, provideBreadcrumbManager, provideBreadcrumbParent }
export function createBreadcrumbManager(): BreadcrumbManager {
const stack = shallowRef<InternalBreadcrumb[]>([])
function findIndex(entry: InternalBreadcrumb): number {
const tokenIndex = stack.value.findIndex((candidate) => candidate.token === entry.token)
if (tokenIndex !== -1) return tokenIndex
return stack.value.findIndex((candidate) => candidate.definition.slot === entry.definition.slot)
}
function findParentIndex(entry: InternalBreadcrumb): number {
if (entry.parentToken) {
const tokenIndex = stack.value.findIndex(
(candidate) => candidate.token === entry.parentToken,
)
if (tokenIndex !== -1) return tokenIndex
}
if (entry.parentSlot) {
return stack.value.findIndex(
(candidate) => candidate.definition.slot === entry.parentSlot,
)
}
return stack.value.length - 1
}
function activate(entry: InternalBreadcrumb) {
if (entry.root) {
stack.value = [entry]
return
}
const existingIndex = findIndex(entry)
if (existingIndex !== -1) {
stack.value = [...stack.value.slice(0, existingIndex), entry]
return
}
const parentIndex = findParentIndex(entry)
stack.value = [...stack.value.slice(0, parentIndex + 1), entry]
}
function resetTo(entry: InternalBreadcrumb) {
stack.value = [entry]
}
function pop(entry: InternalBreadcrumb) {
const index = stack.value.findIndex((candidate) => candidate.token === entry.token)
if (index !== -1) {
stack.value = stack.value.slice(0, index)
}
}
function createHandle(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle; root: boolean },
): BreadcrumbHandle {
const entry = {} as InternalBreadcrumb
const handle: BreadcrumbHandle = {
slot: definition.slot,
activate: () => activate(entry),
reset: () => resetTo(entry),
pop: () => pop(entry),
}
Object.assign(entry, {
token: Symbol(definition.slot),
definition,
parentToken: options.parent
? stack.value.find((candidate) => candidate.handle === options.parent)?.token
: undefined,
parentSlot: options.parent?.slot,
root: options.root,
handle,
})
return handle
}
function reset(definition: BreadcrumbDefinition): BreadcrumbHandle {
const handle = createHandle(definition, { root: true })
handle.reset()
return handle
}
function push(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle } = {},
): BreadcrumbHandle {
const handle = createHandle(definition, { ...options, root: false })
handle.activate()
return handle
}
const entries = computed<ResolvedBreadcrumb[]>(() =>
stack.value.map(({ definition }) => ({
slot: definition.slot,
id: toValue(definition.id),
label: toValue(definition.label),
to: definition.to === undefined ? undefined : toValue(definition.to),
})),
)
return {
entries,
reset,
push,
find: (slot) =>
stack.value.find((entry) => entry.definition.slot === slot)?.handle,
}
}
function watchBreadcrumbIdentity(
definition: BreadcrumbDefinition,
handle: BreadcrumbHandle,
) {
watch(
() => toValue(definition.id),
() => handle.activate(),
{ flush: 'sync' },
)
}
export function useRootBreadcrumb(definition: BreadcrumbDefinition): BreadcrumbHandle {
const manager = injectBreadcrumbManager()
const handle = manager.reset(definition)
watchBreadcrumbIdentity(definition, handle)
return handle
}
export function useBreadcrumb(
definition: BreadcrumbDefinition,
options: { parent?: BreadcrumbHandle } = {},
): BreadcrumbHandle {
const manager = injectBreadcrumbManager()
const parent = options.parent ?? injectBreadcrumbParent(null) ?? undefined
const handle = manager.push(definition, { parent })
watchBreadcrumbIdentity(definition, handle)
return handle
}
-88
View File
@@ -16,25 +16,16 @@ export default new createRouter({
path: '/',
name: 'Home',
component: Pages.Index,
meta: {
breadcrumb: [{ name: 'Home' }],
},
},
{
path: '/worlds',
name: 'Worlds',
component: Pages.Worlds,
meta: {
breadcrumb: [{ name: 'Worlds' }],
},
},
{
path: '/hosting/manage/',
name: 'Servers',
component: Pages.Servers,
meta: {
breadcrumb: [{ name: 'Servers' }],
},
},
{
path: '/hosting/manage/:id',
@@ -45,41 +36,26 @@ export default new createRouter({
path: '',
name: 'ServerManageOverview',
component: Hosting.Overview,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'content',
name: 'ServerManageContent',
component: Hosting.Content,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'files',
name: 'ServerManageFiles',
component: Hosting.Files,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'backups',
name: 'ServerManageBackups',
component: Hosting.Backups,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'access',
name: 'ServerManageAccess',
component: Hosting.Access,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
@@ -87,34 +63,21 @@ export default new createRouter({
path: '/browse/:projectType',
name: 'Discover content',
component: Pages.Browse,
meta: {
useContext: true,
breadcrumb: [{ name: '?BrowseTitle' }],
},
},
{
path: '/skins',
name: 'Skin selector',
component: Pages.Skins,
meta: {
breadcrumb: [{ name: 'Skin selector' }],
},
},
{
path: '/user/:user/:projectType?',
name: 'User',
component: Pages.User,
meta: {
breadcrumb: [{ name: '?User' }],
},
},
{
path: '/library',
name: 'Library',
component: Library.Index,
meta: {
breadcrumb: [{ name: 'Library' }],
},
children: [
{
path: '',
@@ -160,42 +123,22 @@ export default new createRouter({
path: '',
name: 'Description',
component: Project.Description,
meta: {
useContext: true,
breadcrumb: [{ name: '?Project' }],
},
},
{
path: 'versions',
name: 'Versions',
component: Project.Versions,
meta: {
useContext: true,
breadcrumb: [{ name: '?Project', link: '/project/{id}/' }, { name: 'Versions' }],
},
},
{
path: 'version/:version',
name: 'Version',
component: Project.Version,
props: true,
meta: {
useContext: true,
breadcrumb: [
{ name: '?Project', link: '/project/{id}/' },
{ name: 'Versions', link: '/project/{id}/versions' },
{ name: '?Version' },
],
},
},
{
path: 'gallery',
name: 'Gallery',
component: Project.Gallery,
meta: {
useContext: true,
breadcrumb: [{ name: '?Project', link: '/project/{id}/' }, { name: 'Gallery' }],
},
},
],
},
@@ -205,59 +148,30 @@ export default new createRouter({
component: Instance.Index,
props: true,
children: [
// {
// path: '',
// name: 'Overview',
// component: Instance.Overview,
// meta: {
// useRootContext: true,
// breadcrumb: [{ name: '?Instance' }],
// },
// },
{
path: 'worlds',
name: 'InstanceWorlds',
component: Instance.Worlds,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Worlds' }],
},
},
{
path: 'share',
name: 'InstanceShare',
component: Instance.Share,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Share' }],
},
},
{
path: '',
name: 'Mods',
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
path: 'projects/:type',
name: 'ModsFilter',
component: Instance.Mods,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Content' }],
},
},
{
path: 'files',
name: 'Files',
component: Instance.Files,
meta: {
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Files' }],
},
},
{
path: 'logs',
@@ -265,8 +179,6 @@ export default new createRouter({
component: Instance.Logs,
meta: {
renderMode: 'fixed',
useRootContext: true,
breadcrumb: [{ name: '?Instance', link: '/instance/{id}/' }, { name: 'Logs' }],
},
},
],
@@ -1,37 +0,0 @@
import { defineStore } from 'pinia'
export const useBreadcrumbs = defineStore('breadcrumbsStore', {
state: () => ({
names: new Map(),
context: null,
rootContext: null,
}),
actions: {
getName(route) {
return this.names.get(route) ?? ''
},
setName(route, title) {
this.names.set(route, title)
},
// resets breadcrumbs to only included ones as to not have stale breadcrumbs
resetToNames(breadcrumbs) {
if (!breadcrumbs) return
// names is an array of every breadcrumb.name that starts with a ?
const names = breadcrumbs
.filter((breadcrumb) => breadcrumb.name.charAt(0) === '?')
.map((breadcrumb) => breadcrumb.name.slice(1))
// remove all names that are not in the names array
for (const [route] of this.names) {
if (!names.includes(route)) {
this.names.delete(route)
}
}
},
setContext(context) {
this.context = context
},
setRootContext(context) {
this.rootContext = context
},
},
})
+1 -2
View File
@@ -1,4 +1,3 @@
import { useBreadcrumbs } from './breadcrumbs'
import { useTheming } from './theme.ts'
export { useBreadcrumbs, useTheming }
export { useTheming }