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) }} -
@@ -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(() =>