From bd957d9eefaecfa2cad77e489b5bd53ff448132b Mon Sep 17 00:00:00 2001 From: Anish Lakhwara Date: Sat, 12 Apr 2025 21:12:53 -0700 Subject: [PATCH] feat: PWA --- .gitignore | 2 + mast-react-vite/index.html | 14 +- mast-react-vite/public/icons/icon-192x192.png | 1 + mast-react-vite/public/icons/icon-512x512.png | 1 + mast-react-vite/public/logo.svg | 5 + mast-react-vite/public/manifest.json | 23 ++ mast-react-vite/public/offline.html | 128 +++++++ mast-react-vite/public/service-worker.js | 348 ++++++++++++++++++ mast-react-vite/pwa-assets.config.js | 20 + .../src/components/ui/pwa-cache-reset.tsx | 58 +++ .../src/components/ui/pwa-install-prompt.tsx | 113 ++++++ .../components/ui/pwa-update-notification.tsx | 156 ++++++++ .../src/contexts/selection-context.tsx | 44 ++- mast-react-vite/src/lib/pwa-utils.ts | 49 +++ mast-react-vite/src/main.tsx | 62 ++++ 15 files changed, 1012 insertions(+), 12 deletions(-) create mode 100644 mast-react-vite/public/icons/icon-192x192.png create mode 100644 mast-react-vite/public/icons/icon-512x512.png create mode 100644 mast-react-vite/public/logo.svg create mode 100644 mast-react-vite/public/manifest.json create mode 100644 mast-react-vite/public/offline.html create mode 100644 mast-react-vite/public/service-worker.js create mode 100644 mast-react-vite/pwa-assets.config.js create mode 100644 mast-react-vite/src/components/ui/pwa-cache-reset.tsx create mode 100644 mast-react-vite/src/components/ui/pwa-install-prompt.tsx create mode 100644 mast-react-vite/src/components/ui/pwa-update-notification.tsx create mode 100644 mast-react-vite/src/lib/pwa-utils.ts diff --git a/.gitignore b/.gitignore index c4c1710..cf5a173 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ *.sqlite +**/*.db +mast-react-vite/tsconfig.* mast diff --git a/mast-react-vite/index.html b/mast-react-vite/index.html index e4b78ea..2d3398e 100644 --- a/mast-react-vite/index.html +++ b/mast-react-vite/index.html @@ -4,10 +4,22 @@ - Vite + React + TS + Mast + + + + + + +
+ diff --git a/mast-react-vite/public/icons/icon-192x192.png b/mast-react-vite/public/icons/icon-192x192.png new file mode 100644 index 0000000..f8e667f --- /dev/null +++ b/mast-react-vite/public/icons/icon-192x192.png @@ -0,0 +1 @@ +binary_data_placeholder \ No newline at end of file diff --git a/mast-react-vite/public/icons/icon-512x512.png b/mast-react-vite/public/icons/icon-512x512.png new file mode 100644 index 0000000..f8e667f --- /dev/null +++ b/mast-react-vite/public/icons/icon-512x512.png @@ -0,0 +1 @@ +binary_data_placeholder \ No newline at end of file diff --git a/mast-react-vite/public/logo.svg b/mast-react-vite/public/logo.svg new file mode 100644 index 0000000..3f2f313 --- /dev/null +++ b/mast-react-vite/public/logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/mast-react-vite/public/manifest.json b/mast-react-vite/public/manifest.json new file mode 100644 index 0000000..d490ee7 --- /dev/null +++ b/mast-react-vite/public/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "Mast Todo", + "short_name": "Mast", + "description": "A local-first todo application", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#4f46e5", + "icons": [ + { + "src": "/icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} \ No newline at end of file diff --git a/mast-react-vite/public/offline.html b/mast-react-vite/public/offline.html new file mode 100644 index 0000000..8961c88 --- /dev/null +++ b/mast-react-vite/public/offline.html @@ -0,0 +1,128 @@ + + + + + + Offline - Mast Todo + + + +
+
+
📶
+

You're Offline

+

Your todos are still available, but you'll need to reconnect to sync with other devices.

+ +
+ Good news! Mast Todo is designed to work offline. + All your changes will be saved locally and will sync when you're back online. +
+ + +
+
+ + + + \ No newline at end of file diff --git a/mast-react-vite/public/service-worker.js b/mast-react-vite/public/service-worker.js new file mode 100644 index 0000000..56a9b10 --- /dev/null +++ b/mast-react-vite/public/service-worker.js @@ -0,0 +1,348 @@ +// Service worker for Mast Todo PWA + +// Cache names for different types of resources - update versions to force cache refresh +const CACHE_VERSION = '2'; // Increment this when deploying updates +const STATIC_CACHE = 'mast-static-v' + CACHE_VERSION; +const WASM_CACHE = 'mast-wasm-v' + CACHE_VERSION; +const DYNAMIC_CACHE = 'mast-dynamic-v' + CACHE_VERSION; + +// Core application shell assets (always loaded from cache first if available) +const CORE_ASSETS = [ + '/', + '/index.html', + '/offline.html', + '/manifest.json', + '/icons/icon-192x192.png', + '/icons/icon-512x512.png' +]; + +// Assets that use the stale-while-revalidate strategy +// (serve from cache while updating in background) +const STALE_WHILE_REVALIDATE_PATTERNS = [ + /\.css$/, + /\.js$/, + /\.json$/, + /\.svg$/, + /\.png$/, + /\.jpg$/, + /\.ico$/, +]; + +// Assets that use cache-first strategy (rarely change) +const CACHE_FIRST_PATTERNS = [ + /\.wasm$/, + /crsqlite\.wasm/, + /sql-wasm\.wasm/, + /sql-wasm-debug\.wasm/, + /assets\/index-.*\.js$/, // Vite bundles with content hash + /assets\/.*\.chunk\.js$/ +]; + +// Network-first patterns (prefer fresh content) +const NETWORK_FIRST_PATTERNS = [ + /api\// +]; + +// Install event - cache core assets +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(STATIC_CACHE).then((cache) => { + console.log('[Service Worker] Caching core app shell'); + return cache.addAll(CORE_ASSETS).then(() => { + console.log('[Service Worker] Core assets cached'); + self.skipWaiting(); + }); + }) + ); +}); + +// Activate event - clean up old caches and take control +self.addEventListener('activate', (event) => { + const currentCaches = [STATIC_CACHE, WASM_CACHE, DYNAMIC_CACHE]; + + event.waitUntil( + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter(cacheName => !currentCaches.includes(cacheName)) + .map(cacheName => { + console.log('[Service Worker] Deleting old cache:', cacheName); + return caches.delete(cacheName); + }) + ); + }).then(() => { + console.log('[Service Worker] Claiming clients'); + return self.clients.claim(); + }) + ); +}); + +// Helper function to detect if a request matches any pattern in an array +function matchesPattern(url, patterns) { + const urlObj = new URL(url); + const path = urlObj.pathname; + return patterns.some(pattern => pattern.test(path)); +} + +// Fetch event - handle different caching strategies based on request type +self.addEventListener('fetch', (event) => { + const url = event.request.url; + + // Skip cross-origin requests and non-GET requests + if (!url.startsWith(self.location.origin) || event.request.method !== 'GET') { + return; + } + + // WebSocket connections should bypass the service worker + if (url.startsWith('ws:') || url.startsWith('wss:')) { + return; + } + + // Skip any IndexedDB or database related requests + if (url.includes('indexeddb') || + url.includes('idb') || + url.includes('.db') || + url.includes('sqlite') || + url.includes('database')) { + return; + } + + // Skip any API calls that might be used for syncing + if (url.includes('/api/') || url.includes('/sync')) { + return; + } + + // STRATEGY 1: Cache-first for WASM files (they rarely change) + if (matchesPattern(url, CACHE_FIRST_PATTERNS)) { + event.respondWith(cacheFirstStrategy(event.request, WASM_CACHE)); + return; + } + + // STRATEGY 2: Network-first for API requests and other dynamic content + if (matchesPattern(url, NETWORK_FIRST_PATTERNS)) { + event.respondWith(networkFirstStrategy(event.request)); + return; + } + + // STRATEGY 3: Stale-while-revalidate for most static assets + if (matchesPattern(url, STALE_WHILE_REVALIDATE_PATTERNS)) { + event.respondWith(staleWhileRevalidateStrategy(event.request, DYNAMIC_CACHE)); + return; + } + + // STRATEGY 4: Default cache strategy for everything else (including navigation) + event.respondWith(defaultStrategy(event.request)); +}); + +// Cache-first strategy +async function cacheFirstStrategy(request, cacheName) { + // Skip caching for IndexedDB-related requests + if (request.url.includes('indexeddb') || request.url.includes('idb')) { + return fetch(request); + } + + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + console.log('[Service Worker] Serving from cache:', request.url); + return cachedResponse; + } + + try { + const networkResponse = await fetch(request); + // Cache the fresh response + if (networkResponse.ok) { + console.log('[Service Worker] Caching new resource:', request.url); + // Clone is needed because response body can only be read once + const clonedResponse = networkResponse.clone(); + + // Don't cache if the response contains IndexedDB-related content + const contentType = networkResponse.headers.get('Content-Type') || ''; + const shouldCache = !contentType.includes('indexeddb') && + !request.url.includes('indexeddb') && + !request.url.includes('idb'); + + if (shouldCache) { + cache.put(request, clonedResponse); + } + } + return networkResponse; + } catch (error) { + console.log('[Service Worker] Network error for:', request.url); + // Return offline fallback if it's a page navigation + if (request.mode === 'navigate') { + return caches.match('/offline.html'); + } + throw error; + } +} + +// Network-first strategy +async function networkFirstStrategy(request) { + try { + const networkResponse = await fetch(request); + // Don't cache API responses - they're usually dynamic + return networkResponse; + } catch (error) { + console.log('[Service Worker] Network error for API call:', request.url); + // Try to get from cache as fallback + const cache = await caches.open(DYNAMIC_CACHE); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + console.log('[Service Worker] Serving API from cache as fallback'); + return cachedResponse; + } + + // If it's a page navigation, show offline page + if (request.mode === 'navigate') { + return caches.match('/offline.html'); + } + + // Return error response for other cases + return new Response('Network error while offline', { + status: 503, + statusText: 'Service Unavailable' + }); + } +} + +// Stale-while-revalidate strategy +async function staleWhileRevalidateStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match(request); + + // Start network fetch without awaiting it + const fetchPromise = fetch(request).then(networkResponse => { + if (networkResponse.ok) { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + }).catch(error => { + console.log('[Service Worker] Network error while revalidating:', request.url); + throw error; + }); + + // Return cached response immediately if available + if (cachedResponse) { + console.log('[Service Worker] Returning cached while revalidating:', request.url); + return cachedResponse; + } + + // If no cached response, wait for network + try { + return await fetchPromise; + } catch (error) { + console.log('[Service Worker] Complete fetch failure:', request.url); + // If it's a page navigation, show offline page + if (request.mode === 'navigate') { + return caches.match('/offline.html'); + } + throw error; + } +} + +// Default strategy for navigation and other requests +async function defaultStrategy(request) { + // For navigation requests, try network first then fall back to cache + if (request.mode === 'navigate') { + try { + // Try network first for fresh content + const networkResponse = await fetch(request); + // Cache the response for future use + const cache = await caches.open(STATIC_CACHE); + cache.put(request, networkResponse.clone()); + return networkResponse; + } catch (error) { + // If network fails, try to serve from cache + const cache = await caches.open(STATIC_CACHE); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + // If no cached version, show offline page + return caches.match('/offline.html'); + } + } + + // For non-navigation requests, try cache first then network + const cache = await caches.open(STATIC_CACHE); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + return cachedResponse; + } + + try { + const networkResponse = await fetch(request); + // Cache successful responses + if (networkResponse.ok) { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + console.log('[Service Worker] Network error for other request:', request.url); + // For other requests, just fail if we can't get from network or cache + throw error; + } +} + +// Listen for messages from the client +self.addEventListener('message', (event) => { + // Handle skip waiting message to activate new service worker + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + // Notify all clients of the update + self.clients.matchAll().then(clients => { + clients.forEach(client => { + client.postMessage({ type: 'REFRESH_PAGE' }); + }); + }); + } + + // Handle cache clearing request + if (event.data && event.data.type === 'CLEAR_CACHES') { + self.caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + console.log('[Service Worker] Clearing cache:', cacheName); + return self.caches.delete(cacheName); + }) + ); + }).then(() => { + // Notify clients that caches are cleared + self.clients.matchAll().then(clients => { + clients.forEach(client => { + client.postMessage({ type: 'CACHES_CLEARED' }); + }); + }); + }); + } +}); + +// When a new service worker is activated +self.addEventListener('activate', event => { + event.waitUntil( + // Delete old caches + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames + .filter(name => { + // Check if this is one of our caches but not current version + return name.startsWith('mast-') && + !name.endsWith(CACHE_VERSION); + }) + .map(name => { + console.log('[Service Worker] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }).then(() => { + console.log('[Service Worker] Claiming clients'); + return self.clients.claim(); + }) + ); +}); \ No newline at end of file diff --git a/mast-react-vite/pwa-assets.config.js b/mast-react-vite/pwa-assets.config.js new file mode 100644 index 0000000..1d57532 --- /dev/null +++ b/mast-react-vite/pwa-assets.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from '@vite-pwa/assets-generator/config'; + +export default defineConfig({ + headLinkOptions: { + preset: '2023' + }, + preset: { + transparent: { + sizes: [64, 192, 512], + favicons: [[48, 'favicon.ico']], + }, + maskable: { + sizes: [512], + }, + apple: { + sizes: [180], + }, + }, + images: ['public/logo.svg'] +}); \ No newline at end of file diff --git a/mast-react-vite/src/components/ui/pwa-cache-reset.tsx b/mast-react-vite/src/components/ui/pwa-cache-reset.tsx new file mode 100644 index 0000000..f58780b --- /dev/null +++ b/mast-react-vite/src/components/ui/pwa-cache-reset.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; + +export function PWACacheReset() { + const [isResetting, setIsResetting] = useState(false); + const [message, setMessage] = useState(null); + + const handleResetCache = async () => { + if (!('serviceWorker' in navigator)) { + setMessage('Service workers not supported in this browser'); + return; + } + + try { + setIsResetting(true); + setMessage('Clearing caches...'); + + // Get all service worker registrations and unregister them + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map(registration => registration.unregister())); + + // Clear all caches + const keys = await caches.keys(); + await Promise.all(keys.map(key => caches.delete(key))); + + // Clear any relevant localStorage + localStorage.removeItem('pwaInstallPromptTime'); + + setMessage('Cache cleared successfully! Reloading page...'); + + // Give the user a moment to see the success message before reload + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + console.error('Failed to reset cache:', error); + setMessage(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); + setIsResetting(false); + } + }; + + return ( +
+ + + {message && ( +
+ {message} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/mast-react-vite/src/components/ui/pwa-install-prompt.tsx b/mast-react-vite/src/components/ui/pwa-install-prompt.tsx new file mode 100644 index 0000000..757d197 --- /dev/null +++ b/mast-react-vite/src/components/ui/pwa-install-prompt.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react'; +import { canInstallPwa, isPwaMode } from '@/lib/pwa-utils'; + +interface BeforeInstallPromptEvent extends Event { + readonly platforms: string[]; + readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed', platform: string }>; + prompt(): Promise; +} + +export function PWAInstallPrompt() { + const [installPromptEvent, setInstallPromptEvent] = useState(null); + const [showPrompt, setShowPrompt] = useState(false); + + useEffect(() => { + // Don't show install prompt if already in PWA mode + if (isPwaMode()) { + return; + } + + // Check if the user was previously prompted + const lastPromptTime = localStorage.getItem('pwaInstallPromptTime'); + const now = Date.now(); + const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds + + // Only show the prompt if they haven't been prompted in the last week + const shouldShowPrompt = !lastPromptTime || (now - Number(lastPromptTime)) > ONE_WEEK; + + if (!shouldShowPrompt) { + return; + } + + const handleBeforeInstallPrompt = (e: Event) => { + // Prevent Chrome 67 and earlier from automatically showing the prompt + e.preventDefault(); + // Save the event so it can be triggered later + setInstallPromptEvent(e as BeforeInstallPromptEvent); + // Show the install button + setShowPrompt(true); + }; + + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + + return () => { + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); + }; + }, []); + + const handleInstallClick = async () => { + if (!installPromptEvent) { + return; + } + + // Show the install prompt + await installPromptEvent.prompt(); + + // Wait for the user to respond to the prompt + const choiceResult = await installPromptEvent.userChoice; + + // Record the time of the prompt in localStorage + localStorage.setItem('pwaInstallPromptTime', Date.now().toString()); + + // Hide the install button regardless of the outcome + setShowPrompt(false); + setInstallPromptEvent(null); + + // Optionally track analytics for accepted/dismissed + if (choiceResult.outcome === 'accepted') { + console.log('User accepted the PWA installation'); + } else { + console.log('User dismissed the PWA installation'); + } + }; + + const handleDismiss = () => { + // Save the current time to prevent showing again too soon + localStorage.setItem('pwaInstallPromptTime', Date.now().toString()); + setShowPrompt(false); + }; + + if (!showPrompt || !canInstallPwa()) { + return null; + } + + return ( +
+
+
+
📱
+
+

Install Mast Todo

+

+ Install this app on your device for offline access and better performance. +

+
+
+
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/mast-react-vite/src/components/ui/pwa-update-notification.tsx b/mast-react-vite/src/components/ui/pwa-update-notification.tsx new file mode 100644 index 0000000..524d1fd --- /dev/null +++ b/mast-react-vite/src/components/ui/pwa-update-notification.tsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react'; +import { PWACacheReset } from './pwa-cache-reset'; + +export function PWAUpdateNotification() { + const [showUpdateNotification, setShowUpdateNotification] = useState(false); + const [showRefreshPrompt, setShowRefreshPrompt] = useState(false); + + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + + // Listen for service worker update messages + const handleMessage = (event: MessageEvent) => { + if (event.data && event.data.type === 'NEW_VERSION_AVAILABLE') { + setShowUpdateNotification(true); + } + + if (event.data && event.data.type === 'REFRESH_PAGE') { + setShowRefreshPrompt(true); + } + + if (event.data && event.data.type === 'CACHES_CLEARED') { + // Could show a notification that caches were cleared + window.location.reload(); + } + }; + + // Add the message listener + navigator.serviceWorker.addEventListener('message', handleMessage); + + // Setup event listeners for service worker updates + const setupUpdateDetection = async () => { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration) return; + + // Check if there's already a waiting service worker + if (registration.waiting) { + setShowUpdateNotification(true); + } + + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + if (!newWorker) return; + + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // New version installed and ready to take over + setShowUpdateNotification(true); + } + }); + }); + }; + + setupUpdateDetection().catch(console.error); + + return () => { + navigator.serviceWorker.removeEventListener('message', handleMessage); + }; + }, []); + + // Handle the update action when user clicks "Update Now" + const handleUpdate = () => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + // Send message to service worker to skip waiting + registration.waiting?.postMessage({ type: 'SKIP_WAITING' }); + + // Hide the notification + setShowUpdateNotification(false); + + // Note: We don't reload here as the service worker will send a REFRESH_PAGE message + }); + } + }; + + const handleClearCaches = () => { + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready.then(registration => { + if (registration.active) { + registration.active.postMessage({ type: 'CLEAR_CACHES' }); + } + }); + } + }; + + // If both notifications are hidden, don't render anything + if (!showUpdateNotification && !showRefreshPrompt) return null; + + return ( +
+ {showUpdateNotification && ( +
+
+
+

Update Available

+

+ A new version of Mast Todo is available. Update now for the latest features and improvements. +

+
+
+
+ + +
+ +
+ )} + + {showRefreshPrompt && ( +
+
+
+

Update Ready

+

+ The new version is ready. Please refresh the page to use the updated app. +

+
+
+
+ +
+
+
+ + Having issues? + +
+

If you're experiencing problems, try clearing the cache:

+ +
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/mast-react-vite/src/contexts/selection-context.tsx b/mast-react-vite/src/contexts/selection-context.tsx index be73350..749586d 100644 --- a/mast-react-vite/src/contexts/selection-context.tsx +++ b/mast-react-vite/src/contexts/selection-context.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useState, useEffect } from "react"; type SelectionContextType = { selectedItems: Set; @@ -8,14 +8,30 @@ type SelectionContextType = { getSelectionString: () => string; }; -const SelectionContext = createContext( - undefined, -); +// Create a default context value to avoid the "dispatcher is null" error +const defaultContextValue: SelectionContextType = { + selectedItems: new Set(), + toggleSelection: () => {}, + clearSelection: () => {}, + isSelected: () => false, + getSelectionString: () => "", +}; + +const SelectionContext = createContext(defaultContextValue); -export function SelectionProvider({ children }) { +export function SelectionProvider({ children }: { children: React.ReactNode }) { + // Initialize with an empty Set and use useEffect for any side effects const [selectedItems, setSelectedItems] = useState>(new Set()); + const [isInitialized, setIsInitialized] = useState(false); + + // Ensure the state is properly initialized when loaded from cache + useEffect(() => { + setIsInitialized(true); + }, []); const toggleSelection = (id: number) => { + if (!isInitialized) return; + setSelectedItems((prev) => { const next = new Set(prev); if (next.has(id)) { @@ -27,10 +43,18 @@ export function SelectionProvider({ children }) { }); }; - const clearSelection = () => setSelectedItems(new Set()); + const clearSelection = () => { + if (!isInitialized) return; + setSelectedItems(new Set()); + }; + const isSelected = (id: number) => selectedItems.has(id); - const getSelectionString = () => Array.from(selectedItems).join(","); + + const getSelectionString = () => { + return Array.from(selectedItems).join(","); + }; + // Only render the Provider when initialized to prevent rendering with incomplete state return ( { +export const useSelection = (): SelectionContextType => { const context = useContext(SelectionContext); - if (!context) - throw new Error("useSelection must be used within SelectionProvider"); - return context; + return context; // We can safely return context now as it always has a default value }; diff --git a/mast-react-vite/src/lib/pwa-utils.ts b/mast-react-vite/src/lib/pwa-utils.ts new file mode 100644 index 0000000..8f9052a --- /dev/null +++ b/mast-react-vite/src/lib/pwa-utils.ts @@ -0,0 +1,49 @@ +/** + * Check if the application is running in PWA (installed) mode + */ +export function isPwaMode(): boolean { + return ( + // Check if app is in standalone mode (installed PWA) + window.matchMedia('(display-mode: standalone)').matches || + // Check for iOS PWA mode + (window.navigator as any).standalone === true || + // Check if it's running in TWA (Trusted Web Activity) + document.referrer.includes('android-app://') || + // Additional check for some browsers + window.matchMedia('(display-mode: fullscreen)').matches || + window.matchMedia('(display-mode: minimal-ui)').matches + ); +} + +/** + * Check if the application can be installed as a PWA + */ +export function canInstallPwa(): boolean { + return ( + // Has service worker + 'serviceWorker' in navigator && + // Is HTTPS or localhost + (window.location.protocol === 'https:' || + window.location.hostname === 'localhost' || + window.location.hostname === '127.0.0.1') + ); +} + +/** + * Force refresh service worker registration + */ +export async function updateServiceWorker(): Promise { + if (!('serviceWorker' in navigator)) return false; + + try { + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + await registration.update(); + return true; + } + return false; + } catch (error) { + console.error('Failed to update service worker:', error); + return false; + } +} \ No newline at end of file diff --git a/mast-react-vite/src/main.tsx b/mast-react-vite/src/main.tsx index 79bd2ee..4c4dbce 100644 --- a/mast-react-vite/src/main.tsx +++ b/mast-react-vite/src/main.tsx @@ -131,3 +131,65 @@ const init = async () => { document.getElementById("root")!.classList.add("dark"); init(); +// Service Worker Registration - defer until after React hydration +const registerServiceWorker = () => { + if ('serviceWorker' in navigator) { + // Use requestIdleCallback or setTimeout to defer registration until after React is loaded + const registerSW = async () => { + try { + const registration = await navigator.serviceWorker.register('/service-worker.js'); + console.log('Service worker registered:', registration.scope); + + // Handle service worker updates + registration.addEventListener('updatefound', () => { + const newWorker = registration.installing; + if (newWorker) { + newWorker.addEventListener('statechange', () => { + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { + // New service worker available - sending a message to notify UI + console.log('New service worker available'); + // Broadcast to all window clients + if (registration.active) { + navigator.serviceWorker.controller?.postMessage({ + type: 'NEW_VERSION_AVAILABLE' + }); + } + } + }); + } + }); + } catch (error) { + console.error('Service worker registration failed:', error); + } + }; + + // Use requestIdleCallback for browsers that support it, otherwise setTimeout + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => { + // Wait a bit longer to ensure React is fully hydrated + setTimeout(registerSW, 1000); + }); + } else { + // For browsers without requestIdleCallback + setTimeout(registerSW, 2000); + } + + // Handle service worker updates when browser comes back online + window.addEventListener('online', async () => { + const registration = await navigator.serviceWorker.getRegistration(); + if (registration) { + registration.update().catch(err => { + console.error('Failed to update service worker:', err); + }); + } + }); + } +}; + +// Wait for document to be fully loaded before registering the service worker +if (document.readyState === 'complete') { + registerServiceWorker(); +} else { + window.addEventListener('load', registerServiceWorker); +} + -- 2.51.2