feat: Set up PWA infrastructure with service worker, offline support, and manifest
- Created service worker (public/sw.js) with caching strategies - Cache-first strategy for static assets - Stale-while-revalidate for API responses and pages - Network-first fallback strategy - Implemented offline fallback page (/offline) - Updated manifest.json with complete PWA metadata - Added service worker registration in app layout - Created ServiceWorkerRegistration component for client-side registration - Generated SVG icons for PWA (192x192 and 512x512) - Added PWA meta tags for iOS Safari support Validates Requirements: 4.1, 4.3, 4.4
This commit is contained in:
@@ -151,7 +151,7 @@
|
||||
- [x] 10. Checkpoint - Ensure all tests pass
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 11. Set up PWA infrastructure
|
||||
- [x] 11. Set up PWA infrastructure
|
||||
- Create service worker (public/sw.js) with caching strategies
|
||||
- Implement cache-first strategy for static assets
|
||||
- Implement stale-while-revalidate for API responses
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import ServiceWorkerRegistration from '@/components/ServiceWorkerRegistration'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Ultimate Lists',
|
||||
description: 'The ultimate todo list app',
|
||||
description: 'The ultimate todo list app with advanced task management',
|
||||
manifest: '/manifest.json',
|
||||
themeColor: '#3B82F6',
|
||||
viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no',
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'Ultimate Lists',
|
||||
},
|
||||
applicationName: 'Ultimate Lists',
|
||||
icons: {
|
||||
icon: '/icon-192.svg',
|
||||
apple: '/icon-192.svg',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -16,7 +27,17 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gray-50 min-h-screen">{children}</body>
|
||||
<head>
|
||||
<link rel="apple-touch-icon" href="/icon-192.svg" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ultimate Lists" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
</head>
|
||||
<body className="bg-gray-50 min-h-screen">
|
||||
<ServiceWorkerRegistration />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
50
app/offline/page.tsx
Normal file
50
app/offline/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { WifiOff } from 'lucide-react';
|
||||
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="bg-blue-100 p-4 rounded-full">
|
||||
<WifiOff className="w-12 h-12 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
You're Offline
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8">
|
||||
It looks like you've lost your internet connection. Don't worry, your data is safe and will sync when you're back online.
|
||||
</p>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 text-left">
|
||||
<h2 className="font-semibold text-gray-900 mb-3">What you can do:</h2>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-blue-600 mr-2">•</span>
|
||||
<span>View your cached lists and tasks</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-blue-600 mr-2">•</span>
|
||||
<span>Make changes that will sync later</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-blue-600 mr-2">•</span>
|
||||
<span>Check your connection and try again</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-8 bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
components/ServiceWorkerRegistration.tsx
Normal file
44
components/ServiceWorkerRegistration.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Register service worker
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered with scope:', registration.scope);
|
||||
|
||||
// Check for updates periodically
|
||||
setInterval(() => {
|
||||
registration.update();
|
||||
}, 60000); // Check every minute
|
||||
|
||||
// Listen for updates
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
// New service worker available, could show update notification
|
||||
console.log('New service worker available');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
});
|
||||
|
||||
// Listen for controller change (new service worker activated)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('Service Worker controller changed');
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return null; // This component doesn't render anything
|
||||
}
|
||||
7
public/icon-192.svg
Normal file
7
public/icon-192.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="192" height="192" viewBox="0 0 192 192" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="192" height="192" rx="48" fill="#3B82F6"/>
|
||||
<path d="M57.599999999999994 96L86.4 124.80000000000001L134.39999999999998 67.19999999999999"
|
||||
stroke="white" stroke-width="15.36" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="48" y="115.19999999999999" width="96" height="15.36" rx="3.84" fill="white"/>
|
||||
<rect x="48" y="138.24" width="76.80000000000001" height="15.36" rx="3.84" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 523 B |
7
public/icon-512.svg
Normal file
7
public/icon-512.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="128" fill="#3B82F6"/>
|
||||
<path d="M153.6 256L230.4 332.8L358.4 179.2"
|
||||
stroke="white" stroke-width="40.96" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="128" y="307.2" width="256" height="40.96" rx="10.24" fill="white"/>
|
||||
<rect x="128" y="368.64" width="204.8" height="40.96" rx="10.24" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 455 B |
6
public/icon.svg
Normal file
6
public/icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="512" height="512" rx="128" fill="#3B82F6"/>
|
||||
<path d="M160 200L220 260L360 120" stroke="white" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="120" y="300" width="272" height="40" rx="8" fill="white"/>
|
||||
<rect x="120" y="360" width="200" height="40" rx="8" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 425 B |
@@ -1,21 +1,37 @@
|
||||
{
|
||||
"name": "Ultimate Lists",
|
||||
"short_name": "Lists",
|
||||
"description": "The ultimate todo list app",
|
||||
"description": "The ultimate todo list app with advanced task management",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#3B82F6",
|
||||
"categories": ["productivity", "utilities"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"src": "/icon-192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"src": "/icon-512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
],
|
||||
"screenshots": [],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "New List",
|
||||
"short_name": "New List",
|
||||
"description": "Create a new list",
|
||||
"url": "/?action=new-list",
|
||||
"icons": []
|
||||
}
|
||||
],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
164
public/sw.js
Normal file
164
public/sw.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// Service Worker for Ultimate Lists App
|
||||
// Implements caching strategies for offline functionality
|
||||
|
||||
const CACHE_NAME = 'ultimate-lists-v1';
|
||||
const RUNTIME_CACHE = 'ultimate-lists-runtime-v1';
|
||||
|
||||
// Static assets to cache on install
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/offline',
|
||||
'/manifest.json',
|
||||
'/icon-192.svg',
|
||||
'/icon-512.svg',
|
||||
];
|
||||
|
||||
// Install event - cache static assets
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(STATIC_ASSETS).catch((error) => {
|
||||
console.error('Failed to cache static assets:', error);
|
||||
// Continue even if some assets fail to cache
|
||||
return Promise.resolve();
|
||||
});
|
||||
})
|
||||
);
|
||||
// Activate immediately
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
// Activate event - clean up old caches
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter((name) => name !== CACHE_NAME && name !== RUNTIME_CACHE)
|
||||
.map((name) => caches.delete(name))
|
||||
);
|
||||
})
|
||||
);
|
||||
// Take control of all pages immediately
|
||||
return self.clients.claim();
|
||||
});
|
||||
|
||||
// Fetch event - implement caching strategies
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Skip non-GET requests
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip chrome extensions and other protocols
|
||||
if (!url.protocol.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache-first strategy for static assets
|
||||
if (isStaticAsset(url)) {
|
||||
event.respondWith(cacheFirst(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Stale-while-revalidate for API responses and pages
|
||||
if (isApiRequest(url) || isPageRequest(url)) {
|
||||
event.respondWith(staleWhileRevalidate(request));
|
||||
return;
|
||||
}
|
||||
|
||||
// Network-first for everything else
|
||||
event.respondWith(networkFirst(request));
|
||||
});
|
||||
|
||||
// Helper: Check if request is for a static asset
|
||||
function isStaticAsset(url) {
|
||||
const staticExtensions = ['.js', '.css', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2'];
|
||||
return staticExtensions.some((ext) => url.pathname.endsWith(ext));
|
||||
}
|
||||
|
||||
// Helper: Check if request is an API request
|
||||
function isApiRequest(url) {
|
||||
return url.pathname.startsWith('/api/');
|
||||
}
|
||||
|
||||
// Helper: Check if request is a page request
|
||||
function isPageRequest(url) {
|
||||
return url.origin === self.location.origin && !url.pathname.includes('.');
|
||||
}
|
||||
|
||||
// Strategy: Cache-first (for static assets)
|
||||
async function cacheFirst(request) {
|
||||
const cache = await caches.open(CACHE_NAME);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Cache-first fetch failed:', error);
|
||||
return new Response('Network error', { status: 408 });
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy: Stale-while-revalidate (for API and pages)
|
||||
async function staleWhileRevalidate(request) {
|
||||
const cache = await caches.open(RUNTIME_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
const fetchPromise = fetch(request).then((response) => {
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
}).catch((error) => {
|
||||
console.error('Stale-while-revalidate fetch failed:', error);
|
||||
// If we have a cached version, return it on network error
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
// Otherwise, return offline page for navigation requests
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/offline');
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return cached || fetchPromise;
|
||||
}
|
||||
|
||||
// Strategy: Network-first (fallback)
|
||||
async function networkFirst(request) {
|
||||
try {
|
||||
const response = await fetch(request);
|
||||
if (response.ok) {
|
||||
const cache = await caches.open(RUNTIME_CACHE);
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
const cache = await caches.open(RUNTIME_CACHE);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Return offline page for navigation requests
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match('/offline');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
45
scripts/generate-icons.js
Normal file
45
scripts/generate-icons.js
Normal file
@@ -0,0 +1,45 @@
|
||||
// Icon generation script
|
||||
// This script creates placeholder icon files for the PWA
|
||||
// In production, you should replace these with proper high-quality icons
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Create a simple data URL for a blue icon with a checkmark
|
||||
function createIconDataURL(size) {
|
||||
// Simple SVG icon
|
||||
const svg = `
|
||||
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" rx="${size * 0.25}" fill="#3B82F6"/>
|
||||
<path d="M${size * 0.3} ${size * 0.5}L${size * 0.45} ${size * 0.65}L${size * 0.7} ${size * 0.35}"
|
||||
stroke="white" stroke-width="${size * 0.08}" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`;
|
||||
}
|
||||
|
||||
// For now, we'll create SVG files that browsers can use
|
||||
function createSVGIcon(size, outputPath) {
|
||||
const svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" rx="${size * 0.25}" fill="#3B82F6"/>
|
||||
<path d="M${size * 0.3} ${size * 0.5}L${size * 0.45} ${size * 0.65}L${size * 0.7} ${size * 0.35}"
|
||||
stroke="white" stroke-width="${size * 0.08}" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<rect x="${size * 0.25}" y="${size * 0.6}" width="${size * 0.5}" height="${size * 0.08}" rx="${size * 0.02}" fill="white"/>
|
||||
<rect x="${size * 0.25}" y="${size * 0.72}" width="${size * 0.4}" height="${size * 0.08}" rx="${size * 0.02}" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
fs.writeFileSync(outputPath, svg);
|
||||
console.log(`Created ${outputPath}`);
|
||||
}
|
||||
|
||||
// Create icons
|
||||
const publicDir = path.join(__dirname, '..', 'public');
|
||||
|
||||
createSVGIcon(192, path.join(publicDir, 'icon-192.svg'));
|
||||
createSVGIcon(512, path.join(publicDir, 'icon-512.svg'));
|
||||
|
||||
console.log('\nNote: SVG icons created. For production, convert these to PNG using:');
|
||||
console.log(' - Online tools like https://cloudconvert.com/svg-to-png');
|
||||
console.log(' - ImageMagick: convert icon-192.svg icon-192.png');
|
||||
console.log(' - Or use a design tool like Figma/Sketch to export PNG versions');
|
||||
Reference in New Issue
Block a user