refactor: breadcrumbs system (#6912)

* refactor: breadcrumbs system

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: loading flashes

* fix: qa

* fix: prepr

* fix: qa

* fix: change icon

* use modrinth logo in header for compactness

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-07-28 20:38:16 +00:00
committed by GitHub
co-authored by Prospector
parent f89c116bf8
commit b17523e7fd
23 changed files with 749 additions and 355 deletions
+40 -10
View File
@@ -10,9 +10,10 @@ import {
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChevronLeftIcon,
ChevronRightIcon,
CompassIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
@@ -46,6 +47,7 @@ import {
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
TextLogo,
useDebugLogger,
useFormatBytes,
useHostingIntercom,
@@ -63,7 +65,6 @@ import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue'
import AppActionBar from '@/components/ui/AppActionBar.vue'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
@@ -129,6 +130,7 @@ import {
openAppUpdateChangelog,
setAppUpdateActions,
} from '@/providers/app-update.ts'
import { createBreadcrumbManager, provideBreadcrumbManager } from '@/providers/breadcrumbs'
import { createContentInstall, provideContentInstall } from '@/providers/content-install'
import {
provideAppUpdateDownloadProgress,
@@ -151,6 +153,19 @@ import { appSettingsModalOpenProfileKey } from './providers/app-settings-modal'
const themeStore = useTheming()
const router = useRouter()
const route = useRoute()
const breadcrumbManager = createBreadcrumbManager()
provideBreadcrumbManager(breadcrumbManager)
const canNavigateBack = ref(false)
const canNavigateForward = ref(false)
function updateHistoryNavigationState() {
const historyState = window.history.state
canNavigateBack.value = historyState?.back != null
canNavigateForward.value = historyState?.forward != null
}
updateHistoryNavigationState()
const APP_LEFT_NAV_WIDTH = '4rem'
const APP_SIDEBAR_WIDTH = 300
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
@@ -655,6 +670,7 @@ router.beforeEach(() => {
routerToken = loading.begin()
})
router.afterEach((to, from, failure) => {
updateHistoryNavigationState()
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
@@ -1623,23 +1639,37 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</NavButton>
</div>
<div data-tauri-drag-region class="app-grid-statusbar bg-bg-raised h-[--top-bar-height] flex">
<div data-tauri-drag-region class="flex min-w-0 flex-1 overflow-hidden p-3">
<ModrinthAppLogo class="h-full w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="flex shrink-0 items-center gap-1 ml-3">
<div data-tauri-drag-region class="flex min-w-0 flex-1 items-center overflow-hidden p-2">
<TextLogo class="h-7 w-auto shrink-0 text-contrast pointer-events-none" />
<div data-tauri-drag-region class="ml-2 flex shrink-0 items-center gap-2">
<ButtonStyled type="outlined" circular>
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateBack"
aria-label="Go back"
@click="router.back()"
>
<LeftArrowIcon />
<ChevronLeftIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateBack }"
/>
</button>
</ButtonStyled>
<ButtonStyled type="outlined" circular>
<button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
class="!h-7 !min-w-7 !w-7 !border !border-surface-4 !p-0 !opacity-100"
:disabled="!canNavigateForward"
aria-label="Go forward"
@click="router.forward()"
>
<RightArrowIcon />
<ChevronRightIcon
class="!size-4 !text-primary"
:class="{ 'opacity-20': !canNavigateForward }"
/>
</button>
</ButtonStyled>
</div>
<Breadcrumbs class="pt-[2px]" />
<Breadcrumbs />
</div>
<section data-tauri-drag-region class="flex shrink-0 ml-auto items-center">
<ButtonStyled
@@ -2,7 +2,7 @@
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
class="min-w-0 overflow-hidden pl-4"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@@ -11,30 +11,48 @@
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
class="flex w-fit items-center gap-2 pr-4"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
{{ breadcrumbData.resetToNames(breadcrumbs) }}
<template v-for="breadcrumb in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="shrink-0 whitespace-nowrap text-primary"
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.slot">
<component
:is="index < breadcrumbs.length - 1 && breadcrumb.to ? RouterLink : 'span'"
v-bind="index < breadcrumbs.length - 1 && breadcrumb.to ? { to: breadcrumb.to } : {}"
:data-tauri-drag-region="index === breadcrumbs.length - 1 ? '' : undefined"
class="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-base font-medium leading-6"
:class="
index === breadcrumbs.length - 1
? 'cursor-default select-none text-contrast'
: 'text-primary hover:text-contrast'
"
:aria-current="index === breadcrumbs.length - 1 ? 'page' : undefined"
>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
<Avatar
v-if="breadcrumb.visual?.type === 'image'"
:src="breadcrumb.visual.src"
:alt="breadcrumb.visual.alt ?? breadcrumb.label"
:circle="breadcrumb.visual.circle"
:tint-by="breadcrumb.visual.tintBy ?? breadcrumb.id"
size="20px"
no-shadow
raised
class="inline-block shrink-0 align-middle"
:class="{ '!rounded-md': !breadcrumb.visual.circle }"
/>
<component
:is="breadcrumb.visual.component"
v-else-if="breadcrumb.visual?.type === 'icon'"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<span>{{ breadcrumb.label }}</span>
</component>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="shrink-0 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon v-if="breadcrumb.link" data-tauri-drag-region class="w-5 h-5 shrink-0" />
class="size-5 shrink-0 text-primary"
/>
</template>
</div>
</div>
@@ -42,34 +60,13 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { Avatar } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink } from 'vue-router'
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)
@@ -83,9 +80,13 @@ let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
const outerStyles = window.getComputedStyle(outerRef.value)
const horizontalPadding =
Number.parseFloat(outerStyles.paddingLeft) + Number.parseFloat(outerStyles.paddingRight)
const availableWidth = outerRef.value.clientWidth - horizontalPadding
const overflow = innerRef.value.scrollWidth - availableWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
overflowAmount.value = overflow
}
function onMouseEnter() {
@@ -1,6 +1,7 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { Avatar, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
@@ -14,6 +15,7 @@ const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just clos
const STORAGE_KEY = 'modrinth-quick-instance-count'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
@@ -120,6 +122,10 @@ const onDividerPointerUp = (event) => {
const getInstances = async () => {
const instances = await list().catch(handleError)
for (const instance of instances) {
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
}
allInstances.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
@@ -251,6 +251,9 @@
"app.browse.back-to-instance": {
"message": "Back to instance"
},
"app.browse.discover-project-type": {
"message": "Discover {projectType}"
},
"app.browse.discover-servers": {
"message": "Discover servers"
},
@@ -803,6 +806,9 @@
"app.project.install-context.back-to-browse": {
"message": "Back to discover"
},
"app.project.install-context.back-to-instance": {
"message": "Back to instance"
},
"app.project.version.all-versions": {
"message": "All versions"
},
+133 -56
View File
@@ -3,9 +3,11 @@ import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ClipboardCopyIcon,
CompassIcon,
ExternalIcon,
GlobeIcon,
PlusIcon,
ServerStackIcon,
SpinnerIcon,
} from '@modrinth/assets'
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
@@ -15,6 +17,7 @@ import {
commonMessages,
CreationFlowModal,
defineMessages,
formatProjectTypeSentence,
getLatestMatchingInstallVersion,
getSelectedInstallPreferences,
getTargetInstallPreferences,
@@ -31,9 +34,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'
@@ -52,13 +55,17 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_instance_worlds } from '@/helpers/worlds'
import {
type BreadcrumbDefinition,
useBreadcrumb,
useRootBreadcrumb,
} from '@/providers/breadcrumbs'
import { injectContentInstall } from '@/providers/content-install'
import { injectServerInstall } from '@/providers/server-install'
import {
createServerInstallContent,
provideServerInstallContent,
} from '@/providers/setup/server-install-content'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
@@ -71,6 +78,40 @@ 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 breadcrumbMessages = defineMessages({
discoverProjectType: {
id: 'app.browse.discover-project-type',
defaultMessage: 'Discover {projectType}',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
})
const breadcrumbLabel = computed(() => {
const browseRoute = displayedBrowseRoute.value
if (browseRoute.query.from === 'worlds' || browseRoute.params.projectType === 'server') {
return formatMessage(breadcrumbMessages.discoverServers)
}
return formatMessage(breadcrumbMessages.discoverProjectType, {
projectType: formatProjectTypeSentence(
formatMessage,
String(browseRoute.params.projectType ?? ''),
2,
),
})
})
const themeStore = useTheming()
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
@@ -111,6 +152,86 @@ const {
markServerProjectInstalled,
} = serverInstallContent
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const initialInstanceId = String(route.query.i ?? '')
const instance: Ref<Instance | null> = ref(
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
const instanceBreadcrumb = route.query.i
? useBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedBrowseRoute.value.query.i ?? '')}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: String(displayedBrowseRoute.value.query.i ?? ''),
}),
to: () => {
const instancePath = `/instance/${encodeURIComponent(
String(displayedBrowseRoute.value.query.i ?? ''),
)}`
return displayedBrowseRoute.value.query.from === 'worlds'
? `${instancePath}/worlds`
: instancePath
},
})
: 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),
visual: { type: 'icon', component: ServerStackIcon },
to: serverBreadcrumbTo,
})
: undefined
const breadcrumbParent = instanceBreadcrumb ?? 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,
visual: { type: 'icon', component: CompassIcon },
} satisfies BreadcrumbDefinition
const browseBreadcrumb = breadcrumbParent
? useBreadcrumb(breadcrumbDefinition, { parent: breadcrumbParent })
: useRootBreadcrumb(breadcrumbDefinition)
debugLog('fetching tags (categories, loaders, gameVersions)')
const [categories, loaders, availableGameVersions] = await Promise.all([
get_categories()
@@ -130,28 +251,6 @@ const tags: Ref<Tags> = computed(() => ({
categories: categories.value ?? [],
}))
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
link?: {
type: string
project_id: string
version_id: string
}
}
const instance: Ref<Instance | null> = ref(null)
const installedProjectIds: Ref<string[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
const hiddenInstanceProjectIdsInitialized = ref(false)
const isServerInstance = ref(false)
if (isFromWorlds.value && route.params.projectType !== 'server') {
router.replace({
path: '/browse/server',
@@ -385,10 +484,6 @@ const messages = defineMessages({
id: 'app.browse.add-to-an-instance',
defaultMessage: 'Add to an instance',
},
discoverServers: {
id: 'app.browse.discover-servers',
defaultMessage: 'Discover servers',
},
environmentProvidedByServer: {
id: 'search.filter.locked.server-environment.title',
defaultMessage: 'Only client-side mods can be added to the server instance',
@@ -444,31 +539,6 @@ const messages = defineMessages({
},
})
const breadcrumbs = useBreadcrumbs()
const browseTitle = computed(() =>
formatMessage(
isFromWorlds.value ? messages.discoverServers : commonMessages.discoverContentLabel,
),
)
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,
})
})
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
function resetInstanceContext() {
@@ -482,8 +552,7 @@ function resetInstanceContext() {
hiddenInstanceProjectIds.value = new Set()
hiddenInstanceProjectIdsInitialized.value = false
isServerInstance.value = false
breadcrumbs.setName('BrowseTitle', formatMessage(commonMessages.discoverContentLabel))
breadcrumbs.setContext(null)
browseBreadcrumb.reset()
}
watch(
@@ -901,6 +970,14 @@ async function search(requestParams: string) {
}
}
for (const hit of rawResults.result.hits) {
for (const identifier of [hit.project_id, hit.slug]) {
if (identifier) {
queryClient.setQueryData(['projects', 'summary', identifier], hit)
}
}
}
if (isServer) {
const hits = rawResults.result.hits ?? []
updateServerHits(hits)
+9 -5
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { HomeIcon } from '@modrinth/assets'
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 +11,17 @@ 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: '/',
visual: { type: 'icon', component: HomeIcon },
})
const instances = ref<GameInstance[]>([])
+11
View File
@@ -1,15 +1,26 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ServerStackIcon } from '@modrinth/assets'
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/',
visual: { type: 'icon', component: ServerStackIcon },
})
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
+10
View File
@@ -6,6 +6,7 @@ import {
EyeIcon,
LogInIcon,
RotateCounterClockwiseIcon,
ShirtIcon,
SpinnerIcon,
} from '@modrinth/assets'
import {
@@ -56,10 +57,19 @@ 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'
import { appMessages } from '@/utils/app-messages'
useRootBreadcrumb({
slot: 'root',
id: 'skins',
label: 'Skin selector',
to: '/skins',
visual: { type: 'icon', component: ShirtIcon },
})
type UnlistenFn = () => void
type VirtualSkinSectionListExpose = {
getAddSkinButtonElement: () => HTMLElement | null | undefined
+43 -24
View File
@@ -13,9 +13,10 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { provideUserProfile, UserProfilePageLayout } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, inject, watch } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import { onBeforeRouteUpdate, useRoute } from 'vue-router'
import {
@@ -29,12 +30,11 @@ import {
unblock_user,
} from '@/helpers/users'
import { appSettingsModalOpenProfileKey } from '@/providers/app-settings-modal'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useBreadcrumb } from '@/providers/breadcrumbs'
const route = useRoute()
const openProfileSettings = inject(appSettingsModalOpenProfileKey, () => {})
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const userProfile = provideUserProfile({
getUser: get_user_profile,
getProjects: get_user_projects,
@@ -55,17 +55,54 @@ const projectType = computed(() => {
return Array.isArray(value) ? value[0] : value
})
function getCachedUserSummary(id: string) {
return queryClient.getQueryData<Labrinth.Users.v3.User>(['users', 'summary', id])
}
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(getCachedUserSummary(userId.value)?.username ?? 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' },
)
useBreadcrumb({
slot: 'user',
id: () => `user:${breadcrumbUserId.value}`,
label: breadcrumbLabel,
to: breadcrumbTo,
visual: () => ({
type: 'image',
src: user.value?.avatar_url ?? getCachedUserSummary(breadcrumbUserId.value)?.avatar_url,
alt: breadcrumbLabel.value,
circle: true,
tintBy: breadcrumbUserId.value,
}),
})
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.
}
@@ -87,8 +124,6 @@ async function ensureUserProfileData(id: string): Promise<void> {
staleTime: 30_000,
}),
])
breadcrumbs.setName('User', breadcrumbName)
}
onBeforeRouteUpdate(async (to) => {
@@ -97,21 +132,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>
@@ -48,15 +48,22 @@
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
import { ServerStackIcon } from '@modrinth/assets'
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,47 +72,63 @@ 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 ?? '')
})
if (serverId.value) {
try {
await queryClient.ensureQueryData({
queryKey: ['servers', 'detail', serverId.value],
queryFn: () => client.archon.servers_v0.get(serverId.value)!,
staleTime: 30_000,
})
} catch {
// Let mounted layouts' useQuery surface errors; do not fail route setup.
}
function getCachedServerName(id: string): string | undefined {
return queryClient
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
?.servers.find((server) => server.server_id === id)?.name
}
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,
enabled: false,
queryFn: () => client.archon.servers_v0.get(serverId.value),
enabled: computed(() => Boolean(serverId.value)),
placeholderData: () =>
queryClient
.getQueryData<Archon.Servers.v0.ServerGetResponse>(['servers'])
?.servers.find((server) => server.server_id === serverId.value),
staleTime: 30_000,
})
const breadcrumbServerId = ref(serverId.value)
const breadcrumbLabel = ref(
getCachedServerName(serverId.value) ?? formatMessage(commonMessages.loadingLabel),
)
watch(
serverId,
(value) => {
if (!route.path.startsWith('/hosting/manage/') || route.name === 'Servers') return
breadcrumbServerId.value = value
breadcrumbLabel.value = getCachedServerName(value) ?? formatMessage(commonMessages.loadingLabel)
},
{ flush: 'sync' },
)
watch(
serverData,
(server) => {
if (server?.name) {
breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({
name: server.name,
link: `/hosting/manage/${serverId.value}/content`,
})
}
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,
visual: { type: 'icon', component: ServerStackIcon },
to: () => `/hosting/manage/${encodeURIComponent(breadcrumbServerId.value)}`,
})
provideBreadcrumbParent(serverBreadcrumb)
watch(
() => auth.user.value,
(user, previousUser) => {
+31 -18
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'
@@ -185,9 +192,10 @@ import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
import { injectServerInstall } from '@/providers/server-install'
import { handleSevereError } from '@/store/error.js'
import { useBreadcrumbs, useTheming } from '@/store/state'
import { useTheming } from '@/store/state'
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
@@ -198,10 +206,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'])
@@ -214,7 +222,23 @@ window.addEventListener('online', () => {
offline.value = false
})
const instance = ref<GameInstance>()
const initialInstanceId = String(displayedInstanceRoute.value.params.id ?? '')
const instance = ref<GameInstance | undefined>(
queryClient.getQueryData<GameInstance>(['instances', 'summary', initialInstanceId]),
)
useRootBreadcrumb({
slot: 'instance',
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
visual: () => ({
type: 'image',
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
alt: instance.value?.name,
tintBy: instance.value?.id ?? String(displayedInstanceRoute.value.params.id ?? ''),
}),
to: () => `/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
})
const preloadedContent = ref<InstanceContentData | null>(null)
const playing = ref(false)
const loading = ref(false)
@@ -339,6 +363,9 @@ async function fetchInstance() {
}
instance.value = nextInstance ?? undefined
if (nextInstance) {
queryClient.setQueryData(['instances', 'summary', nextInstance.id], nextInstance)
}
displayedInstanceRoute.value = nextRoute
sharedInstanceState.reset()
sharedInstanceState.refreshAvailability()
@@ -503,20 +530,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) => {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { PlusIcon } from '@modrinth/assets'
import { LibraryIcon, PlusIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NavTabs } from '@modrinth/ui'
import { inject, onUnmounted, ref, shallowRef } from 'vue'
import { useRoute } from 'vue-router'
@@ -7,14 +7,19 @@ 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',
visual: { type: 'icon', component: LibraryIcon },
})
const instances = shallowRef(await list().catch(handleError))
+82 -6
View File
@@ -295,10 +295,10 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
import { get_by_instance_id } from '@/helpers/process'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { getServerAddress } from '@/helpers/worlds'
import { provideBreadcrumbParent, useBreadcrumb } from '@/providers/breadcrumbs'
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 { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
@@ -307,8 +307,29 @@ 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 projectBreadcrumbTo = computed(() => {
const currentRoute = displayedProjectRoute.value
if (currentRoute.name === 'Version') {
return {
name: 'Versions',
params: { id: currentRoute.params.id },
query: currentRoute.query,
}
}
return currentRoute.fullPath
})
const queryClient = useQueryClient()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
@@ -317,6 +338,10 @@ const messages = defineMessages({
id: 'app.project.install-context.back-to-browse',
defaultMessage: 'Back to discover',
},
backToInstance: {
id: 'app.project.install-context.back-to-instance',
defaultMessage: 'Back to instance',
},
alreadyInstalled: {
id: 'app.project.install-button.already-installed',
defaultMessage: 'This project is already installed',
@@ -331,6 +356,40 @@ const { installingServerProjects, playServerProject, showAddServerToInstanceModa
injectServerInstall()
const installing = ref(false)
const data = shallowRef(null)
function getProjectBreadcrumbSummary(projectId) {
const identifier = Array.isArray(projectId) ? projectId[0] : projectId
if (typeof identifier !== 'string' || !identifier) return undefined
return queryClient.getQueryData(['projects', 'summary', identifier])
}
function getProjectBreadcrumbLabel(projectId) {
const summary = getProjectBreadcrumbSummary(projectId)
return summary?.name ?? summary?.title ?? formatMessage(commonMessages.loadingLabel)
}
const projectBreadcrumbLabel = ref(getProjectBreadcrumbLabel(route.params.id))
const projectBreadcrumb = useBreadcrumb({
slot: 'project',
id: () => `project:${String(displayedProjectRoute.value.params.id ?? '')}`,
label: projectBreadcrumbLabel,
visual: () => {
const identifier = String(displayedProjectRoute.value.params.id ?? '')
const loadedProject =
data.value?.id === identifier || data.value?.slug === identifier ? data.value : undefined
const project = loadedProject ?? getProjectBreadcrumbSummary(identifier)
return {
type: 'image',
src: project?.icon_url,
alt: projectBreadcrumbLabel.value,
tintBy: identifier,
}
},
to: projectBreadcrumbTo,
})
provideBreadcrumbParent(projectBreadcrumb)
const versions = shallowRef([])
const members = shallowRef([])
const categories = shallowRef([])
@@ -410,9 +469,18 @@ const projectGalleryHref = computed(() => buildProjectHref(`/project/${route.par
const projectBrowseBackUrl = computed(() => {
const browsePath = route.query.b
if (typeof browsePath === 'string' && browsePath.startsWith('/browse/')) return browsePath
const instanceId = route.query.i
if (typeof instanceId === 'string' && instanceId) {
return `/instance/${encodeURIComponent(instanceId)}`
}
const type = data.value?.project_type ? `${data.value.project_type}` : 'mod'
return buildBrowseHref(`/browse/${type}`)
})
const projectBackLabel = computed(() =>
typeof route.query.i === 'string' && typeof route.query.b !== 'string'
? formatMessage(messages.backToInstance)
: formatMessage(messages.backToBrowse),
)
const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value
@@ -426,7 +494,7 @@ const projectInstallContext = computed(() => {
iconSrc: null,
isMedal: serverData.is_medal,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
backLabel: projectBackLabel.value,
heading: serverInstallContent.serverBrowseHeading.value,
queuedCount: serverInstallContent.queuedServerInstallCount.value,
selectedProjects: serverInstallContent.selectedServerInstallProjects.value,
@@ -446,7 +514,7 @@ const projectInstallContext = computed(() => {
gameVersion: instance.value.game_version,
iconSrc: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
backUrl: projectBrowseBackUrl.value,
backLabel: formatMessage(messages.backToBrowse),
backLabel: projectBackLabel.value,
heading: formatMessage(commonMessages.installingContentLabel),
}
}
@@ -607,6 +675,7 @@ function reportProject() {
}
async function fetchProjectData() {
projectBreadcrumbLabel.value = getProjectBreadcrumbLabel(route.params.id)
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 +688,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),
@@ -628,6 +698,14 @@ async function fetchProjectData() {
route.query.i ? getInstanceProjects(route.query.i).catch(handleError) : Promise.resolve(),
])
for (const member of members.value ?? []) {
for (const identifier of [member.user.id, member.user.username]) {
if (identifier) {
queryClient.setQueryData(['users', 'summary', identifier], member.user)
}
}
}
versions.value = versions.value.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
if (instanceProjects.value) {
@@ -647,8 +725,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)
}
+30 -15
View File
@@ -85,6 +85,7 @@ import {
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
VersionIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -95,12 +96,12 @@ import {
useVIntl,
VersionPage,
} from '@modrinth/ui'
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { computed, 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 +120,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 +144,19 @@ 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 versionBreadcrumbLabel = computed(() => {
const versionNumber = version.value?.version_number
const versionLabel = formatMessage(commonMessages.versionLabel)
return versionNumber ? `${versionLabel} ${versionNumber}` : versionLabel
})
useBreadcrumb({
slot: 'project-version',
id: () =>
`version:${props.project.id}:${String(displayedVersionRoute.value.params.version ?? '')}`,
label: versionBreadcrumbLabel,
visual: { type: 'icon', component: VersionIcon },
to: () => displayedVersionRoute.value.fullPath,
})
const enrichment = ref<Labrinth.Projects.v2.DependencyInfo | undefined>(undefined)
const enrichmentLoading = ref(false)
@@ -202,18 +223,12 @@ async function refreshEnrichment() {
}
}
watch(
() => props.versions,
async () => {
watch([() => 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()
}
},
)
})
await refreshEnrichment()
</script>
@@ -0,0 +1,209 @@
import { createContext } from '@modrinth/ui'
import {
type Component,
computed,
type ComputedRef,
type MaybeRefOrGetter,
shallowRef,
toValue,
watch,
} from 'vue'
import type { RouteLocationRaw } from 'vue-router'
export type BreadcrumbVisual =
| {
type: 'icon'
component: Component
}
| {
type: 'image'
src?: string | null
alt?: string
circle?: boolean
tintBy?: string | null
}
export interface BreadcrumbDefinition {
slot: string
id: MaybeRefOrGetter<string>
label: MaybeRefOrGetter<string>
to?: MaybeRefOrGetter<RouteLocationRaw | undefined>
visual?: MaybeRefOrGetter<BreadcrumbVisual | undefined>
}
export interface ResolvedBreadcrumb {
slot: string
id: string
label: string
to?: RouteLocationRaw
visual?: BreadcrumbVisual
}
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),
visual: definition.visual === undefined ? undefined : toValue(definition.visual),
})),
)
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
}
-85
View File
@@ -16,17 +16,11 @@ export default new createRouter({
path: '/',
name: 'Home',
component: Pages.Index,
meta: {
breadcrumb: [{ name: 'Home' }],
},
},
{
path: '/hosting/manage/',
name: 'Servers',
component: Pages.Servers,
meta: {
breadcrumb: [{ name: 'Servers' }],
},
},
{
path: '/hosting/manage/:id',
@@ -37,41 +31,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' }],
},
},
],
},
@@ -79,34 +58,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: '',
@@ -152,42 +118,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' }],
},
},
],
},
@@ -197,59 +143,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',
@@ -257,8 +174,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 }
+1 -1
View File
@@ -16,7 +16,7 @@
"decorations": true,
"trafficLightPosition": {
"x": 15.0,
"y": 22.0
"y": 26.0
}
}
]
-2
View File
@@ -66,7 +66,6 @@ import _BugIcon from './icons/bug.svg?component'
import _CalendarIcon from './icons/calendar.svg?component'
import _CalendarArrowDownIcon from './icons/calendar-arrow-down.svg?component'
import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component'
import _ChartIcon from './icons/chart.svg?component'
import _ChartAreaIcon from './icons/chart-area.svg?component'
import _ChartColumnBigIcon from './icons/chart-column-big.svg?component'
@@ -499,7 +498,6 @@ export const BugIcon = _BugIcon
export const CalendarIcon = _CalendarIcon
export const CalendarArrowDownIcon = _CalendarArrowDownIcon
export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon
export const ChartIcon = _ChartIcon
export const ChartAreaIcon = _ChartAreaIcon
export const ChartColumnBigIcon = _ChartColumnBigIcon
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" transform="scale(-1 1)" viewBox="0 0 49.915 52.72">
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M15.71 31.484v19.07h18.63v-19.07l6.538 6.539 6.871-6.872-11.203-11.733H14.122L2.166 31.375l6.827 6.827z"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.993" d="M24.872 19.548v-6.44"/>
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="4.331" d="M24.704 13.202a5.518 5.518 0 0 1-5.518-5.518 5.518 5.518 0 0 1 5.518-5.518 5.518 5.518 0 0 1 5.518 5.518"/>
</svg>

Before

Width:  |  Height:  |  Size: 695 B

@@ -107,7 +107,11 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
</template>
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
<NavTabs
v-if="ctx.showProjectTypeTabs.value"
:links="ctx.selectableProjectTypes.value"
:replace="ctx.variant === 'app'"
/>
<StyledInput
v-model="ctx.query.value"
@@ -733,6 +733,21 @@ const projects = computed<ResolvedProject[]>(() =>
resolvedProjectType: resolveProjectType(project, tags?.loaders.value ?? []),
})),
)
watch(
() => projectsQuery.data.value,
(projects) => {
if (props.projectLinkMode !== 'app') return
for (const project of projects ?? []) {
for (const identifier of [project.id, project.slug]) {
if (identifier) {
queryClient.setQueryData(['projects', 'summary', identifier], project)
}
}
}
},
{ immediate: true },
)
const organizations = computed(() => organizationsQuery.data.value ?? [])
const collections = computed(() => collectionsQuery.data.value ?? [])
const isBlocked = computed(() =>