diff --git a/VEZA_VERSIONS_ROADMAP.md b/VEZA_VERSIONS_ROADMAP.md index 764e06ffd..96ef54fcf 100644 --- a/VEZA_VERSIONS_ROADMAP.md +++ b/VEZA_VERSIONS_ROADMAP.md @@ -1084,27 +1084,28 @@ Implémenter le système de modération humaine assistée par règles détermini ### v0.12.5 — PWA & Expérience Mobile (F521-F535 revisités) -**Statut** : ⏳ TODO -**Priorité** : P2 -**Durée estimée** : 4-5 jours -**Prerequisite** : v0.12.4 complète +**Statut** : ✅ DONE +**Priorité** : P2 +**Durée estimée** : 4-5 jours +**Prerequisite** : v0.12.4 complète +**Complété le** : 2026-03-11 -**Objectif** +**Objectif** Stratégie PWA (Progressive Web App) en remplacement des apps natives Electron (Module 20 révisé). Référence : ORIGIN_REVISION_SUMMARY.md §6 Incohérence #2 **Tâches** -- [ ] Service Worker pour offline (lecture des tracks téléchargées) -- [ ] Manifest PWA (installable sur mobile) -- [ ] Push notifications web -- [ ] Contrôles media dans la notification bar (Media Session API) -- [ ] Design responsive optimisé mobile (SUMI design system) +- [x] Service Worker pour offline (lecture des tracks téléchargées) +- [x] Manifest PWA (installable sur mobile) +- [x] Push notifications web +- [x] Contrôles media dans la notification bar (Media Session API) +- [x] Design responsive optimisé mobile (SUMI design system) **Critères d'acceptation** -- [ ] Score Lighthouse PWA ≥ 90 -- [ ] Player audio fonctionne hors ligne pour les tracks téléchargées -- [ ] Installable sur Android et iOS (via "Ajouter à l'écran d'accueil") +- [x] Score Lighthouse PWA ≥ 90 +- [x] Player audio fonctionne hors ligne pour les tracks téléchargées +- [x] Installable sur Android et iOS (via "Ajouter à l'écran d'accueil") --- @@ -1244,7 +1245,7 @@ Toutes les conditions suivantes doivent être remplies avant de taguer v1.0.0 : | v0.12.2 | Distribution Plateformes | P6R | ✅ DONE | 5-6j | v0.12.1 | | v0.12.3 | Formation & Éducation | P6R | ✅ DONE | 6-8j | v0.12.0 | | v0.12.4 | Performance & Scalabilité | P6R | ✅ DONE | 3-4j | v0.12.2 | -| v0.12.5 | PWA & Mobile | P6R | ⏳ TODO | 4-5j | v0.12.4 | +| v0.12.5 | PWA & Mobile | P6R | ✅ DONE | 4-5j | v0.12.4 | | v0.12.6 | Pentest Externe | P6R | ⏳ TODO | 2-4 sem. | v0.12.4 | | v0.12.7 | Internationalisation | P6R | ⏳ TODO | 3-4j | v0.12.5 | | v0.12.8 | Documentation & API Publique | P6R | ⏳ TODO | 3-4j | v0.12.6 | diff --git a/apps/web/public/icons/badge-72x72.png b/apps/web/public/icons/badge-72x72.png new file mode 100644 index 000000000..07e25dafc Binary files /dev/null and b/apps/web/public/icons/badge-72x72.png differ diff --git a/apps/web/public/icons/checkmark.png b/apps/web/public/icons/checkmark.png new file mode 100644 index 000000000..fcdb2f66d Binary files /dev/null and b/apps/web/public/icons/checkmark.png differ diff --git a/apps/web/public/icons/xmark.png b/apps/web/public/icons/xmark.png new file mode 100644 index 000000000..7d6b1d46c Binary files /dev/null and b/apps/web/public/icons/xmark.png differ diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js index 2393484f1..c40827d5b 100644 --- a/apps/web/public/sw.js +++ b/apps/web/public/sw.js @@ -79,11 +79,16 @@ self.addEventListener('fetch', (event) => { // Handle different types of requests with appropriate strategies async function handleRequest(request) { try { + // Strategy 0: Cache First for audio files (offline playback) + if (isAudioRequest(request.url)) { + return await cacheAudio(request); + } + // Strategy 1: Cache First for static assets if (isStaticAsset(request.url)) { return await cacheFirst(request, STATIC_CACHE_NAME); } - + // Strategy 2: Network First for API requests if (isApiRequest(request.url)) { return await networkFirst(request, DYNAMIC_CACHE_NAME); @@ -236,6 +241,41 @@ async function getOfflinePage() { }); } +// v0.12.5: Audio file caching for offline playback +const AUDIO_CACHE_NAME = `veza-audio-${CACHE_VERSION}`; + +function isAudioRequest(url) { + const path = new URL(url).pathname; + return /\.(mp3|m4a|ogg|wav|flac|aac|opus)(\?.*)?$/.test(path) || + path.includes('/audio/') || + path.includes('/hls/'); +} + +// Cache audio with cache-first strategy (audio files are immutable) +async function cacheAudio(request) { + const cached = await caches.match(request); + if (cached) { + return cached; + } + + try { + const response = await fetch(request); + if (response.ok) { + const cache = await caches.open(AUDIO_CACHE_NAME); + cache.put(request, response.clone()); + } + return response; + } catch (error) { + // Return cached version if available (offline playback) + const cachedFallback = await caches.match(request); + if (cachedFallback) { + console.log('[SW] Serving cached audio (offline)'); + return cachedFallback; + } + throw error; + } +} + // Helper functions - only cache images, fonts, ico (never js/css) function isStaticAsset(url) { const path = new URL(url).pathname; @@ -280,7 +320,21 @@ self.addEventListener('message', (event) => { }); }); break; - + + // v0.12.5: Cache a specific audio URL for offline playback + case 'CACHE_AUDIO': + if (event.data.url) { + caches.open(AUDIO_CACHE_NAME).then(async (cache) => { + try { + await cache.add(event.data.url); + event.ports[0]?.postMessage({ type: 'AUDIO_CACHED', payload: { success: true, url: event.data.url } }); + } catch (err) { + event.ports[0]?.postMessage({ type: 'AUDIO_CACHED', payload: { success: false, error: err.message } }); + } + }); + } + break; + default: console.log('[SW] Unknown message type:', type); } diff --git a/apps/web/src/components/pwa/PWAInstallBanner.tsx b/apps/web/src/components/pwa/PWAInstallBanner.tsx index b2dc5f843..cbc651e92 100644 --- a/apps/web/src/components/pwa/PWAInstallBanner.tsx +++ b/apps/web/src/components/pwa/PWAInstallBanner.tsx @@ -5,7 +5,7 @@ import { Download, Smartphone } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { usePWAInstallBanner } from '@/hooks/usePWA'; +import { usePWAInstallBanner, usePWA as usePWAHook } from '@/hooks/usePWA'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; @@ -72,23 +72,59 @@ export function PWAInstallBanner() { } /** - * PWA Update Banner Component - * Shows a banner when app update is available + * v0.12.5: PWA Update Banner Component + * Shows a non-intrusive notification when a new version is ready. */ export function PWAUpdateBanner() { - // This would need to be implemented with the PWA hook - // For now, returning null as it requires more complex state management + const { canUpdate, update, isUpdating } = usePWAHook(); - return null; + if (!canUpdate) return null; + + return ( +
+
+
+
+

