firebase implementation
Gitea Actions / Deploy (push) Successful in 1m14s

This commit is contained in:
vadim committed 2026-10-16 16:56:34 +03:00
1 parent a0c557f9b2
commit 2d4b8ea9cf
17 files changed
+1541 -11

No files matched your search

+1044 -4
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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",
+23
View File
@@ -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
View File
@@ -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>
+31
View File
@@ -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 })
}
+153
View File
@@ -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>
+1 -1
View File
@@ -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 }">
+40
View File
@@ -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>
+38
View File
@@ -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>
+71
View File
@@ -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
},
}
}
+11 -1
View File
@@ -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>
<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">
+3 -1
View File
@@ -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) }),
}
+81
View File
@@ -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
View File
@@ -27,7 +27,7 @@ const router = createRouter({
{
path: '/',
component: HomeView,
meta: { layout: 'Default', fluid: false },
meta: { layout: 'Default', fluid: true },
},
{
name: 'sign-in',
+22
View File
@@ -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,
}
})
-3
View File
@@ -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')" />
+5
View File
@@ -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>