From 4e11e97b14ae90c6757fa544c4fead711b1319ea Mon Sep 17 00:00:00 2001 From: neckfire Date: Sun, 19 Jul 2026 03:46:43 +0200 Subject: [PATCH] =?UTF-8?q?i18n=20front=20:=20facettes=20localis=C3=A9es?= =?UTF-8?q?=20+=20Web=20Push=20(SW=20push,=20abonnement,=20toggle=20profil?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- app/composables/usePush.ts | 52 +++++++++++++++++++++++++++++++++++ app/pages/exercises/index.vue | 11 ++++---- app/pages/profile.vue | 35 +++++++++++++++++++++++ app/service-worker/sw.ts | 46 +++++++++++++++++++++++++++++++ app/stores/auth.ts | 6 ++-- nuxt.config.ts | 20 ++++---------- 6 files changed, 149 insertions(+), 21 deletions(-) create mode 100644 app/composables/usePush.ts create mode 100644 app/service-worker/sw.ts diff --git a/app/composables/usePush.ts b/app/composables/usePush.ts new file mode 100644 index 0000000..c1c36a4 --- /dev/null +++ b/app/composables/usePush.ts @@ -0,0 +1,52 @@ +/** + * Abonnement Web Push. Le front ne fait que : demander la permission, s'abonner (clé VAPID du back), + * transmettre la subscription au back. Le back décide quand/quoi envoyer. + */ +function urlB64ToUint8Array(base64String: string): Uint8Array { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const raw = atob(base64) + const arr = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i) + return arr +} + +export function usePush() { + const { api } = useApi() + + function isSupported(): boolean { + return import.meta.client && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window + } + + async function enable(vapidPublicKey: string) { + if (!isSupported()) throw new Error('Notifications non supportées sur cet appareil.') + const perm = await Notification.requestPermission() + if (perm !== 'granted') throw new Error('Permission refusée.') + + const reg = await navigator.serviceWorker.ready + const existing = await reg.pushManager.getSubscription() + const sub = existing ?? await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlB64ToUint8Array(vapidPublicKey) + }) + + const json = sub.toJSON() as { endpoint?: string, keys?: { p256dh: string, auth: string } } + await api('/api/push/subscribe', { method: 'POST', body: { endpoint: json.endpoint, keys: json.keys } }) + } + + async function disable() { + if (!isSupported()) return + const reg = await navigator.serviceWorker.ready + const sub = await reg.pushManager.getSubscription() + if (sub) { + await api('/api/push/unsubscribe', { method: 'POST', body: { endpoint: sub.endpoint } }).catch(() => {}) + await sub.unsubscribe() + } + } + + async function sendTest() { + await api('/api/push/test', { method: 'POST' }) + } + + return { isSupported, enable, disable, sendTest } +} diff --git a/app/pages/exercises/index.vue b/app/pages/exercises/index.vue index 95b5614..0a46b9d 100644 --- a/app/pages/exercises/index.vue +++ b/app/pages/exercises/index.vue @@ -1,18 +1,19 @@ diff --git a/app/pages/profile.vue b/app/pages/profile.vue index 35d80e0..b6ab12f 100644 --- a/app/pages/profile.vue +++ b/app/pages/profile.vue @@ -2,6 +2,30 @@ definePageMeta({ middleware: 'auth' }) const { t, locale, locales, setLocale } = useI18n() const auth = useAuthStore() +const { isSupported, enable, disable, sendTest } = usePush() +const toast = useToast() + +const pushOn = ref(auth.push.enabled) +const pushLoading = ref(false) + +async function togglePush(val: boolean) { + pushLoading.value = true + try { + if (val) { + await enable(auth.push.vapid_public_key || '') + pushOn.value = true + toast.add({ title: 'Rappels activés 🔥', color: 'primary' }) + } else { + await disable() + pushOn.value = false + } + } catch (e) { + pushOn.value = false + toast.add({ title: (e as Error).message || t('common.error'), color: 'error' }) + } finally { + pushLoading.value = false + } +} async function doLogout() { await auth.logout() @@ -44,6 +68,17 @@ async function doLogout() { + +
+
+

Rappels de série 🔥

+

Un rappel le soir si tu risques de perdre ta flamme.

+
+ +
+ +
+ diff --git a/app/service-worker/sw.ts b/app/service-worker/sw.ts new file mode 100644 index 0000000..78923f4 --- /dev/null +++ b/app/service-worker/sw.ts @@ -0,0 +1,46 @@ +/// +import { precacheAndRoute } from 'workbox-precaching' + +declare const self: ServiceWorkerGlobalScope & { __WB_MANIFEST: Array<{ url: string, revision: string | null }> } + +// Précache des assets de l'app (injecté au build). +precacheAndRoute(self.__WB_MANIFEST) + +self.skipWaiting() +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())) + +// Réception d'un push : affiche la notification système (le contenu vient du back). +self.addEventListener('push', (event: PushEvent) => { + let payload: { title?: string, body?: string, icon?: string, badge?: string, data?: { url?: string } } = {} + try { + payload = event.data?.json() ?? {} + } catch { + payload = { body: event.data?.text() } + } + + const title = payload.title || 'StreakFit' + event.waitUntil( + self.registration.showNotification(title, { + body: payload.body, + icon: payload.icon || '/pwa-192.png', + badge: payload.badge || '/pwa-192.png', + data: payload.data || { url: '/' }, + tag: 'streakfit-streak', + renotify: true + }) + ) +}) + +// Clic sur la notification : ouvre/focus l'app sur l'URL fournie. +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close() + const url = (event.notification.data && event.notification.data.url) || '/' + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if ('focus' in client) return client.focus() + } + return self.clients.openWindow(url) + }) + ) +}) diff --git a/app/stores/auth.ts b/app/stores/auth.ts index 0edd1d5..916abab 100644 --- a/app/stores/auth.ts +++ b/app/stores/auth.ts @@ -16,6 +16,7 @@ interface SfUser { export const useAuthStore = defineStore('auth', () => { const user = ref(null) const streak = ref<{ current: number, longest: number, freezes: number } | null>(null) + const push = ref<{ vapid_public_key: string | null, enabled: boolean }>({ vapid_public_key: null, enabled: false }) const ready = ref(false) const isAuthed = computed(() => !!user.value) @@ -23,9 +24,10 @@ export const useAuthStore = defineStore('auth', () => { async function fetchMe() { const { api } = useApi() try { - const res = await api<{ user: SfUser, streak: typeof streak.value }>('/api/me') + const res = await api<{ user: SfUser, streak: typeof streak.value, push: typeof push.value }>('/api/me') user.value = res.user streak.value = res.streak + if (res.push) push.value = res.push } catch { user.value = null streak.value = null @@ -65,5 +67,5 @@ export const useAuthStore = defineStore('auth', () => { window.location.href = `${base}/auth/oidc/redirect` } - return { user, streak, ready, isAuthed, fetchMe, login, register, logout, loginWithPocketId } + return { user, streak, push, ready, isAuthed, fetchMe, login, register, logout, loginWithPocketId } }) diff --git a/nuxt.config.ts b/nuxt.config.ts index bd9ef6b..8ab1987 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -58,7 +58,13 @@ export default defineNuxtConfig({ }, pwa: { + strategies: 'injectManifest', + srcDir: 'service-worker', + filename: 'sw.ts', registerType: 'autoUpdate', + injectManifest: { + globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'] + }, manifest: { name: 'StreakFit', short_name: 'StreakFit', @@ -75,20 +81,6 @@ export default defineNuxtConfig({ { src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' } ] }, - workbox: { - navigateFallback: '/', - globPatterns: ['**/*.{js,css,html,svg,png,wo4,woff2}'], - runtimeCaching: [ - { - urlPattern: ({ url }: { url: URL }) => url.hostname === 's3.nfteam.ovh', - handler: 'CacheFirst', - options: { - cacheName: 'sf-media', - expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 30 } - } - } - ] - }, client: { installPrompt: true } },