diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 0c3ee503cc..e2f78fe965 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -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)
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
+
- {{ breadcrumbData.resetToNames(breadcrumbs) }}
-
-
+
- {{ resolveLabel(breadcrumb.name) }}
-
-
+
+ {{ breadcrumb.label }}
+
+
- {{ resolveLabel(breadcrumb.name) }}
-
-
+ class="size-5 shrink-0 text-primary"
+ />
@@ -42,34 +60,13 @@
diff --git a/apps/app-frontend/src/pages/hosting/manage/Index.vue b/apps/app-frontend/src/pages/hosting/manage/Index.vue
index e283f38390..a90ee87076 100644
--- a/apps/app-frontend/src/pages/hosting/manage/Index.vue
+++ b/apps/app-frontend/src/pages/hosting/manage/Index.vue
@@ -48,15 +48,22 @@
diff --git a/apps/app-frontend/src/providers/breadcrumbs.ts b/apps/app-frontend/src/providers/breadcrumbs.ts
new file mode 100644
index 0000000000..58b6054b76
--- /dev/null
+++ b/apps/app-frontend/src/providers/breadcrumbs.ts
@@ -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
+ label: MaybeRefOrGetter
+ to?: MaybeRefOrGetter
+ visual?: MaybeRefOrGetter
+}
+
+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
+ 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(
+ 'root',
+ 'breadcrumbManager',
+)
+const [injectBreadcrumbParent, provideBreadcrumbParent] = createContext(
+ 'BreadcrumbParent',
+ 'breadcrumbParent',
+)
+
+export { injectBreadcrumbManager, provideBreadcrumbManager, provideBreadcrumbParent }
+
+export function createBreadcrumbManager(): BreadcrumbManager {
+ const stack = shallowRef([])
+
+ 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(() =>
+ 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
+}
diff --git a/apps/app-frontend/src/routes.js b/apps/app-frontend/src/routes.js
index a5aadd7456..70f6e5372b 100644
--- a/apps/app-frontend/src/routes.js
+++ b/apps/app-frontend/src/routes.js
@@ -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' }],
},
},
],
diff --git a/apps/app-frontend/src/store/breadcrumbs.js b/apps/app-frontend/src/store/breadcrumbs.js
deleted file mode 100644
index ff13440c0e..0000000000
--- a/apps/app-frontend/src/store/breadcrumbs.js
+++ /dev/null
@@ -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
- },
- },
-})
diff --git a/apps/app-frontend/src/store/state.js b/apps/app-frontend/src/store/state.js
index d25ee51b1a..b5fa69692c 100644
--- a/apps/app-frontend/src/store/state.js
+++ b/apps/app-frontend/src/store/state.js
@@ -1,4 +1,3 @@
-import { useBreadcrumbs } from './breadcrumbs'
import { useTheming } from './theme.ts'
-export { useBreadcrumbs, useTheming }
+export { useTheming }
diff --git a/apps/app/tauri.macos.conf.json b/apps/app/tauri.macos.conf.json
index 363f00e853..7ab0afc80c 100644
--- a/apps/app/tauri.macos.conf.json
+++ b/apps/app/tauri.macos.conf.json
@@ -16,7 +16,7 @@
"decorations": true,
"trafficLightPosition": {
"x": 15.0,
- "y": 22.0
+ "y": 26.0
}
}
]
diff --git a/packages/assets/generated-icons.ts b/packages/assets/generated-icons.ts
index f0e78c8f82..0b524d52ad 100644
--- a/packages/assets/generated-icons.ts
+++ b/packages/assets/generated-icons.ts
@@ -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
diff --git a/packages/assets/icons/change-skin.svg b/packages/assets/icons/change-skin.svg
deleted file mode 100644
index 7626051500..0000000000
--- a/packages/assets/icons/change-skin.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/packages/ui/src/layouts/shared/browse-tab/layout.vue b/packages/ui/src/layouts/shared/browse-tab/layout.vue
index f4215569ce..c407a9be94 100644
--- a/packages/ui/src/layouts/shared/browse-tab/layout.vue
+++ b/packages/ui/src/layouts/shared/browse-tab/layout.vue
@@ -107,7 +107,11 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
-
+
(() =>
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(() =>