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
+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)
}
+34 -19
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 () => {
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()
}
},
)
watch([() => props.versions, () => route.params.version], async () => {
if (route.params.version) {
version.value = props.versions.find((v) => v.id === route.params.version)
await refreshEnrichment()
}
})
await refreshEnrichment()
</script>