This commit is contained in:
1 parent
a0c557f9b2
commit
2d4b8ea9cf
17 files changed
+1542
-12
No files matched your search
Generated
+1044
-4
File diff suppressed because it is too large.
Load diff
@@ -19,6 +19,7 @@
|
||||
"@vueuse/core": "^14.4.0",
|
||||
"better-auth": "^1.7.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"firebase": "^13.0.0",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.41",
|
||||
"vue-chartjs": "^5.3.4",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
importScripts('https://www.gstatic.com/firebasejs/13.0.0/firebase-app-compat.js')
|
||||
importScripts('https://www.gstatic.com/firebasejs/13.0.0/firebase-messaging-compat.js')
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: 'AIzaSyAKY4nPht7-YsIQfhcECxVwZFx7iAnCQSM',
|
||||
authDomain: 'ulitka-c811b.firebaseapp.com',
|
||||
projectId: 'ulitka-c811b',
|
||||
storageBucket: 'ulitka-c811b.firebasestorage.app',
|
||||
messagingSenderId: '82243223520',
|
||||
appId: '1:82243223520:web:2c0e68358f8d5f2d2a7585',
|
||||
measurementId: 'G-CMDB21T1WG',
|
||||
})
|
||||
|
||||
const messaging = firebase.messaging()
|
||||
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
// Если в payload есть блок "notification", SDK покажет уведомление сам —
|
||||
// без этой проверки получится дубль "Новое сообщение" с пустым телом.
|
||||
if (payload.notification) return
|
||||
// Для data-only сообщений показываем вручную:
|
||||
const { title, body } = payload.data || {}
|
||||
self.registration.showNotification(title || 'Новое сообщение', { body })
|
||||
})
|
||||
+16
@@ -6,11 +6,17 @@ import { useRoute } from 'vue-router'
|
||||
import { useStatusStore } from '@/stores/status.ts'
|
||||
import { useIssueStore } from '@/stores/issue.ts'
|
||||
import { useProjectStore } from '@/stores/project.ts'
|
||||
import { authClient } from '@/lib/auth-client.ts'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
import SnackbarQueue from '@/components/SnackbarQueue.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const issueStore = useIssueStore()
|
||||
const statusStore = useStatusStore()
|
||||
const projectStore = useProjectStore()
|
||||
const session = authClient.useSession()
|
||||
const push = usePushNotifications()
|
||||
|
||||
const { currentProjectId } = storeToRefs(projectStore)
|
||||
|
||||
interface IssueSseEvent {
|
||||
@@ -39,6 +45,15 @@ watch(event, () => {
|
||||
}
|
||||
})
|
||||
|
||||
// FID сохраняется на бэкенде под текущим пользователем, поэтому стартуем только с сессией
|
||||
watch(
|
||||
() => session.value.data?.user.id,
|
||||
(userId) => {
|
||||
if (userId) push.autoStart().catch((e) => console.error('Failed to start push', e))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const layout = computed(() => {
|
||||
const layout = route?.meta?.layout
|
||||
return layout ? `${layout}Layout` : 'DefaultLayout'
|
||||
@@ -58,6 +73,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</router-view>
|
||||
</component>
|
||||
<snackbar-queue />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { apiClient } from '@/lib/api-client.ts'
|
||||
|
||||
export type DevicePlatform = 'ios' | 'android' | 'web'
|
||||
export type NotificationChannel = 'EMAIL' | 'PUSH'
|
||||
|
||||
export interface NotificationPreference {
|
||||
channel: NotificationChannel
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
// CLIENT REQUEST
|
||||
|
||||
export async function deviceTokenRegister(token: string, platform: DevicePlatform = 'web') {
|
||||
return apiClient.post<void>('/api/notifications/device-tokens', { token, platform })
|
||||
}
|
||||
|
||||
export async function deviceTokenRemove(token: string) {
|
||||
return apiClient.delete('/api/notifications/device-tokens', { token })
|
||||
}
|
||||
|
||||
export async function preferencesList() {
|
||||
return apiClient.get<NotificationPreference[]>('/api/notifications/preferences')
|
||||
}
|
||||
|
||||
export async function preferenceSet(channel: NotificationChannel, enabled: boolean) {
|
||||
return apiClient.put<void>(`/api/notifications/preferences/${channel}`, { enabled })
|
||||
}
|
||||
|
||||
export async function testPushSend(title?: string, body?: string) {
|
||||
return apiClient.post<void>('/api/notifications/test-push', { title, body })
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
preferenceSet,
|
||||
preferencesList,
|
||||
testPushSend,
|
||||
type NotificationChannel,
|
||||
type NotificationPreference,
|
||||
} from '@/api/notification.api.ts'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
|
||||
const CHANNEL_LABELS: Record<NotificationChannel, string> = {
|
||||
EMAIL: 'Email',
|
||||
PUSH: 'Push',
|
||||
}
|
||||
|
||||
const snackbarStore = useSnackbarStore()
|
||||
const push = usePushNotifications()
|
||||
|
||||
const preferences = ref<NotificationPreference[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref<NotificationChannel | null>(null)
|
||||
|
||||
const permission = ref<NotificationPermission | 'unsupported'>(
|
||||
'Notification' in window ? Notification.permission : 'unsupported',
|
||||
)
|
||||
const deviceSaving = ref(false)
|
||||
const deviceEnabled = computed(() => !!push.installationId.value)
|
||||
|
||||
function showError(text: string, e: unknown) {
|
||||
snackbarStore.add({ title: text, text: e instanceof Error ? e.message : '', color: 'error' })
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
loading.value = true
|
||||
try {
|
||||
preferences.value = await preferencesList()
|
||||
} catch (e) {
|
||||
showError('Failed to load notification settings', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePreference(preference: NotificationPreference, enabled: boolean | null) {
|
||||
const previous = preference.enabled
|
||||
preference.enabled = !!enabled
|
||||
saving.value = preference.channel
|
||||
try {
|
||||
await preferenceSet(preference.channel, preference.enabled)
|
||||
} catch (e) {
|
||||
preference.enabled = previous
|
||||
showError('Failed to save notification settings', e)
|
||||
} finally {
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDevice(enabled: boolean | null) {
|
||||
deviceSaving.value = true
|
||||
try {
|
||||
if (enabled) permission.value = await push.enable()
|
||||
else await push.disable(true)
|
||||
} catch (e) {
|
||||
showError('Failed to change push in this browser', e)
|
||||
} finally {
|
||||
deviceSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testSending = ref(false)
|
||||
|
||||
async function sendTestPush() {
|
||||
testSending.value = true
|
||||
try {
|
||||
await testPushSend()
|
||||
snackbarStore.add({
|
||||
title: 'Test push sent',
|
||||
text: 'It should arrive on all your devices shortly',
|
||||
color: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
showError('Failed to send test push', e)
|
||||
} finally {
|
||||
testSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadPreferences()
|
||||
|
||||
if (!navigator.permissions) return
|
||||
const perm = await navigator.permissions.query({ name: 'notifications' })
|
||||
perm.onchange = () => {
|
||||
permission.value = Notification.permission
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="text-uppercase">Notifications</div>
|
||||
|
||||
<v-skeleton-loader v-if="loading && !preferences.length" type="list-item, list-item" />
|
||||
<div v-else class="d-flex flex-column">
|
||||
<v-switch
|
||||
v-for="preference in preferences"
|
||||
:key="preference.channel"
|
||||
:model-value="preference.enabled"
|
||||
:label="CHANNEL_LABELS[preference.channel] ?? preference.channel"
|
||||
:loading="saving === preference.channel"
|
||||
:disabled="saving === preference.channel"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="togglePreference(preference, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-uppercase mt-4">Push in this browser</div>
|
||||
<div v-if="permission === 'unsupported'" class="text-medium-emphasis">
|
||||
This browser does not support push notifications
|
||||
</div>
|
||||
<div v-else-if="permission === 'denied'" class="text-medium-emphasis">
|
||||
Notifications are blocked — allow them in the browser site settings
|
||||
</div>
|
||||
<v-switch
|
||||
v-else
|
||||
:model-value="deviceEnabled"
|
||||
:label="deviceEnabled ? 'Enabled' : 'Disabled'"
|
||||
:loading="deviceSaving"
|
||||
:disabled="deviceSaving"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
@update:model-value="toggleDevice"
|
||||
/>
|
||||
|
||||
<v-btn
|
||||
class="mt-2"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
prepend-icon="mdi-bell-ring-outline"
|
||||
:loading="testSending"
|
||||
@click="sendTestPush"
|
||||
>
|
||||
Send test push
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -31,7 +31,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="projects.length" class="d-flex align-center ga-2">
|
||||
<div v-if="projects.length" class="d-flex align-center ga-2 mr-2">
|
||||
<div>Current project:</div>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
|
||||
const status = ref<NotificationPermission | 'unsupported'>(
|
||||
'Notification' in window ? Notification.permission : 'unsupported',
|
||||
)
|
||||
const loading = ref(false)
|
||||
const push = usePushNotifications()
|
||||
|
||||
async function onClick() {
|
||||
loading.value = true
|
||||
try {
|
||||
status.value = await push.enable()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!navigator.permissions) return
|
||||
const perm = await navigator.permissions.query({ name: 'notifications' })
|
||||
perm.onchange = () => {
|
||||
status.value = Notification.permission
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mr-2">
|
||||
<span v-if="status === 'granted' && push.installationId.value">Уведомления включены</span>
|
||||
<button v-else-if="status === 'default' || status === 'granted'" :disabled="loading" @click="onClick">
|
||||
Включить уведомления
|
||||
</button>
|
||||
<span v-else-if="status === 'denied'">Уведомления заблокированы.</span>
|
||||
<span v-else>Браузер не поддерживает уведомления</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
|
||||
const snackbarStore = useSnackbarStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-snackbar-queue
|
||||
ref="queue"
|
||||
v-model="snackbarStore.snackbarQueue"
|
||||
location="bottom end"
|
||||
timeout="5000"
|
||||
total-visible="5"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.bouncy-slide-x-transition-enter-active,
|
||||
.bouncy-slide-x-transition-leave-active,
|
||||
.bouncy-slide-x-transition-move,
|
||||
.bouncy-slide-x-reverse-transition-enter-active,
|
||||
.bouncy-slide-x-reverse-transition-leave-active,
|
||||
.bouncy-slide-x-reverse-transition-move {
|
||||
transition: transform, opacity;
|
||||
transition-duration: 0.5s;
|
||||
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
.bouncy-slide-x-transition-enter-from,
|
||||
.bouncy-slide-x-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-30%);
|
||||
}
|
||||
.bouncy-slide-x-reverse-transition-enter-from,
|
||||
.bouncy-slide-x-reverse-transition-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(30%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
import type { MessagePayload } from 'firebase/messaging'
|
||||
import { autoRequestPush, disablePush, enablePush, type PushOptions } from '@/lib/firebase.ts'
|
||||
import { deviceTokenRegister, deviceTokenRemove } from '@/api/notification.api.ts'
|
||||
import { useSnackbarStore } from '@/stores/snackbar.ts'
|
||||
|
||||
// FID этого браузера; null — пуши в этом браузере не подключены
|
||||
const installationId = ref<string | null>(null)
|
||||
|
||||
// Разрешение браузера после выключения в профиле остаётся granted, поэтому
|
||||
// помним выбор пользователя локально, иначе autoStart снова подпишет браузер
|
||||
const DISABLED_KEY = 'push-disabled'
|
||||
|
||||
function isDisabledLocally(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(DISABLED_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setDisabledLocally(disabled: boolean) {
|
||||
try {
|
||||
if (disabled) localStorage.setItem(DISABLED_KEY, '1')
|
||||
else localStorage.removeItem(DISABLED_KEY)
|
||||
} catch {
|
||||
// хранилище недоступно — просто не запоминаем
|
||||
}
|
||||
}
|
||||
|
||||
// Одни и те же опции и для автостарта, и для кнопки: messaging стартует один раз,
|
||||
// и тот, кто стартовал первым, определяет колбэки
|
||||
export function usePushNotifications() {
|
||||
const snackbarStore = useSnackbarStore()
|
||||
|
||||
const options: PushOptions = {
|
||||
onInstallationId: (fid) => {
|
||||
installationId.value = fid
|
||||
deviceTokenRegister(fid).catch((e) => console.error('Failed to save push token', e))
|
||||
},
|
||||
onInstallationIdRemoved: (fid) => {
|
||||
installationId.value = null
|
||||
deviceTokenRemove(fid).catch((e) => console.error('Failed to remove push token', e))
|
||||
},
|
||||
onForeground: (payload: MessagePayload) => {
|
||||
const title = payload.notification?.title ?? payload.data?.title
|
||||
const body = payload.notification?.body ?? payload.data?.body
|
||||
if (!title && !body) return
|
||||
|
||||
snackbarStore.add({ title: title ?? '', text: body ?? '', color: payload.data?.color ?? 'default' })
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
installationId: readonly(installationId),
|
||||
enable: () => {
|
||||
setDisabledLocally(false)
|
||||
return enablePush(options)
|
||||
},
|
||||
autoStart: () => (isDisabledLocally() ? Promise.resolve() : autoRequestPush(options)),
|
||||
// Вызывать до signOut: удалить токен на бэкенде можно только пока есть сессия.
|
||||
// remember = true — пользователь сам выключил пуши в этом браузере, не включать их при следующем входе
|
||||
async disable(remember = false) {
|
||||
if (remember) setDisabledLocally(true)
|
||||
const fid = installationId.value
|
||||
if (fid) await deviceTokenRemove(fid).catch((e) => console.error('Failed to remove push token', e))
|
||||
await disablePush().catch((e) => console.error('Failed to unregister push', e))
|
||||
installationId.value = null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,15 @@ import BackButton from '@/components/BackButton.vue'
|
||||
import { useThemeMode } from '@/composables/useThemeMode.ts'
|
||||
import { THEME_ICONS } from '@/lib/theme.ts'
|
||||
import NavigationMenu from '@/components/NavigationMenu.vue'
|
||||
import PushEnable from '@/components/PushEnable.vue'
|
||||
import { usePushNotifications } from '@/composables/usePushNotifications.ts'
|
||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const session = authClient.useSession()
|
||||
const { mode: themeMode, cycleThemeMode } = useThemeMode()
|
||||
const push = usePushNotifications()
|
||||
|
||||
const title = 'Ulitka'
|
||||
|
||||
@@ -30,6 +34,8 @@ function goToProfile() {
|
||||
}
|
||||
|
||||
async function goToSignOut() {
|
||||
// Пока сессия жива — иначе бэкенд не даст удалить токен и пуши продолжат приходить в этот браузер
|
||||
await push.disable()
|
||||
await authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
@@ -63,9 +69,13 @@ const fluid = computed(() => {
|
||||
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
||||
</div>
|
||||
|
||||
<v-btn rounded="0" :icon="THEME_ICONS[themeMode]" :title="`Theme: ${themeMode}`" @click="cycleThemeMode" />
|
||||
<div v-if="session.data" class="d-flex align-center">
|
||||
<project-menu />
|
||||
|
||||
<push-enable />
|
||||
|
||||
<v-btn rounded="0" :icon="THEME_ICONS[themeMode]" :title="`Theme: ${themeMode}`" @click="cycleThemeMode" />
|
||||
|
||||
<div v-if="session.data">
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn rounded="0" icon v-bind="props" class="mr-4">
|
||||
|
||||
@@ -39,6 +39,8 @@ export const apiClient = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
put: <T>(path: string, body?: unknown) => request<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
upload: <T>(path: string, form: FormData) => request<T>(path, { method: 'POST', body: form }),
|
||||
delete: (path: string) => request<void>(path, { method: 'DELETE' }),
|
||||
delete: (path: string, body?: unknown) =>
|
||||
request<void>(path, { method: 'DELETE', body: body === undefined ? undefined : JSON.stringify(body) }),
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import {
|
||||
getMessaging,
|
||||
register,
|
||||
unregister,
|
||||
onRegistered,
|
||||
onUnregistered,
|
||||
onMessage,
|
||||
isSupported,
|
||||
type MessagePayload,
|
||||
type Unsubscribe,
|
||||
} from 'firebase/messaging'
|
||||
|
||||
export type PushOptions = {
|
||||
// Вызывается при каждой (пере)регистрации: первый register(), смена FID,
|
||||
// событие pushsubscriptionchange. Здесь отправляйте FID на бэкенд.
|
||||
onInstallationId: (installationId: string) => void
|
||||
// FID больше не активен (unregister) — его нужно удалить на бэкенде
|
||||
onInstallationIdRemoved?: (installationId: string) => void
|
||||
// Сообщения, пришедшие, пока вкладка открыта
|
||||
onForeground?: (payload: MessagePayload) => void
|
||||
}
|
||||
|
||||
const VAPID_KEY = 'BPr-D-K2uyc9GDNaIBg1JpnjWlNPw-btaKXowDWgvIUZ14l8IBk421x9iJDlGBB_rFSfsK6HCOOfvW0-l88yGPg'
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAKY4nPht7-YsIQfhcECxVwZFx7iAnCQSM',
|
||||
authDomain: 'ulitka-c811b.firebaseapp.com',
|
||||
projectId: 'ulitka-c811b',
|
||||
storageBucket: 'ulitka-c811b.firebasestorage.app',
|
||||
messagingSenderId: '82243223520',
|
||||
appId: '1:82243223520:web:2c0e68358f8d5f2d2a7585',
|
||||
measurementId: 'G-CMDB21T1WG',
|
||||
}
|
||||
|
||||
const app = initializeApp(firebaseConfig)
|
||||
|
||||
let started = false
|
||||
let unsubscribers: Unsubscribe[] = []
|
||||
|
||||
export async function enablePush(options: PushOptions): Promise<NotificationPermission | 'unsupported'> {
|
||||
if (!(await isSupported())) return 'unsupported'
|
||||
|
||||
const result = await Notification.requestPermission()
|
||||
if (result === 'granted') await startMessaging(options)
|
||||
return result
|
||||
}
|
||||
|
||||
export async function autoRequestPush(options: PushOptions): Promise<void> {
|
||||
if (!(await isSupported())) return
|
||||
if (Notification.permission === 'granted') return startMessaging(options)
|
||||
}
|
||||
|
||||
// Отписывает браузер от FCM. Колбэк onInstallationIdRemoved сработает через onUnregistered.
|
||||
export async function disablePush(): Promise<void> {
|
||||
if (!started) return
|
||||
await unregister(getMessaging(app))
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe())
|
||||
unsubscribers = []
|
||||
started = false
|
||||
}
|
||||
|
||||
async function startMessaging({ onInstallationId, onInstallationIdRemoved, onForeground }: PushOptions): Promise<void> {
|
||||
if (started) return
|
||||
started = true
|
||||
|
||||
try {
|
||||
const messaging = getMessaging(app)
|
||||
unsubscribers = [
|
||||
onRegistered(messaging, onInstallationId),
|
||||
onUnregistered(messaging, (fid) => onInstallationIdRemoved?.(fid)),
|
||||
onMessage(messaging, (payload) => onForeground?.(payload)),
|
||||
]
|
||||
await register(messaging, { vapidKey: VAPID_KEY })
|
||||
} catch (err) {
|
||||
unsubscribers.forEach((unsubscribe) => unsubscribe())
|
||||
unsubscribers = []
|
||||
started = false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -27,7 +27,7 @@ const router = createRouter({
|
||||
{
|
||||
path: '/',
|
||||
component: HomeView,
|
||||
meta: { layout: 'Default', fluid: false },
|
||||
meta: { layout: 'Default', fluid: true },
|
||||
},
|
||||
{
|
||||
name: 'sign-in',
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
interface Snackbar {
|
||||
title: string
|
||||
text: string
|
||||
color?: string
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
export const useSnackbarStore = defineStore('snackbar', () => {
|
||||
const snackbarQueue = ref<Snackbar[]>([])
|
||||
|
||||
function add(snackbar: Snackbar) {
|
||||
snackbarQueue.value.push(snackbar)
|
||||
}
|
||||
|
||||
return {
|
||||
snackbarQueue,
|
||||
add,
|
||||
}
|
||||
})
|
||||
@@ -10,7 +10,6 @@ import type { Issue } from '@/api/issue.api.ts'
|
||||
import type { Project } from '@/api/project.api.ts'
|
||||
import DashboardCountCard from '@/components/DashboardCountCard.vue'
|
||||
import LabelText from '@/components/LabelText.vue'
|
||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
||||
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
||||
import IssueActivityChart from '@/components/IssueActivityChart.vue'
|
||||
|
||||
@@ -140,8 +139,6 @@ function onClickCard(status?: string) {
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="session.data" class="d-flex flex-column ga-2">
|
||||
<project-menu />
|
||||
|
||||
<div class="dashboard_grid">
|
||||
<dashboard-count-card label="Total issues" :value="stats?.total ?? 0" @click="onClickCard()" />
|
||||
<dashboard-count-card label="Open" :value="statCount('OPEN')" @click="onClickCard('OPEN')" />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { authClient } from '@/lib/auth-client.js'
|
||||
import SessionsList from '@/components/SessionsList.vue'
|
||||
import UserProfile from '@/components/UserProfile.vue'
|
||||
import PasswordChange from '@/components/PasswordChange.vue'
|
||||
import NotificationSettings from '@/components/NotificationSettings.vue'
|
||||
|
||||
const session = authClient.useSession()
|
||||
</script>
|
||||
@@ -13,8 +14,12 @@ const session = authClient.useSession()
|
||||
|
||||
<user-profile :user="session.data?.user" />
|
||||
|
||||
<v-divider />
|
||||
<password-change />
|
||||
|
||||
<v-divider />
|
||||
<notification-settings />
|
||||
|
||||
<sessions-list />
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
Reference in new issue
Block a user