i18n front : facettes localisées + Web Push (SW push, abonnement, toggle profil)
Build & Deploy / build (push) Successful in 6s
Build & Deploy / build (push) Successful in 6s
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||||
|
}
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { t } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
|
|
||||||
interface ExerciseRow { id: number, name: string, slug: string, body_part: string | null, equipment: string | null, target: string | null, image_url: string | null }
|
interface ExerciseRow { id: number, name: string, slug: string, body_part: string | null, equipment: string | null, target: string | null, image_url: string | null }
|
||||||
interface Facets { body_parts: string[], equipments: string[], targets: string[], categories: string[] }
|
interface Facet { value: string, label: string }
|
||||||
|
interface Facets { body_parts: Facet[], equipments: Facet[], targets: Facet[], categories: Facet[] }
|
||||||
interface Paginated { data: ExerciseRow[], meta: { current_page: number, last_page: number, total: number } }
|
interface Paginated { data: ExerciseRow[], meta: { current_page: number, last_page: number, total: number } }
|
||||||
|
|
||||||
const search = ref('')
|
const search = ref('')
|
||||||
const bodyPart = ref<string | undefined>()
|
const bodyPart = ref<string | undefined>()
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
|
|
||||||
const { data: facets } = await useAsyncData('ex-facets', () => api<Facets>('/api/exercises/facets'), { lazy: true })
|
const { data: facets } = await useAsyncData('ex-facets', () => api<Facets>('/api/exercises/facets', { query: { locale: locale.value } }), { lazy: true, watch: [locale] })
|
||||||
|
|
||||||
const query = computed(() => ({ search: search.value || undefined, body_part: bodyPart.value || undefined, page: page.value }))
|
const query = computed(() => ({ search: search.value || undefined, body_part: bodyPart.value || undefined, page: page.value, locale: locale.value }))
|
||||||
const { data, pending } = await useAsyncData<Paginated>(
|
const { data, pending } = await useAsyncData<Paginated>(
|
||||||
'exercises',
|
'exercises',
|
||||||
() => api<Paginated>('/api/exercises', { query: query.value }),
|
() => api<Paginated>('/api/exercises', { query: query.value }),
|
||||||
@@ -23,7 +24,7 @@ watch([search, bodyPart], () => { page.value = 1 })
|
|||||||
|
|
||||||
const bodyPartOptions = computed(() => [
|
const bodyPartOptions = computed(() => [
|
||||||
{ label: t('exercises.all'), value: undefined },
|
{ label: t('exercises.all'), value: undefined },
|
||||||
...(facets.value?.body_parts ?? []).map(b => ({ label: b, value: b }))
|
...(facets.value?.body_parts ?? []).map(b => ({ label: b.label, value: b.value }))
|
||||||
])
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,30 @@
|
|||||||
definePageMeta({ middleware: 'auth' })
|
definePageMeta({ middleware: 'auth' })
|
||||||
const { t, locale, locales, setLocale } = useI18n()
|
const { t, locale, locales, setLocale } = useI18n()
|
||||||
const auth = useAuthStore()
|
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() {
|
async function doLogout() {
|
||||||
await auth.logout()
|
await auth.logout()
|
||||||
@@ -44,6 +68,17 @@ async function doLogout() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UCard v-if="isSupported()" :ui="{ body: 'p-4' }">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">Rappels de série 🔥</p>
|
||||||
|
<p class="text-xs text-zinc-500">Un rappel le soir si tu risques de perdre ta flamme.</p>
|
||||||
|
</div>
|
||||||
|
<USwitch v-model="pushOn" :loading="pushLoading" @update:model-value="togglePush" />
|
||||||
|
</div>
|
||||||
|
<UButton v-if="pushOn" size="xs" variant="link" color="neutral" class="mt-1 -ml-2" label="Envoyer un test" @click="sendTest" />
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UButton block color="neutral" variant="subtle" icon="i-lucide-log-out" :label="t('auth.logout')" @click="doLogout" />
|
<UButton block color="neutral" variant="subtle" icon="i-lucide-log-out" :label="t('auth.logout')" @click="doLogout" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
+4
-2
@@ -16,6 +16,7 @@ interface SfUser {
|
|||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<SfUser | null>(null)
|
const user = ref<SfUser | null>(null)
|
||||||
const streak = ref<{ current: number, longest: number, freezes: number } | null>(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 ready = ref(false)
|
||||||
|
|
||||||
const isAuthed = computed(() => !!user.value)
|
const isAuthed = computed(() => !!user.value)
|
||||||
@@ -23,9 +24,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
async function fetchMe() {
|
async function fetchMe() {
|
||||||
const { api } = useApi()
|
const { api } = useApi()
|
||||||
try {
|
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
|
user.value = res.user
|
||||||
streak.value = res.streak
|
streak.value = res.streak
|
||||||
|
if (res.push) push.value = res.push
|
||||||
} catch {
|
} catch {
|
||||||
user.value = null
|
user.value = null
|
||||||
streak.value = null
|
streak.value = null
|
||||||
@@ -65,5 +67,5 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
window.location.href = `${base}/auth/oidc/redirect`
|
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 }
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-14
@@ -58,7 +58,13 @@ export default defineNuxtConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
pwa: {
|
pwa: {
|
||||||
|
strategies: 'injectManifest',
|
||||||
|
srcDir: 'service-worker',
|
||||||
|
filename: 'sw.ts',
|
||||||
registerType: 'autoUpdate',
|
registerType: 'autoUpdate',
|
||||||
|
injectManifest: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}']
|
||||||
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'StreakFit',
|
name: 'StreakFit',
|
||||||
short_name: 'StreakFit',
|
short_name: 'StreakFit',
|
||||||
@@ -75,20 +81,6 @@ export default defineNuxtConfig({
|
|||||||
{ src: '/pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }
|
{ 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 }
|
client: { installPrompt: true }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user