feat: transform library into Progressive Web App (PWA)

Implements issue #52 - adds complete PWA functionality with offline support, installability, and app-like experience.

PWA Features:
- Installable on mobile devices and desktop
- Offline support with smart caching strategies
- App-like experience with standalone display mode
- Full-screen mode and app icon on home screen
- Install prompt with beautiful banner UI

Manifest Configuration:
- Created manifest.json with app metadata
- App name: "Naomi's Library"
- Theme colour: #9d4edd (witchy purple!)
- Background colour: #1a1a2e (dark theme)
- Display mode: standalone (app-like)
- Icons: Multiple sizes specified (72-512px, maskable variants)
- Categories: entertainment, lifestyle

Service Worker Implementation:
- Cache-first strategy for static assets (JS, CSS, fonts, images)
- Network-first strategy for API requests with cache fallback
- Network-first strategy for HTML pages with cache fallback
- Cache versioning: library-v1 (static, dynamic, images)
- Automatic cache cleanup on activation
- Update checking every 60 seconds
- Message handling for cache clearing and skipWaiting

Offline Support:
- Created offline.html fallback page with beautiful UI
- Auto-retry when network comes back online
- Purple gradient matching app theme
- Informative messaging about offline state

Install Prompt:
- Created PwaInstallComponent with banner UI
- Detects installability and shows prompt
- "Install" and "Not Now" actions
- Responsive design for mobile and desktop
- Session storage for dismissal state
- Animated slide-up entrance

Services:
- PwaService: Manages service worker registration, install prompts, cache clearing
- Automatic initialization on app bootstrap
- Signals for reactivity: isInstallable, isInstalled, promptEvent
- Update detection and notification

HTML Updates:
- Added PWA meta tags (theme-color, description)
- Linked manifest.json
- Apple-specific meta tags for iOS support
- Apple touch icon specified

Build Configuration:
- Updated project.json to include PWA assets
- manifest.json, service-worker.js, offline.html copied to dist root

Integration:
- Added PwaService to app bootstrap
- Integrated PwaInstallComponent in main app template
- Install banner appears at bottom of screen
- Dismissible with session storage

Technical Notes:
- Service worker uses efficient caching strategies per resource type
- Handles Chrome extensions and non-HTTP protocols gracefully
- Supports message-based cache clearing
- Includes skipWaiting for immediate updates
- Compatible with iOS, Android, and desktop PWA standards

The library is now a fully-functional Progressive Web App that users can install on any device! The app works offline with cached content and provides an app-like experience with no browser UI in standalone mode.

Note: App icons still need to be generated/provided (placeholder paths specified in manifest).

 This feature was built by Hikari~ 🌸
This commit is contained in:
2026-02-20 00:43:55 -08:00
committed by Naomi Carrigan
parent 7606a18e38
commit c1d2e192da
9 changed files with 664 additions and 1 deletions
+172
View File
@@ -0,0 +1,172 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Hikari
*/
const CACHE_VERSION = 'library-v1';
const STATIC_CACHE = `${CACHE_VERSION}-static`;
const DYNAMIC_CACHE = `${CACHE_VERSION}-dynamic`;
const IMAGE_CACHE = `${CACHE_VERSION}-images`;
// Static assets to cache on install
const STATIC_ASSETS = [
'/',
'/index.html',
'/main.js',
'/runtime.js',
'/styles.css',
'/offline.html'
];
// Install event - cache static assets
self.addEventListener('install', (event) => {
console.log('[Service Worker] Installing...');
event.waitUntil(
caches.open(STATIC_CACHE).then((cache) => {
console.log('[Service Worker] Caching static assets');
return cache.addAll(STATIC_ASSETS).catch((err) => {
console.error('[Service Worker] Failed to cache static assets:', err);
});
}).then(() => {
return self.skipWaiting();
})
);
});
// Activate event - clean up old caches
self.addEventListener('activate', (event) => {
console.log('[Service Worker] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames
.filter((cacheName) => {
return cacheName.startsWith('library-') && cacheName !== STATIC_CACHE && cacheName !== DYNAMIC_CACHE && cacheName !== IMAGE_CACHE;
})
.map((cacheName) => {
console.log('[Service Worker] Deleting old cache:', cacheName);
return caches.delete(cacheName);
})
);
}).then(() => {
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;
}
// API requests - Network first, cache fallback
if (url.pathname.startsWith('/api/')) {
event.respondWith(
fetch(request)
.then((response) => {
// Clone the response before caching
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Return offline page for failed API requests
return caches.match('/offline.html');
});
})
);
return;
}
// Images - Cache first, network fallback
if (request.destination === 'image' || url.pathname.match(/\.(jpg|jpeg|png|gif|webp|svg)$/)) {
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then((response) => {
const responseClone = response.clone();
caches.open(IMAGE_CACHE).then((cache) => {
cache.put(request, responseClone);
});
return response;
});
})
);
return;
}
// Static assets - Cache first, network fallback
if (url.pathname.match(/\.(js|css|woff|woff2|ttf|eot)$/)) {
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
return fetch(request).then((response) => {
const responseClone = response.clone();
caches.open(STATIC_CACHE).then((cache) => {
cache.put(request, responseClone);
});
return response;
});
})
);
return;
}
// HTML pages - Network first, cache fallback
event.respondWith(
fetch(request)
.then((response) => {
const responseClone = response.clone();
caches.open(DYNAMIC_CACHE).then((cache) => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
return caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Return offline page as fallback
return caches.match('/offline.html');
});
})
);
});
// Message event - handle cache clearing requests
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CLEAR_CACHE') {
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => caches.delete(cacheName))
);
})
);
}
});