Compare commits
3
Commits
c405507603
...
1274dece67
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1274dece67 | ||
|
|
c03f9826af | ||
|
|
9b70fb774f |
No files matched your search
Generated
+30
@@ -11,8 +11,10 @@
|
|||||||
"@fontsource/roboto": "^5.3.0",
|
"@fontsource/roboto": "^5.3.0",
|
||||||
"@vueuse/core": "^14.4.0",
|
"@vueuse/core": "^14.4.0",
|
||||||
"better-auth": "^1.7.1",
|
"better-auth": "^1.7.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
"vuetify": "^4.2.2"
|
"vuetify": "^4.2.2"
|
||||||
},
|
},
|
||||||
@@ -895,6 +897,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@mdi/font": {
|
||||||
"version": "7.4.47",
|
"version": "7.4.47",
|
||||||
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
|
"resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz",
|
||||||
@@ -3045,6 +3053,18 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"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": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"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": {
|
"node_modules/vue-eslint-parser": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz",
|
||||||
|
|||||||
@@ -18,8 +18,10 @@
|
|||||||
"@fontsource/roboto": "^5.3.0",
|
"@fontsource/roboto": "^5.3.0",
|
||||||
"@vueuse/core": "^14.4.0",
|
"@vueuse/core": "^14.4.0",
|
||||||
"better-auth": "^1.7.1",
|
"better-auth": "^1.7.1",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
"vuetify": "^4.2.2"
|
"vuetify": "^4.2.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useBreadcrumbs } from '@/composables/useBreadcrumbs.ts'
|
||||||
|
|
||||||
|
const { breadcrumbs } = useBreadcrumbs()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-breadcrumbs v-if="breadcrumbs.length > 0" :items="breadcrumbs" density="compact" class="pa-0 ma-0">
|
||||||
|
<template #divider>
|
||||||
|
<v-icon icon="mdi-chevron-right" size="small" />
|
||||||
|
</template>
|
||||||
|
</v-breadcrumbs>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const backTo = computed(() => {
|
||||||
|
return [...route.matched].reverse().find((record) => record.meta.backTo)?.meta.backTo
|
||||||
|
})
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (backTo.value) router.push({ name: backTo.value })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-btn v-if="backTo" icon="mdi-arrow-left" variant="text" density="compact" aria-label="Back" @click="goBack" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
@@ -31,11 +31,11 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="projects.length" class="d-flex align-center ga-2">
|
<div v-if="projects.length" class="d-flex align-center">
|
||||||
<div>Project:</div>
|
<div>Project:</div>
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn density="comfortable" v-bind="props">
|
<v-btn density="comfortable" v-bind="props" slim>
|
||||||
<label-text :label="current?.key" :text="currentProjectName" />
|
<label-text :label="current?.key" :text="currentProjectName" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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<Breadcrumb[]>(() => {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { watch } from 'vue'
|
||||||
|
import { useTheme } from 'vuetify'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { THEME_MODES, THEME_STORAGE_KEY, type ThemeMode } from '@/lib/theme.ts'
|
||||||
|
|
||||||
|
/** Persisted light/dark/system theme mode, cycled in that order on each call to `cycleThemeMode`. */
|
||||||
|
export function useThemeMode() {
|
||||||
|
const theme = useTheme()
|
||||||
|
const mode = useStorage<ThemeMode>(THEME_STORAGE_KEY, 'system')
|
||||||
|
|
||||||
|
watch(mode, (value) => theme.change(value), { immediate: true })
|
||||||
|
|
||||||
|
function cycleThemeMode(e?: MouseEvent) {
|
||||||
|
if (e) theme.setTransitionOrigin(e.target as HTMLElement)
|
||||||
|
const nextIndex = (THEME_MODES.indexOf(mode.value) + 1) % THEME_MODES.length
|
||||||
|
mode.value = THEME_MODES[nextIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, cycleThemeMode }
|
||||||
|
}
|
||||||
@@ -2,15 +2,17 @@
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { authClient } from '@/lib/auth-client.ts'
|
import { authClient } from '@/lib/auth-client.ts'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useTheme } from 'vuetify'
|
|
||||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
import ProjectMenu from '@/components/ProjectMenu.vue'
|
||||||
|
import AppBreadcrumbs from '@/components/AppBreadcrumbs.vue'
|
||||||
|
import BackButton from '@/components/BackButton.vue'
|
||||||
|
import { useThemeMode } from '@/composables/useThemeMode.ts'
|
||||||
|
import { THEME_ICONS } from '@/lib/theme.ts'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const session = authClient.useSession()
|
const session = authClient.useSession()
|
||||||
const theme = useTheme()
|
const { mode: themeMode, cycleThemeMode } = useThemeMode()
|
||||||
|
|
||||||
const title = 'APP'
|
const title = 'Ulitka'
|
||||||
const version = '0.0.1'
|
|
||||||
|
|
||||||
const isAuth = computed(() => !!session.value.data)
|
const isAuth = computed(() => !!session.value.data)
|
||||||
|
|
||||||
@@ -54,12 +56,6 @@ async function goToSignOut() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme(e: MouseEvent) {
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
theme.setTransitionOrigin(target)
|
|
||||||
theme.toggle()
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToSettings = () => {
|
const goToSettings = () => {
|
||||||
router.push({ name: 'settings' })
|
router.push({ name: 'settings' })
|
||||||
}
|
}
|
||||||
@@ -88,18 +84,18 @@ const goToSettings = () => {
|
|||||||
</template>
|
</template>
|
||||||
<v-divider class="mx-1" vertical />
|
<v-divider class="mx-1" vertical />
|
||||||
<v-app-bar-title>
|
<v-app-bar-title>
|
||||||
<div class="d-flex ga-2 align-center">
|
<router-link class="text-decoration-none text-link font-weight-bold d-flex align-baseline ga-2" to="/">
|
||||||
<router-link class="text-decoration-none text-link font-weight-bold" to="/">
|
<v-icon icon="mdi-snail" />
|
||||||
{{ title }}
|
<div>{{ title }}</div>
|
||||||
</router-link>
|
</router-link>
|
||||||
<span>v{{ version }}</span>
|
|
||||||
|
|
||||||
<project-menu v-if="session.data" />
|
|
||||||
</div>
|
|
||||||
</v-app-bar-title>
|
</v-app-bar-title>
|
||||||
|
|
||||||
|
<project-menu v-if="session.data" />
|
||||||
|
|
||||||
|
<v-divider class="mx-2" vertical />
|
||||||
|
|
||||||
<div class="d-flex ga-4 align-center" v-if="session.data">
|
<div class="d-flex ga-4 align-center" v-if="session.data">
|
||||||
<div class="mr-2">
|
<div class="">
|
||||||
Hello,
|
Hello,
|
||||||
<router-link class="text-decoration-none text-link text-capitalize" to="/profile">
|
<router-link class="text-decoration-none text-link text-capitalize" to="/profile">
|
||||||
{{ session?.data.user.name }}
|
{{ session?.data.user.name }}
|
||||||
@@ -111,11 +107,16 @@ const goToSettings = () => {
|
|||||||
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-divider class="mx-1" vertical />
|
<v-divider class="mx-2" vertical />
|
||||||
<v-btn icon="mdi-brightness-6" density="comfortable" @click="toggleTheme" />
|
<v-btn
|
||||||
|
:icon="THEME_ICONS[themeMode]"
|
||||||
|
:title="`Theme: ${themeMode}`"
|
||||||
|
density="comfortable"
|
||||||
|
@click="cycleThemeMode"
|
||||||
|
/>
|
||||||
|
<v-divider class="mx-2" vertical />
|
||||||
|
|
||||||
<div v-if="session.data">
|
<div v-if="session.data">
|
||||||
<v-divider class="mx-1" vertical />
|
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn density="comfortable" class="mr-1" icon v-bind="props">
|
<v-btn density="comfortable" class="mr-1" icon v-bind="props">
|
||||||
@@ -140,11 +141,19 @@ const goToSettings = () => {
|
|||||||
</div>
|
</div>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<v-main>
|
<v-main>
|
||||||
<v-container :fluid="false">
|
<v-container :fluid="true">
|
||||||
|
<div class="nav-row d-flex align-center ga-2 mb-2">
|
||||||
|
<back-button />
|
||||||
|
<app-breadcrumbs />
|
||||||
|
</div>
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
</v-container>
|
</v-container>
|
||||||
</v-main>
|
</v-main>
|
||||||
</v-layout>
|
</v-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
<style scoped lang="scss">
|
||||||
|
.nav-row:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
export type ThemeMode = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
export const THEME_MODES: ThemeMode[] = ['light', 'dark', 'system']
|
||||||
|
export const THEME_STORAGE_KEY = 'theme-mode'
|
||||||
|
|
||||||
|
export const THEME_ICONS: Record<ThemeMode, string> = {
|
||||||
|
light: 'mdi-white-balance-sunny',
|
||||||
|
dark: 'mdi-weather-night',
|
||||||
|
system: 'mdi-theme-light-dark',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isThemeMode(value: unknown): value is ThemeMode {
|
||||||
|
return THEME_MODES.includes(value as ThemeMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read the persisted theme mode synchronously, before Vuetify is created, to avoid a flash of the wrong theme. */
|
||||||
|
export function readStoredThemeMode(): ThemeMode {
|
||||||
|
if (typeof localStorage === 'undefined') return 'system'
|
||||||
|
const stored = localStorage.getItem(THEME_STORAGE_KEY)
|
||||||
|
return isThemeMode(stored) ? stored : 'system'
|
||||||
|
}
|
||||||
+2
-1
@@ -14,6 +14,7 @@ import * as components from 'vuetify/components'
|
|||||||
import { VAvatarGroup } from 'vuetify/labs/VAvatarGroup'
|
import { VAvatarGroup } from 'vuetify/labs/VAvatarGroup'
|
||||||
import * as directives from 'vuetify/directives'
|
import * as directives from 'vuetify/directives'
|
||||||
import { registerLayouts } from '@/layouts/register.ts'
|
import { registerLayouts } from '@/layouts/register.ts'
|
||||||
|
import { readStoredThemeMode } from '@/lib/theme.ts'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ const vuetify = createVuetify({
|
|||||||
components: { ...components, VAvatarGroup },
|
components: { ...components, VAvatarGroup },
|
||||||
directives,
|
directives,
|
||||||
theme: {
|
theme: {
|
||||||
defaultTheme: 'dark', // 'system' | 'light' | 'dark'
|
defaultTheme: readStoredThemeMode(),
|
||||||
themes: {
|
themes: {
|
||||||
dark: {
|
dark: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
+5
-1
@@ -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 ProfileView from '@/views/users/ProfileView.vue'
|
||||||
import { issuesRoutes } from '@/router/issues.ts'
|
import { issuesRoutes } from '@/router/issues.ts'
|
||||||
import HomeView from '@/views/HomeView.vue'
|
import HomeView from '@/views/HomeView.vue'
|
||||||
@@ -12,6 +12,10 @@ import { settingsRoutes } from '@/router/settings.ts'
|
|||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
layout?: 'Blank' | 'Default'
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,19 @@ export const issuesRoutes: RouteRecordRaw = {
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
|
name: 'issues',
|
||||||
component: IssuesView,
|
component: IssuesView,
|
||||||
|
meta: { breadcrumb: 'Issues' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ':ckey',
|
path: ':ckey',
|
||||||
|
name: 'issue-detail',
|
||||||
component: IssueView,
|
component: IssueView,
|
||||||
props: true,
|
props: true,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: (route) => route.params.ckey as string,
|
||||||
|
backTo: 'issues',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -7,17 +7,19 @@ export const settingsRoutes: RouteRecordRaw = {
|
|||||||
path: '/settings',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
component: SettingsView,
|
component: SettingsView,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true, breadcrumb: 'Settings' },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'general-settings',
|
name: 'general-settings',
|
||||||
component: GeneralSettingsView,
|
component: GeneralSettingsView,
|
||||||
|
meta: { breadcrumb: 'General' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'projects',
|
path: 'projects',
|
||||||
name: 'settings-projects',
|
name: 'settings-projects',
|
||||||
component: ProjectsListView,
|
component: ProjectsListView,
|
||||||
|
meta: { breadcrumb: 'Projects' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user