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' },
},
],
}