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",
|
"@vueuse/core": "^14.4.0",
|
||||||
"better-auth": "^1.7.1",
|
"better-auth": "^1.7.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
|
"firebase": "^13.0.0",
|
||||||
"pinia": "^4.0.3",
|
"pinia": "^4.0.3",
|
||||||
"vue": "^3.5.41",
|
"vue": "^3.5.41",
|
||||||
"vue-chartjs": "^5.3.4",
|
"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 { useStatusStore } from '@/stores/status.ts'
|
||||||
import { useIssueStore } from '@/stores/issue.ts'
|
import { useIssueStore } from '@/stores/issue.ts'
|
||||||
import { useProjectStore } from '@/stores/project.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 route = useRoute()
|
||||||
const issueStore = useIssueStore()
|
const issueStore = useIssueStore()
|
||||||
const statusStore = useStatusStore()
|
const statusStore = useStatusStore()
|
||||||
const projectStore = useProjectStore()
|
const projectStore = useProjectStore()
|
||||||
|
const session = authClient.useSession()
|
||||||
|
const push = usePushNotifications()
|
||||||
|
|
||||||
const { currentProjectId } = storeToRefs(projectStore)
|
const { currentProjectId } = storeToRefs(projectStore)
|
||||||
|
|
||||||
interface IssueSseEvent {
|
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 = computed(() => {
|
||||||
const layout = route?.meta?.layout
|
const layout = route?.meta?.layout
|
||||||
return layout ? `${layout}Layout` : 'DefaultLayout'
|
return layout ? `${layout}Layout` : 'DefaultLayout'
|
||||||
@@ -58,6 +73,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</router-view>
|
</router-view>
|
||||||
</component>
|
</component>
|
||||||
|
<snackbar-queue />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped></style>
|
<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>
|
</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 ga-2 mr-2">
|
||||||
<div>Current project:</div>
|
<div>Current project:</div>
|
||||||
<v-menu>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props }">
|
<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 { useThemeMode } from '@/composables/useThemeMode.ts'
|
||||||
import { THEME_ICONS } from '@/lib/theme.ts'
|
import { THEME_ICONS } from '@/lib/theme.ts'
|
||||||
import NavigationMenu from '@/components/NavigationMenu.vue'
|
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 router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const session = authClient.useSession()
|
const session = authClient.useSession()
|
||||||
const { mode: themeMode, cycleThemeMode } = useThemeMode()
|
const { mode: themeMode, cycleThemeMode } = useThemeMode()
|
||||||
|
const push = usePushNotifications()
|
||||||
|
|
||||||
const title = 'Ulitka'
|
const title = 'Ulitka'
|
||||||
|
|
||||||
@@ -30,6 +34,8 @@ function goToProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function goToSignOut() {
|
async function goToSignOut() {
|
||||||
|
// Пока сессия жива — иначе бэкенд не даст удалить токен и пуши продолжат приходить в этот браузер
|
||||||
|
await push.disable()
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
fetchOptions: {
|
fetchOptions: {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -63,9 +69,13 @@ const fluid = computed(() => {
|
|||||||
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
<v-btn @click="goToSignUp">Sign Up</v-btn>
|
||||||
</div>
|
</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>
|
<v-menu>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn rounded="0" icon v-bind="props" class="mr-4">
|
<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),
|
get: <T>(path: string) => request<T>(path),
|
||||||
post: <T>(path: string, body?: unknown) => request<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
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) }),
|
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 }),
|
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: '/',
|
path: '/',
|
||||||
component: HomeView,
|
component: HomeView,
|
||||||
meta: { layout: 'Default', fluid: false },
|
meta: { layout: 'Default', fluid: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'sign-in',
|
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 type { Project } from '@/api/project.api.ts'
|
||||||
import DashboardCountCard from '@/components/DashboardCountCard.vue'
|
import DashboardCountCard from '@/components/DashboardCountCard.vue'
|
||||||
import LabelText from '@/components/LabelText.vue'
|
import LabelText from '@/components/LabelText.vue'
|
||||||
import ProjectMenu from '@/components/ProjectMenu.vue'
|
|
||||||
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
import IssueStatusChart from '@/components/IssueStatusChart.vue'
|
||||||
import IssueActivityChart from '@/components/IssueActivityChart.vue'
|
import IssueActivityChart from '@/components/IssueActivityChart.vue'
|
||||||
|
|
||||||
@@ -140,8 +139,6 @@ function onClickCard(status?: string) {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="session.data" class="d-flex flex-column ga-2">
|
<div v-if="session.data" class="d-flex flex-column ga-2">
|
||||||
<project-menu />
|
|
||||||
|
|
||||||
<div class="dashboard_grid">
|
<div class="dashboard_grid">
|
||||||
<dashboard-count-card label="Total issues" :value="stats?.total ?? 0" @click="onClickCard()" />
|
<dashboard-count-card label="Total issues" :value="stats?.total ?? 0" @click="onClickCard()" />
|
||||||
<dashboard-count-card label="Open" :value="statCount('OPEN')" @click="onClickCard('OPEN')" />
|
<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 SessionsList from '@/components/SessionsList.vue'
|
||||||
import UserProfile from '@/components/UserProfile.vue'
|
import UserProfile from '@/components/UserProfile.vue'
|
||||||
import PasswordChange from '@/components/PasswordChange.vue'
|
import PasswordChange from '@/components/PasswordChange.vue'
|
||||||
|
import NotificationSettings from '@/components/NotificationSettings.vue'
|
||||||
|
|
||||||
const session = authClient.useSession()
|
const session = authClient.useSession()
|
||||||
</script>
|
</script>
|
||||||
@@ -13,8 +14,12 @@ const session = authClient.useSession()
|
|||||||
|
|
||||||
<user-profile :user="session.data?.user" />
|
<user-profile :user="session.data?.user" />
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
<password-change />
|
<password-change />
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
<notification-settings />
|
||||||
|
|
||||||
<sessions-list />
|
<sessions-list />
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in new issue
Block a user