this repo has no description
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: PWA

+1012 -12
+2
.gitignore
··· 1 1 *.sqlite 2 + **/*.db 3 + mast-react-vite/tsconfig.* 2 4 mast
+13 -1
mast-react-vite/index.html
··· 4 4 <meta charset="UTF-8" /> 5 5 <link rel="icon" type="image/svg+xml" href="/vite.svg" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 7 - <title>Vite + React + TS</title> 7 + <title>Mast</title> 8 + <meta name="description" content="A local-first todo application with offline capabilities" /> 9 + <meta name="theme-color" content="#4f46e5" /> 10 + <link rel="manifest" href="/manifest.json" /> 11 + <link rel="apple-touch-icon" href="/icons/icon-192x192.png" /> 12 + <meta name="apple-mobile-web-app-capable" content="yes" /> 13 + <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> 14 + <meta name="apple-mobile-web-app-title" content="Mast Todo" /> 8 15 </head> 9 16 <body> 10 17 <div id="root"></div> 11 18 <script type="module" src="/src/main.tsx"></script> 19 + <noscript> 20 + <div style="padding: 2rem; text-align: center;"> 21 + This application requires JavaScript to run. 22 + </div> 23 + </noscript> 12 24 </body> 13 25 </html>
+1
mast-react-vite/public/icons/icon-192x192.png
··· 1 + binary_data_placeholder
+1
mast-react-vite/public/icons/icon-512x512.png
··· 1 + binary_data_placeholder
+5
mast-react-vite/public/logo.svg
··· 1 + <svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <rect width="512" height="512" rx="100" fill="#4F46E5"/> 3 + <path d="M256 128L384 256L256 384L128 256L256 128Z" fill="white"/> 4 + <circle cx="256" cy="256" r="48" fill="#4F46E5"/> 5 + </svg>
+23
mast-react-vite/public/manifest.json
··· 1 + { 2 + "name": "Mast Todo", 3 + "short_name": "Mast", 4 + "description": "A local-first todo application", 5 + "start_url": "/", 6 + "display": "standalone", 7 + "background_color": "#ffffff", 8 + "theme_color": "#4f46e5", 9 + "icons": [ 10 + { 11 + "src": "/icons/icon-192x192.png", 12 + "sizes": "192x192", 13 + "type": "image/png", 14 + "purpose": "any maskable" 15 + }, 16 + { 17 + "src": "/icons/icon-512x512.png", 18 + "sizes": "512x512", 19 + "type": "image/png", 20 + "purpose": "any maskable" 21 + } 22 + ] 23 + }
+128
mast-react-vite/public/offline.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en" class="dark"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Offline - Mast Todo</title> 7 + <style> 8 + :root { 9 + --background: #171717; 10 + --text-primary: #e5e5e5; 11 + --text-secondary: #a3a3a3; 12 + --accent: #4f46e5; 13 + --card-bg: #262626; 14 + --border: #404040; 15 + } 16 + 17 + html, body { 18 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 19 + height: 100%; 20 + width: 100%; 21 + margin: 0; 22 + padding: 0; 23 + background-color: var(--background); 24 + color: var(--text-primary); 25 + } 26 + 27 + .offline-container { 28 + display: flex; 29 + flex-direction: column; 30 + align-items: center; 31 + justify-content: center; 32 + height: 100%; 33 + padding: 20px; 34 + text-align: center; 35 + box-sizing: border-box; 36 + } 37 + 38 + .card { 39 + max-width: 500px; 40 + width: 100%; 41 + padding: 40px 30px; 42 + background-color: var(--card-bg); 43 + border-radius: 12px; 44 + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); 45 + border: 1px solid var(--border); 46 + } 47 + 48 + .icon { 49 + font-size: 64px; 50 + margin-bottom: 20px; 51 + color: var(--accent); 52 + } 53 + 54 + h1 { 55 + margin: 0 0 16px 0; 56 + font-size: 24px; 57 + font-weight: 600; 58 + } 59 + 60 + p { 61 + color: var(--text-secondary); 62 + margin: 0 0 24px 0; 63 + line-height: 1.6; 64 + font-size: 16px; 65 + } 66 + 67 + .message { 68 + padding: 12px 16px; 69 + background-color: rgba(79, 70, 229, 0.1); 70 + border-left: 4px solid var(--accent); 71 + border-radius: 4px; 72 + text-align: left; 73 + margin-bottom: 32px; 74 + } 75 + 76 + .button { 77 + display: inline-block; 78 + background-color: var(--accent); 79 + color: white; 80 + padding: 12px 24px; 81 + border-radius: 6px; 82 + text-decoration: none; 83 + font-weight: 500; 84 + border: none; 85 + cursor: pointer; 86 + font-size: 16px; 87 + transition: background-color 0.2s; 88 + } 89 + 90 + .button:hover { 91 + background-color: #4338ca; 92 + } 93 + </style> 94 + </head> 95 + <body> 96 + <div class="offline-container"> 97 + <div class="card"> 98 + <div class="icon">📶</div> 99 + <h1>You're Offline</h1> 100 + <p>Your todos are still available, but you'll need to reconnect to sync with other devices.</p> 101 + 102 + <div class="message"> 103 + <strong>Good news!</strong> Mast Todo is designed to work offline. 104 + All your changes will be saved locally and will sync when you're back online. 105 + </div> 106 + 107 + <button class="button" onclick="window.location.reload()">Try Again</button> 108 + </div> 109 + </div> 110 + 111 + <script> 112 + // Check for online status periodically 113 + function checkOnlineStatus() { 114 + if (navigator.onLine) { 115 + window.location.reload(); 116 + } 117 + } 118 + 119 + // Check every 10 seconds if we're back online 120 + setInterval(checkOnlineStatus, 10000); 121 + 122 + // Also check when online status changes 123 + window.addEventListener('online', () => { 124 + window.location.reload(); 125 + }); 126 + </script> 127 + </body> 128 + </html>
+348
mast-react-vite/public/service-worker.js
··· 1 + // Service worker for Mast Todo PWA 2 + 3 + // Cache names for different types of resources - update versions to force cache refresh 4 + const CACHE_VERSION = '2'; // Increment this when deploying updates 5 + const STATIC_CACHE = 'mast-static-v' + CACHE_VERSION; 6 + const WASM_CACHE = 'mast-wasm-v' + CACHE_VERSION; 7 + const DYNAMIC_CACHE = 'mast-dynamic-v' + CACHE_VERSION; 8 + 9 + // Core application shell assets (always loaded from cache first if available) 10 + const CORE_ASSETS = [ 11 + '/', 12 + '/index.html', 13 + '/offline.html', 14 + '/manifest.json', 15 + '/icons/icon-192x192.png', 16 + '/icons/icon-512x512.png' 17 + ]; 18 + 19 + // Assets that use the stale-while-revalidate strategy 20 + // (serve from cache while updating in background) 21 + const STALE_WHILE_REVALIDATE_PATTERNS = [ 22 + /\.css$/, 23 + /\.js$/, 24 + /\.json$/, 25 + /\.svg$/, 26 + /\.png$/, 27 + /\.jpg$/, 28 + /\.ico$/, 29 + ]; 30 + 31 + // Assets that use cache-first strategy (rarely change) 32 + const CACHE_FIRST_PATTERNS = [ 33 + /\.wasm$/, 34 + /crsqlite\.wasm/, 35 + /sql-wasm\.wasm/, 36 + /sql-wasm-debug\.wasm/, 37 + /assets\/index-.*\.js$/, // Vite bundles with content hash 38 + /assets\/.*\.chunk\.js$/ 39 + ]; 40 + 41 + // Network-first patterns (prefer fresh content) 42 + const NETWORK_FIRST_PATTERNS = [ 43 + /api\// 44 + ]; 45 + 46 + // Install event - cache core assets 47 + self.addEventListener('install', (event) => { 48 + event.waitUntil( 49 + caches.open(STATIC_CACHE).then((cache) => { 50 + console.log('[Service Worker] Caching core app shell'); 51 + return cache.addAll(CORE_ASSETS).then(() => { 52 + console.log('[Service Worker] Core assets cached'); 53 + self.skipWaiting(); 54 + }); 55 + }) 56 + ); 57 + }); 58 + 59 + // Activate event - clean up old caches and take control 60 + self.addEventListener('activate', (event) => { 61 + const currentCaches = [STATIC_CACHE, WASM_CACHE, DYNAMIC_CACHE]; 62 + 63 + event.waitUntil( 64 + caches.keys().then((cacheNames) => { 65 + return Promise.all( 66 + cacheNames 67 + .filter(cacheName => !currentCaches.includes(cacheName)) 68 + .map(cacheName => { 69 + console.log('[Service Worker] Deleting old cache:', cacheName); 70 + return caches.delete(cacheName); 71 + }) 72 + ); 73 + }).then(() => { 74 + console.log('[Service Worker] Claiming clients'); 75 + return self.clients.claim(); 76 + }) 77 + ); 78 + }); 79 + 80 + // Helper function to detect if a request matches any pattern in an array 81 + function matchesPattern(url, patterns) { 82 + const urlObj = new URL(url); 83 + const path = urlObj.pathname; 84 + return patterns.some(pattern => pattern.test(path)); 85 + } 86 + 87 + // Fetch event - handle different caching strategies based on request type 88 + self.addEventListener('fetch', (event) => { 89 + const url = event.request.url; 90 + 91 + // Skip cross-origin requests and non-GET requests 92 + if (!url.startsWith(self.location.origin) || event.request.method !== 'GET') { 93 + return; 94 + } 95 + 96 + // WebSocket connections should bypass the service worker 97 + if (url.startsWith('ws:') || url.startsWith('wss:')) { 98 + return; 99 + } 100 + 101 + // Skip any IndexedDB or database related requests 102 + if (url.includes('indexeddb') || 103 + url.includes('idb') || 104 + url.includes('.db') || 105 + url.includes('sqlite') || 106 + url.includes('database')) { 107 + return; 108 + } 109 + 110 + // Skip any API calls that might be used for syncing 111 + if (url.includes('/api/') || url.includes('/sync')) { 112 + return; 113 + } 114 + 115 + // STRATEGY 1: Cache-first for WASM files (they rarely change) 116 + if (matchesPattern(url, CACHE_FIRST_PATTERNS)) { 117 + event.respondWith(cacheFirstStrategy(event.request, WASM_CACHE)); 118 + return; 119 + } 120 + 121 + // STRATEGY 2: Network-first for API requests and other dynamic content 122 + if (matchesPattern(url, NETWORK_FIRST_PATTERNS)) { 123 + event.respondWith(networkFirstStrategy(event.request)); 124 + return; 125 + } 126 + 127 + // STRATEGY 3: Stale-while-revalidate for most static assets 128 + if (matchesPattern(url, STALE_WHILE_REVALIDATE_PATTERNS)) { 129 + event.respondWith(staleWhileRevalidateStrategy(event.request, DYNAMIC_CACHE)); 130 + return; 131 + } 132 + 133 + // STRATEGY 4: Default cache strategy for everything else (including navigation) 134 + event.respondWith(defaultStrategy(event.request)); 135 + }); 136 + 137 + // Cache-first strategy 138 + async function cacheFirstStrategy(request, cacheName) { 139 + // Skip caching for IndexedDB-related requests 140 + if (request.url.includes('indexeddb') || request.url.includes('idb')) { 141 + return fetch(request); 142 + } 143 + 144 + const cache = await caches.open(cacheName); 145 + const cachedResponse = await cache.match(request); 146 + 147 + if (cachedResponse) { 148 + console.log('[Service Worker] Serving from cache:', request.url); 149 + return cachedResponse; 150 + } 151 + 152 + try { 153 + const networkResponse = await fetch(request); 154 + // Cache the fresh response 155 + if (networkResponse.ok) { 156 + console.log('[Service Worker] Caching new resource:', request.url); 157 + // Clone is needed because response body can only be read once 158 + const clonedResponse = networkResponse.clone(); 159 + 160 + // Don't cache if the response contains IndexedDB-related content 161 + const contentType = networkResponse.headers.get('Content-Type') || ''; 162 + const shouldCache = !contentType.includes('indexeddb') && 163 + !request.url.includes('indexeddb') && 164 + !request.url.includes('idb'); 165 + 166 + if (shouldCache) { 167 + cache.put(request, clonedResponse); 168 + } 169 + } 170 + return networkResponse; 171 + } catch (error) { 172 + console.log('[Service Worker] Network error for:', request.url); 173 + // Return offline fallback if it's a page navigation 174 + if (request.mode === 'navigate') { 175 + return caches.match('/offline.html'); 176 + } 177 + throw error; 178 + } 179 + } 180 + 181 + // Network-first strategy 182 + async function networkFirstStrategy(request) { 183 + try { 184 + const networkResponse = await fetch(request); 185 + // Don't cache API responses - they're usually dynamic 186 + return networkResponse; 187 + } catch (error) { 188 + console.log('[Service Worker] Network error for API call:', request.url); 189 + // Try to get from cache as fallback 190 + const cache = await caches.open(DYNAMIC_CACHE); 191 + const cachedResponse = await cache.match(request); 192 + 193 + if (cachedResponse) { 194 + console.log('[Service Worker] Serving API from cache as fallback'); 195 + return cachedResponse; 196 + } 197 + 198 + // If it's a page navigation, show offline page 199 + if (request.mode === 'navigate') { 200 + return caches.match('/offline.html'); 201 + } 202 + 203 + // Return error response for other cases 204 + return new Response('Network error while offline', { 205 + status: 503, 206 + statusText: 'Service Unavailable' 207 + }); 208 + } 209 + } 210 + 211 + // Stale-while-revalidate strategy 212 + async function staleWhileRevalidateStrategy(request, cacheName) { 213 + const cache = await caches.open(cacheName); 214 + const cachedResponse = await cache.match(request); 215 + 216 + // Start network fetch without awaiting it 217 + const fetchPromise = fetch(request).then(networkResponse => { 218 + if (networkResponse.ok) { 219 + cache.put(request, networkResponse.clone()); 220 + } 221 + return networkResponse; 222 + }).catch(error => { 223 + console.log('[Service Worker] Network error while revalidating:', request.url); 224 + throw error; 225 + }); 226 + 227 + // Return cached response immediately if available 228 + if (cachedResponse) { 229 + console.log('[Service Worker] Returning cached while revalidating:', request.url); 230 + return cachedResponse; 231 + } 232 + 233 + // If no cached response, wait for network 234 + try { 235 + return await fetchPromise; 236 + } catch (error) { 237 + console.log('[Service Worker] Complete fetch failure:', request.url); 238 + // If it's a page navigation, show offline page 239 + if (request.mode === 'navigate') { 240 + return caches.match('/offline.html'); 241 + } 242 + throw error; 243 + } 244 + } 245 + 246 + // Default strategy for navigation and other requests 247 + async function defaultStrategy(request) { 248 + // For navigation requests, try network first then fall back to cache 249 + if (request.mode === 'navigate') { 250 + try { 251 + // Try network first for fresh content 252 + const networkResponse = await fetch(request); 253 + // Cache the response for future use 254 + const cache = await caches.open(STATIC_CACHE); 255 + cache.put(request, networkResponse.clone()); 256 + return networkResponse; 257 + } catch (error) { 258 + // If network fails, try to serve from cache 259 + const cache = await caches.open(STATIC_CACHE); 260 + const cachedResponse = await cache.match(request); 261 + 262 + if (cachedResponse) { 263 + return cachedResponse; 264 + } 265 + 266 + // If no cached version, show offline page 267 + return caches.match('/offline.html'); 268 + } 269 + } 270 + 271 + // For non-navigation requests, try cache first then network 272 + const cache = await caches.open(STATIC_CACHE); 273 + const cachedResponse = await cache.match(request); 274 + 275 + if (cachedResponse) { 276 + return cachedResponse; 277 + } 278 + 279 + try { 280 + const networkResponse = await fetch(request); 281 + // Cache successful responses 282 + if (networkResponse.ok) { 283 + cache.put(request, networkResponse.clone()); 284 + } 285 + return networkResponse; 286 + } catch (error) { 287 + console.log('[Service Worker] Network error for other request:', request.url); 288 + // For other requests, just fail if we can't get from network or cache 289 + throw error; 290 + } 291 + } 292 + 293 + // Listen for messages from the client 294 + self.addEventListener('message', (event) => { 295 + // Handle skip waiting message to activate new service worker 296 + if (event.data && event.data.type === 'SKIP_WAITING') { 297 + self.skipWaiting(); 298 + // Notify all clients of the update 299 + self.clients.matchAll().then(clients => { 300 + clients.forEach(client => { 301 + client.postMessage({ type: 'REFRESH_PAGE' }); 302 + }); 303 + }); 304 + } 305 + 306 + // Handle cache clearing request 307 + if (event.data && event.data.type === 'CLEAR_CACHES') { 308 + self.caches.keys().then(cacheNames => { 309 + return Promise.all( 310 + cacheNames.map(cacheName => { 311 + console.log('[Service Worker] Clearing cache:', cacheName); 312 + return self.caches.delete(cacheName); 313 + }) 314 + ); 315 + }).then(() => { 316 + // Notify clients that caches are cleared 317 + self.clients.matchAll().then(clients => { 318 + clients.forEach(client => { 319 + client.postMessage({ type: 'CACHES_CLEARED' }); 320 + }); 321 + }); 322 + }); 323 + } 324 + }); 325 + 326 + // When a new service worker is activated 327 + self.addEventListener('activate', event => { 328 + event.waitUntil( 329 + // Delete old caches 330 + caches.keys().then(cacheNames => { 331 + return Promise.all( 332 + cacheNames 333 + .filter(name => { 334 + // Check if this is one of our caches but not current version 335 + return name.startsWith('mast-') && 336 + !name.endsWith(CACHE_VERSION); 337 + }) 338 + .map(name => { 339 + console.log('[Service Worker] Deleting old cache:', name); 340 + return caches.delete(name); 341 + }) 342 + ); 343 + }).then(() => { 344 + console.log('[Service Worker] Claiming clients'); 345 + return self.clients.claim(); 346 + }) 347 + ); 348 + });
+20
mast-react-vite/pwa-assets.config.js
··· 1 + import { defineConfig } from '@vite-pwa/assets-generator/config'; 2 + 3 + export default defineConfig({ 4 + headLinkOptions: { 5 + preset: '2023' 6 + }, 7 + preset: { 8 + transparent: { 9 + sizes: [64, 192, 512], 10 + favicons: [[48, 'favicon.ico']], 11 + }, 12 + maskable: { 13 + sizes: [512], 14 + }, 15 + apple: { 16 + sizes: [180], 17 + }, 18 + }, 19 + images: ['public/logo.svg'] 20 + });
+58
mast-react-vite/src/components/ui/pwa-cache-reset.tsx
··· 1 + import { useState } from 'react'; 2 + 3 + export function PWACacheReset() { 4 + const [isResetting, setIsResetting] = useState(false); 5 + const [message, setMessage] = useState<string | null>(null); 6 + 7 + const handleResetCache = async () => { 8 + if (!('serviceWorker' in navigator)) { 9 + setMessage('Service workers not supported in this browser'); 10 + return; 11 + } 12 + 13 + try { 14 + setIsResetting(true); 15 + setMessage('Clearing caches...'); 16 + 17 + // Get all service worker registrations and unregister them 18 + const registrations = await navigator.serviceWorker.getRegistrations(); 19 + await Promise.all(registrations.map(registration => registration.unregister())); 20 + 21 + // Clear all caches 22 + const keys = await caches.keys(); 23 + await Promise.all(keys.map(key => caches.delete(key))); 24 + 25 + // Clear any relevant localStorage 26 + localStorage.removeItem('pwaInstallPromptTime'); 27 + 28 + setMessage('Cache cleared successfully! Reloading page...'); 29 + 30 + // Give the user a moment to see the success message before reload 31 + setTimeout(() => { 32 + window.location.reload(); 33 + }, 1500); 34 + } catch (error) { 35 + console.error('Failed to reset cache:', error); 36 + setMessage(`Error: ${error instanceof Error ? error.message : 'Unknown error'}`); 37 + setIsResetting(false); 38 + } 39 + }; 40 + 41 + return ( 42 + <div className="mt-4"> 43 + <button 44 + onClick={handleResetCache} 45 + disabled={isResetting} 46 + className="px-3 py-1.5 text-sm bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90 disabled:opacity-50" 47 + > 48 + {isResetting ? 'Resetting...' : 'Reset PWA Cache'} 49 + </button> 50 + 51 + {message && ( 52 + <div className="mt-2 text-sm text-muted-foreground"> 53 + {message} 54 + </div> 55 + )} 56 + </div> 57 + ); 58 + }
+113
mast-react-vite/src/components/ui/pwa-install-prompt.tsx
··· 1 + import { useState, useEffect } from 'react'; 2 + import { canInstallPwa, isPwaMode } from '@/lib/pwa-utils'; 3 + 4 + interface BeforeInstallPromptEvent extends Event { 5 + readonly platforms: string[]; 6 + readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed', platform: string }>; 7 + prompt(): Promise<void>; 8 + } 9 + 10 + export function PWAInstallPrompt() { 11 + const [installPromptEvent, setInstallPromptEvent] = useState<BeforeInstallPromptEvent | null>(null); 12 + const [showPrompt, setShowPrompt] = useState(false); 13 + 14 + useEffect(() => { 15 + // Don't show install prompt if already in PWA mode 16 + if (isPwaMode()) { 17 + return; 18 + } 19 + 20 + // Check if the user was previously prompted 21 + const lastPromptTime = localStorage.getItem('pwaInstallPromptTime'); 22 + const now = Date.now(); 23 + const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds 24 + 25 + // Only show the prompt if they haven't been prompted in the last week 26 + const shouldShowPrompt = !lastPromptTime || (now - Number(lastPromptTime)) > ONE_WEEK; 27 + 28 + if (!shouldShowPrompt) { 29 + return; 30 + } 31 + 32 + const handleBeforeInstallPrompt = (e: Event) => { 33 + // Prevent Chrome 67 and earlier from automatically showing the prompt 34 + e.preventDefault(); 35 + // Save the event so it can be triggered later 36 + setInstallPromptEvent(e as BeforeInstallPromptEvent); 37 + // Show the install button 38 + setShowPrompt(true); 39 + }; 40 + 41 + window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 42 + 43 + return () => { 44 + window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 45 + }; 46 + }, []); 47 + 48 + const handleInstallClick = async () => { 49 + if (!installPromptEvent) { 50 + return; 51 + } 52 + 53 + // Show the install prompt 54 + await installPromptEvent.prompt(); 55 + 56 + // Wait for the user to respond to the prompt 57 + const choiceResult = await installPromptEvent.userChoice; 58 + 59 + // Record the time of the prompt in localStorage 60 + localStorage.setItem('pwaInstallPromptTime', Date.now().toString()); 61 + 62 + // Hide the install button regardless of the outcome 63 + setShowPrompt(false); 64 + setInstallPromptEvent(null); 65 + 66 + // Optionally track analytics for accepted/dismissed 67 + if (choiceResult.outcome === 'accepted') { 68 + console.log('User accepted the PWA installation'); 69 + } else { 70 + console.log('User dismissed the PWA installation'); 71 + } 72 + }; 73 + 74 + const handleDismiss = () => { 75 + // Save the current time to prevent showing again too soon 76 + localStorage.setItem('pwaInstallPromptTime', Date.now().toString()); 77 + setShowPrompt(false); 78 + }; 79 + 80 + if (!showPrompt || !canInstallPwa()) { 81 + return null; 82 + } 83 + 84 + return ( 85 + <div className="fixed bottom-4 left-4 z-50 md:left-auto md:right-4"> 86 + <div className="bg-card text-card-foreground rounded-lg shadow-lg border border-border p-4 max-w-sm"> 87 + <div className="flex items-start space-x-4"> 88 + <div className="text-primary text-2xl">📱</div> 89 + <div> 90 + <h3 className="font-medium">Install Mast Todo</h3> 91 + <p className="text-sm text-muted-foreground mt-1"> 92 + Install this app on your device for offline access and better performance. 93 + </p> 94 + </div> 95 + </div> 96 + <div className="mt-4 flex justify-end space-x-2"> 97 + <button 98 + onClick={handleDismiss} 99 + className="px-3 py-1.5 text-sm bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/80" 100 + > 101 + Not now 102 + </button> 103 + <button 104 + onClick={handleInstallClick} 105 + className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90" 106 + > 107 + Install 108 + </button> 109 + </div> 110 + </div> 111 + </div> 112 + ); 113 + }
+156
mast-react-vite/src/components/ui/pwa-update-notification.tsx
··· 1 + import { useState, useEffect } from 'react'; 2 + import { PWACacheReset } from './pwa-cache-reset'; 3 + 4 + export function PWAUpdateNotification() { 5 + const [showUpdateNotification, setShowUpdateNotification] = useState(false); 6 + const [showRefreshPrompt, setShowRefreshPrompt] = useState(false); 7 + 8 + useEffect(() => { 9 + if (!('serviceWorker' in navigator)) return; 10 + 11 + // Listen for service worker update messages 12 + const handleMessage = (event: MessageEvent) => { 13 + if (event.data && event.data.type === 'NEW_VERSION_AVAILABLE') { 14 + setShowUpdateNotification(true); 15 + } 16 + 17 + if (event.data && event.data.type === 'REFRESH_PAGE') { 18 + setShowRefreshPrompt(true); 19 + } 20 + 21 + if (event.data && event.data.type === 'CACHES_CLEARED') { 22 + // Could show a notification that caches were cleared 23 + window.location.reload(); 24 + } 25 + }; 26 + 27 + // Add the message listener 28 + navigator.serviceWorker.addEventListener('message', handleMessage); 29 + 30 + // Setup event listeners for service worker updates 31 + const setupUpdateDetection = async () => { 32 + const registration = await navigator.serviceWorker.getRegistration(); 33 + if (!registration) return; 34 + 35 + // Check if there's already a waiting service worker 36 + if (registration.waiting) { 37 + setShowUpdateNotification(true); 38 + } 39 + 40 + registration.addEventListener('updatefound', () => { 41 + const newWorker = registration.installing; 42 + if (!newWorker) return; 43 + 44 + newWorker.addEventListener('statechange', () => { 45 + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { 46 + // New version installed and ready to take over 47 + setShowUpdateNotification(true); 48 + } 49 + }); 50 + }); 51 + }; 52 + 53 + setupUpdateDetection().catch(console.error); 54 + 55 + return () => { 56 + navigator.serviceWorker.removeEventListener('message', handleMessage); 57 + }; 58 + }, []); 59 + 60 + // Handle the update action when user clicks "Update Now" 61 + const handleUpdate = () => { 62 + if ('serviceWorker' in navigator) { 63 + navigator.serviceWorker.ready.then(registration => { 64 + // Send message to service worker to skip waiting 65 + registration.waiting?.postMessage({ type: 'SKIP_WAITING' }); 66 + 67 + // Hide the notification 68 + setShowUpdateNotification(false); 69 + 70 + // Note: We don't reload here as the service worker will send a REFRESH_PAGE message 71 + }); 72 + } 73 + }; 74 + 75 + const handleClearCaches = () => { 76 + if ('serviceWorker' in navigator) { 77 + navigator.serviceWorker.ready.then(registration => { 78 + if (registration.active) { 79 + registration.active.postMessage({ type: 'CLEAR_CACHES' }); 80 + } 81 + }); 82 + } 83 + }; 84 + 85 + // If both notifications are hidden, don't render anything 86 + if (!showUpdateNotification && !showRefreshPrompt) return null; 87 + 88 + return ( 89 + <div className="fixed bottom-4 right-4 z-50"> 90 + {showUpdateNotification && ( 91 + <div className="bg-card text-card-foreground rounded-lg shadow-lg border border-border p-4 max-w-sm"> 92 + <div className="flex items-start justify-between space-x-4"> 93 + <div> 94 + <h3 className="font-medium">Update Available</h3> 95 + <p className="text-sm text-muted-foreground mt-1"> 96 + A new version of Mast Todo is available. Update now for the latest features and improvements. 97 + </p> 98 + </div> 99 + </div> 100 + <div className="mt-4 flex justify-end space-x-2"> 101 + <button 102 + onClick={() => setShowUpdateNotification(false)} 103 + className="px-3 py-1.5 text-sm bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/80" 104 + > 105 + Later 106 + </button> 107 + <button 108 + onClick={handleUpdate} 109 + className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90" 110 + > 111 + Update Now 112 + </button> 113 + </div> 114 + <PWACacheReset /> 115 + </div> 116 + )} 117 + 118 + {showRefreshPrompt && ( 119 + <div className="bg-card text-card-foreground rounded-lg shadow-lg border border-border p-4 max-w-sm"> 120 + <div className="flex items-start justify-between space-x-4"> 121 + <div> 122 + <h3 className="font-medium">Update Ready</h3> 123 + <p className="text-sm text-muted-foreground mt-1"> 124 + The new version is ready. Please refresh the page to use the updated app. 125 + </p> 126 + </div> 127 + </div> 128 + <div className="mt-4 flex justify-end space-x-2"> 129 + <button 130 + onClick={() => window.location.reload()} 131 + className="px-3 py-1.5 text-sm bg-primary text-primary-foreground rounded-md hover:bg-primary/90" 132 + > 133 + Refresh Now 134 + </button> 135 + </div> 136 + <div className="mt-2"> 137 + <details className="text-sm"> 138 + <summary className="cursor-pointer text-muted-foreground hover:text-foreground"> 139 + Having issues? 140 + </summary> 141 + <div className="mt-2"> 142 + <p className="mb-2 text-muted-foreground">If you're experiencing problems, try clearing the cache:</p> 143 + <button 144 + onClick={handleClearCaches} 145 + className="px-3 py-1.5 text-sm bg-destructive text-destructive-foreground rounded-md hover:bg-destructive/90" 146 + > 147 + Clear All Caches 148 + </button> 149 + </div> 150 + </details> 151 + </div> 152 + </div> 153 + )} 154 + </div> 155 + ); 156 + }
+33 -11
mast-react-vite/src/contexts/selection-context.tsx
··· 1 - import { createContext, useContext, useState } from "react"; 1 + import { createContext, useContext, useState, useEffect } from "react"; 2 2 3 3 type SelectionContextType = { 4 4 selectedItems: Set<number>; ··· 8 8 getSelectionString: () => string; 9 9 }; 10 10 11 - const SelectionContext = createContext<SelectionContextType | undefined>( 12 - undefined, 13 - ); 11 + // Create a default context value to avoid the "dispatcher is null" error 12 + const defaultContextValue: SelectionContextType = { 13 + selectedItems: new Set<number>(), 14 + toggleSelection: () => {}, 15 + clearSelection: () => {}, 16 + isSelected: () => false, 17 + getSelectionString: () => "", 18 + }; 14 19 15 - export function SelectionProvider({ children }) { 20 + const SelectionContext = createContext<SelectionContextType>(defaultContextValue); 21 + 22 + export function SelectionProvider({ children }: { children: React.ReactNode }) { 23 + // Initialize with an empty Set and use useEffect for any side effects 16 24 const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set()); 25 + const [isInitialized, setIsInitialized] = useState(false); 26 + 27 + // Ensure the state is properly initialized when loaded from cache 28 + useEffect(() => { 29 + setIsInitialized(true); 30 + }, []); 17 31 18 32 const toggleSelection = (id: number) => { 33 + if (!isInitialized) return; 34 + 19 35 setSelectedItems((prev) => { 20 36 const next = new Set(prev); 21 37 if (next.has(id)) { ··· 27 43 }); 28 44 }; 29 45 30 - const clearSelection = () => setSelectedItems(new Set()); 46 + const clearSelection = () => { 47 + if (!isInitialized) return; 48 + setSelectedItems(new Set()); 49 + }; 50 + 31 51 const isSelected = (id: number) => selectedItems.has(id); 32 - const getSelectionString = () => Array.from(selectedItems).join(","); 52 + 53 + const getSelectionString = () => { 54 + return Array.from(selectedItems).join(","); 55 + }; 33 56 57 + // Only render the Provider when initialized to prevent rendering with incomplete state 34 58 return ( 35 59 <SelectionContext.Provider 36 60 value={{ ··· 46 70 ); 47 71 } 48 72 49 - export const useSelection = () => { 73 + export const useSelection = (): SelectionContextType => { 50 74 const context = useContext(SelectionContext); 51 - if (!context) 52 - throw new Error("useSelection must be used within SelectionProvider"); 53 - return context; 75 + return context; // We can safely return context now as it always has a default value 54 76 };
+49
mast-react-vite/src/lib/pwa-utils.ts
··· 1 + /** 2 + * Check if the application is running in PWA (installed) mode 3 + */ 4 + export function isPwaMode(): boolean { 5 + return ( 6 + // Check if app is in standalone mode (installed PWA) 7 + window.matchMedia('(display-mode: standalone)').matches || 8 + // Check for iOS PWA mode 9 + (window.navigator as any).standalone === true || 10 + // Check if it's running in TWA (Trusted Web Activity) 11 + document.referrer.includes('android-app://') || 12 + // Additional check for some browsers 13 + window.matchMedia('(display-mode: fullscreen)').matches || 14 + window.matchMedia('(display-mode: minimal-ui)').matches 15 + ); 16 + } 17 + 18 + /** 19 + * Check if the application can be installed as a PWA 20 + */ 21 + export function canInstallPwa(): boolean { 22 + return ( 23 + // Has service worker 24 + 'serviceWorker' in navigator && 25 + // Is HTTPS or localhost 26 + (window.location.protocol === 'https:' || 27 + window.location.hostname === 'localhost' || 28 + window.location.hostname === '127.0.0.1') 29 + ); 30 + } 31 + 32 + /** 33 + * Force refresh service worker registration 34 + */ 35 + export async function updateServiceWorker(): Promise<boolean> { 36 + if (!('serviceWorker' in navigator)) return false; 37 + 38 + try { 39 + const registration = await navigator.serviceWorker.getRegistration(); 40 + if (registration) { 41 + await registration.update(); 42 + return true; 43 + } 44 + return false; 45 + } catch (error) { 46 + console.error('Failed to update service worker:', error); 47 + return false; 48 + } 49 + }
+62
mast-react-vite/src/main.tsx
··· 131 131 document.getElementById("root")!.classList.add("dark"); 132 132 init(); 133 133 134 + // Service Worker Registration - defer until after React hydration 135 + const registerServiceWorker = () => { 136 + if ('serviceWorker' in navigator) { 137 + // Use requestIdleCallback or setTimeout to defer registration until after React is loaded 138 + const registerSW = async () => { 139 + try { 140 + const registration = await navigator.serviceWorker.register('/service-worker.js'); 141 + console.log('Service worker registered:', registration.scope); 142 + 143 + // Handle service worker updates 144 + registration.addEventListener('updatefound', () => { 145 + const newWorker = registration.installing; 146 + if (newWorker) { 147 + newWorker.addEventListener('statechange', () => { 148 + if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { 149 + // New service worker available - sending a message to notify UI 150 + console.log('New service worker available'); 151 + // Broadcast to all window clients 152 + if (registration.active) { 153 + navigator.serviceWorker.controller?.postMessage({ 154 + type: 'NEW_VERSION_AVAILABLE' 155 + }); 156 + } 157 + } 158 + }); 159 + } 160 + }); 161 + } catch (error) { 162 + console.error('Service worker registration failed:', error); 163 + } 164 + }; 165 + 166 + // Use requestIdleCallback for browsers that support it, otherwise setTimeout 167 + if ('requestIdleCallback' in window) { 168 + window.requestIdleCallback(() => { 169 + // Wait a bit longer to ensure React is fully hydrated 170 + setTimeout(registerSW, 1000); 171 + }); 172 + } else { 173 + // For browsers without requestIdleCallback 174 + setTimeout(registerSW, 2000); 175 + } 176 + 177 + // Handle service worker updates when browser comes back online 178 + window.addEventListener('online', async () => { 179 + const registration = await navigator.serviceWorker.getRegistration(); 180 + if (registration) { 181 + registration.update().catch(err => { 182 + console.error('Failed to update service worker:', err); 183 + }); 184 + } 185 + }); 186 + } 187 + }; 188 + 189 + // Wait for document to be fully loaded before registering the service worker 190 + if (document.readyState === 'complete') { 191 + registerServiceWorker(); 192 + } else { 193 + window.addEventListener('load', registerServiceWorker); 194 + } 195 +