diff --git a/package-lock.json b/package-lock.json index e61fde3..3cd89a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,10 @@ "@fontsource/roboto": "^5.3.0", "@vueuse/core": "^14.4.0", "better-auth": "^1.7.1", + "chart.js": "^4.5.1", "pinia": "^4.0.3", "vue": "^3.5.41", + "vue-chartjs": "^5.3.4", "vue-router": "^5.2.0", "vuetify": "^4.2.2" }, @@ -895,6 +897,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@mdi/font": { "version": "7.4.47", "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", @@ -3045,6 +3053,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -6262,6 +6282,16 @@ } } }, + "node_modules/vue-chartjs": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz", + "integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, "node_modules/vue-eslint-parser": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", diff --git a/package.json b/package.json index 1acedb5..7f34f2e 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,10 @@ "@fontsource/roboto": "^5.3.0", "@vueuse/core": "^14.4.0", "better-auth": "^1.7.1", + "chart.js": "^4.5.1", "pinia": "^4.0.3", "vue": "^3.5.41", + "vue-chartjs": "^5.3.4", "vue-router": "^5.2.0", "vuetify": "^4.2.2" }, diff --git a/src/components/AppBreadcrumbs.vue b/src/components/AppBreadcrumbs.vue new file mode 100644 index 0000000..308edaf --- /dev/null +++ b/src/components/AppBreadcrumbs.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/src/components/BackButton.vue b/src/components/BackButton.vue new file mode 100644 index 0000000..0949067 --- /dev/null +++ b/src/components/BackButton.vue @@ -0,0 +1,21 @@ + + + + + diff --git a/src/composables/useBreadcrumbs.ts b/src/composables/useBreadcrumbs.ts new file mode 100644 index 0000000..90a5304 --- /dev/null +++ b/src/composables/useBreadcrumbs.ts @@ -0,0 +1,35 @@ +import { computed } from 'vue' +import { useRoute, useRouter } from 'vue-router' + +export interface Breadcrumb { + title: string + to?: string + disabled?: boolean +} + +/** + * Builds a breadcrumb trail from `meta.breadcrumb` on the matched route chain, + * so pages don't need to know their own position in the hierarchy. + */ +export function useBreadcrumbs() { + const route = useRoute() + const router = useRouter() + + const breadcrumbs = computed(() => { + const matched = route.matched.filter((record) => record.meta.breadcrumb) + + return matched.map((record, index) => { + const { breadcrumb } = record.meta + const title = typeof breadcrumb === 'function' ? breadcrumb(route) : (breadcrumb as string) + const isLast = index === matched.length - 1 + const to = + !isLast && record.name + ? router.resolve({ name: record.name, params: route.params }).path + : undefined + + return { title, to, disabled: isLast } + }) + }) + + return { breadcrumbs } +} diff --git a/src/layouts/DefaultLayout.vue b/src/layouts/DefaultLayout.vue index ebe0c66..a588d70 100644 --- a/src/layouts/DefaultLayout.vue +++ b/src/layouts/DefaultLayout.vue @@ -4,6 +4,8 @@ import { authClient } from '@/lib/auth-client.ts' import { computed, ref } from 'vue' import { useTheme } from 'vuetify' import ProjectMenu from '@/components/ProjectMenu.vue' +import AppBreadcrumbs from '@/components/AppBreadcrumbs.vue' +import BackButton from '@/components/BackButton.vue' const router = useRouter() const session = authClient.useSession() @@ -140,11 +142,19 @@ const goToSettings = () => { - + + - + diff --git a/src/router/index.ts b/src/router/index.ts index 1e0e640..a64b0e8 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,4 +1,4 @@ -import { createRouter, createWebHistory } from 'vue-router' +import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded } from 'vue-router' import ProfileView from '@/views/users/ProfileView.vue' import { issuesRoutes } from '@/router/issues.ts' import HomeView from '@/views/HomeView.vue' @@ -12,6 +12,10 @@ import { settingsRoutes } from '@/router/settings.ts' declare module 'vue-router' { interface RouteMeta { layout?: 'Blank' | 'Default' + /** Label shown in the breadcrumbs trail for this route segment. */ + breadcrumb?: string | ((route: RouteLocationNormalizedLoaded) => string) + /** Named route the back button should navigate to, when deep-linking means history may be empty. */ + backTo?: string } } diff --git a/src/router/issues.ts b/src/router/issues.ts index 2d5c48b..c09dfcb 100644 --- a/src/router/issues.ts +++ b/src/router/issues.ts @@ -8,12 +8,19 @@ export const issuesRoutes: RouteRecordRaw = { children: [ { path: '', + name: 'issues', component: IssuesView, + meta: { breadcrumb: 'Issues' }, }, { path: ':ckey', + name: 'issue-detail', component: IssueView, props: true, + meta: { + breadcrumb: (route) => route.params.ckey as string, + backTo: 'issues', + }, }, ], } diff --git a/src/router/settings.ts b/src/router/settings.ts index bdca6a1..26dedf4 100644 --- a/src/router/settings.ts +++ b/src/router/settings.ts @@ -7,17 +7,19 @@ export const settingsRoutes: RouteRecordRaw = { path: '/settings', name: 'settings', component: SettingsView, - meta: { requiresAuth: true }, + meta: { requiresAuth: true, breadcrumb: 'Settings' }, children: [ { path: '', name: 'general-settings', component: GeneralSettingsView, + meta: { breadcrumb: 'General' }, }, { path: 'projects', name: 'settings-projects', component: ProjectsListView, + meta: { breadcrumb: 'Projects' }, }, ], }