From 66de8d66383d9951d7a554455b4b2335b171908d Mon Sep 17 00:00:00 2001 From: senke Date: Wed, 11 Mar 2026 10:09:24 +0100 Subject: [PATCH] =?UTF-8?q?feat(v0.12.5):=20PWA=20enhancements=20=E2=80=94?= =?UTF-8?q?=20offline=20audio,=20responsive=20hooks,=20icons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Service Worker: audio caching strategy for offline playback (cache-first) - Service Worker: CACHE_AUDIO message for explicit track caching - useMediaQuery hook with useIsMobile/useIsTablet/useIsDesktop helpers - PWAUpdateBanner and OfflineBanner components (previously stubs) - Missing notification icons: badge-72x72, checkmark, xmark Co-Authored-By: Claude Opus 4.6 --- apps/web/public/icons/badge-72x72.png | Bin 0 -> 1450 bytes apps/web/public/icons/checkmark.png | Bin 0 -> 94 bytes apps/web/public/icons/xmark.png | Bin 0 -> 94 bytes apps/web/public/sw.js | 58 +++++++++++++++++- .../src/components/pwa/PWAInstallBanner.tsx | 58 ++++++++++++++---- apps/web/src/hooks/useMediaQuery.ts | 42 +++++++++++++ 6 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 apps/web/public/icons/badge-72x72.png create mode 100644 apps/web/public/icons/checkmark.png create mode 100644 apps/web/public/icons/xmark.png create mode 100644 apps/web/src/hooks/useMediaQuery.ts diff --git a/apps/web/public/icons/badge-72x72.png b/apps/web/public/icons/badge-72x72.png new file mode 100644 index 0000000000000000000000000000000000000000..07e25dafc7ea421cf2d69d4b127d8a07a499081f GIT binary patch literal 1450 zcmeAS@N?(olHy`uVBq!ia0vp^9w02h1SGrUe*^<5g=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(G(@^*Lm4+fkO-|qlL*h@TpUD;nTb91uFi?FO+ z#lXPQ<>}%WQgQ3;oZOt?P>JL3UvD~dsrRP9%G)=%Zwtu2uDac*(x#d;fyG{DhJdT` zGJ`c92N(`^w(zGIwwy6vlw8cxBjj{Nz@+1tfa0+W;tM8k=kt4$KYy8C{m$}y`zO6{=fh`b%QUhyer4&nqL6rkDrm-{ zLoK>Zv*SM;EI)hr@~_*sqodD0+jdH9wq-`uwG^Q>GyDJhy}KPh(ciKMyT@^enQTyLrNw zy?3rJH~+f*%lV$VW4qq{PwV1hwz1wbf5rbJ>RZ*+^~=+ZmVF93o3&HZ?qloUswGXO zYfl<2ZT)I<^R~3C?2pfGdlemn5;f}`W2T$WDv!7^;ogkS^DEv@yWbJAX`N~0vMmM& zqF-!XDOK}a{1e~013y<6h1pGfWwBGcxVSa^^tb7)F07O8J(%id``Ybd!sNI!yH2Gz zDx3*^u(mJbqvzU_njV_^J>TYM)m#b-;Oo?XWBx&>je#k#URTm$UH^g044x-nP7*S_ zz3E0#sKxw07N=(1op4;-S^W9|-k+<_-%$47yiVU#fP>klH7@#4@v+m>C+!sHW1i}+ z-FC#yV;8%=d{>=Jl(^Nx_bIWFSqlW&sx6cI0uQq)t~9l5eNxtAy3}m;lV|#$81vrE zy?8;ljX^5$@a=a0SNqm6A2eTF=E3z!&Q|TsreEoL!mt1TjdR_*TjJI3uVUwlkJXl~ zZF?|d4u@1?!}1UJcsq)lKbzjH-~YN;?@7(M{~sfc&YAis(4)!!@3(+0dVgvy)_mW* zPEB?KC-;@oD{f^gyRU@ak@9#ZU$)Kr`up;|Z=<%V=?i~$ofys3ncrGp`b6+r=@NdU z#N_T{VUpQQx2@u?^EnwkZkP0KofCPytLsQ`=XLE>vGVq6+4r;J^6u}P=ir{A|Jv#j z`%Hs`L({I>N>9kq-Q8{fuj2Nd_$7=UR`+*rh?ACywf?nmF-iS&28-=$KL$Or(%A+==o;`Q!(JaN|`;2?oHySAHo1U*XuWV-h zv!6Fl&)$^0%I%)~&zYxR3pF{eo%7X4o&C!pq3WO0j`GJpP4<0Q9M59cn{)Y<>GY4P zuV?!4y=Yba_UUn@L0Q|!1>$Tf^Uk!bKEJqB=Ld1!`dMboFyt=akR{0FgNzL;wH) literal 0 HcmV?d00001 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)'); +}