+ SYSTEM_UPDATE_AVAILABLE +

+ +
+
+ ); } /** - * Offline Banner Component - * Shows when the app is offline + * v0.12.5: Offline Banner Component + * Shows a minimal banner when the app is offline. */ export function OfflineBanner() { - // This would integrate with the offline detection hook - // For now, returning null as it requires more complex state management + const { isOffline } = usePWAHook(); - return null; + if (!isOffline) return null; + + return ( +
+

+ OFFLINE_MODE — CACHED_DATA_ONLY +

+
+ ); } diff --git a/apps/web/src/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts new file mode 100644 index 000000000..1141c49ce --- /dev/null +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -0,0 +1,42 @@ +/** + * v0.12.5: Responsive media query hook + * Provides programmatic access to Tailwind breakpoints + */ + +import { useState, useEffect } from 'react'; + +/** + * Hook that tracks a CSS media query match state. + * @param query - CSS media query string (e.g. "(min-width: 1024px)") + * @returns boolean indicating if the query matches + */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if (typeof window === 'undefined') return false; + return window.matchMedia(query).matches; + }); + + useEffect(() => { + const mql = window.matchMedia(query); + setMatches(mql.matches); + + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, [query]); + + return matches; +} + +/** Tailwind breakpoint hooks */ +export function useIsMobile(): boolean { + return !useMediaQuery('(min-width: 640px)'); +} + +export function useIsTablet(): boolean { + return useMediaQuery('(min-width: 640px)') && !useMediaQuery('(min-width: 1024px)'); +} + +export function useIsDesktop(): boolean { + return useMediaQuery('(min-width: 1024px)'); +}