diff --git a/apps/frontend/project.json b/apps/frontend/project.json index ea4900a..6603e85 100644 --- a/apps/frontend/project.json +++ b/apps/frontend/project.json @@ -19,6 +19,21 @@ { "glob": "**/*", "input": "apps/frontend/public" + }, + { + "glob": "manifest.json", + "input": "apps/frontend/src", + "output": "/" + }, + { + "glob": "service-worker.js", + "input": "apps/frontend/src", + "output": "/" + }, + { + "glob": "offline.html", + "input": "apps/frontend/src", + "output": "/" } ], "styles": ["apps/frontend/src/styles.scss"] diff --git a/apps/frontend/src/app/app.html b/apps/frontend/src/app/app.html index f447414..864bcac 100644 --- a/apps/frontend/src/app/app.html +++ b/apps/frontend/src/app/app.html @@ -4,3 +4,4 @@ + diff --git a/apps/frontend/src/app/app.ts b/apps/frontend/src/app/app.ts index 7826492..2e4aea3 100644 --- a/apps/frontend/src/app/app.ts +++ b/apps/frontend/src/app/app.ts @@ -3,10 +3,12 @@ import { RouterModule } from '@angular/router'; import { HeaderComponent } from './components/header/header.component'; import { FooterComponent } from './components/footer/footer.component'; import { ToastComponent } from './components/toast/toast.component'; +import { PwaInstallComponent } from './components/pwa-install/pwa-install.component'; import { AnalyticsService } from './services/analytics.service'; +import { PwaService } from './services/pwa.service'; @Component({ - imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent], + imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent, PwaInstallComponent], selector: 'app-root', templateUrl: './app.html', styleUrl: './app.scss', @@ -14,8 +16,10 @@ import { AnalyticsService } from './services/analytics.service'; export class App implements OnInit { protected title = 'Naomi\'s Library'; private analytics = inject(AnalyticsService); + private pwa = inject(PwaService); ngOnInit(): void { this.analytics.initialise(); + // PWA service automatically initializes on construction } } diff --git a/apps/frontend/src/app/components/pwa-install/pwa-install.component.ts b/apps/frontend/src/app/components/pwa-install/pwa-install.component.ts new file mode 100644 index 0000000..1b70ba9 --- /dev/null +++ b/apps/frontend/src/app/components/pwa-install/pwa-install.component.ts @@ -0,0 +1,176 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Hikari + */ + +import { Component, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { PwaService } from '../../services/pwa.service'; + +@Component({ + selector: 'app-pwa-install', + standalone: true, + imports: [CommonModule], + template: ` + @if (pwaService.isInstallable() && !dismissed) { +
+
+
📱
+
+

Install Naomi's Library

+

Add to your home screen for quick access and offline support!

+
+
+
+ + +
+
+ } + `, + styles: [` + .install-banner { + position: fixed; + bottom: 1rem; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border: 2px solid #9d4edd; + border-radius: 12px; + padding: 1.5rem; + max-width: 500px; + width: calc(100% - 2rem); + box-shadow: 0 8px 32px rgba(157, 78, 221, 0.3); + z-index: 1000; + animation: slideUp 0.3s ease-out; + } + + @keyframes slideUp { + from { + transform: translateX(-50%) translateY(100px); + opacity: 0; + } + to { + transform: translateX(-50%) translateY(0); + opacity: 1; + } + } + + .install-content { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; + } + + .install-icon { + font-size: 2.5rem; + flex-shrink: 0; + } + + .install-text h3 { + margin: 0 0 0.25rem 0; + color: #9d4edd; + font-size: 1.1rem; + } + + .install-text p { + margin: 0; + color: #b0b0b0; + font-size: 0.9rem; + } + + .install-actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + } + + .btn-install, + .btn-dismiss { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + } + + .btn-install { + background: linear-gradient(135deg, #9d4edd 0%, #c77dff 100%); + color: white; + } + + .btn-install:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(157, 78, 221, 0.4); + } + + .btn-dismiss { + background: transparent; + color: #9d4edd; + border: 1px solid #9d4edd; + } + + .btn-dismiss:hover { + background: rgba(157, 78, 221, 0.1); + } + + @media (max-width: 600px) { + .install-banner { + bottom: 0; + left: 0; + right: 0; + transform: none; + max-width: none; + width: 100%; + border-radius: 12px 12px 0 0; + border-bottom: none; + } + + @keyframes slideUp { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } + + .install-content { + flex-direction: column; + text-align: center; + } + + .install-actions { + flex-direction: column-reverse; + } + + .btn-install, + .btn-dismiss { + width: 100%; + } + } + `] +}) +export class PwaInstallComponent { + protected pwaService = inject(PwaService); + protected dismissed = false; + + protected async install(): Promise { + const result = await this.pwaService.promptInstall(); + if (result) { + this.dismissed = true; + } + } + + protected dismiss(): void { + this.dismissed = true; + // Remember dismissal in session storage + sessionStorage.setItem('pwa-install-dismissed', 'true'); + } +} diff --git a/apps/frontend/src/app/services/pwa.service.ts b/apps/frontend/src/app/services/pwa.service.ts new file mode 100644 index 0000000..df24ae4 --- /dev/null +++ b/apps/frontend/src/app/services/pwa.service.ts @@ -0,0 +1,105 @@ +/** + * @copyright 2026 NHCarrigan + * @license Naomi's Public License + * @author Hikari + */ + +import { Injectable, signal } from '@angular/core'; + +@Injectable({ + providedIn: 'root' +}) +export class PwaService { + public promptEvent = signal(null); + public isInstallable = signal(false); + public isInstalled = signal(false); + + constructor() { + this.init(); + } + + private init(): void { + // Check if already installed + if (window.matchMedia('(display-mode: standalone)').matches) { + this.isInstalled.set(true); + } + + // Listen for beforeinstallprompt event + window.addEventListener('beforeinstallprompt', (event: Event) => { + event.preventDefault(); + this.promptEvent.set(event); + this.isInstallable.set(true); + }); + + // Listen for app installed event + window.addEventListener('appinstalled', () => { + this.isInstalled.set(true); + this.isInstallable.set(false); + this.promptEvent.set(null); + }); + + // Register service worker + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/service-worker.js') + .then((registration) => { + console.log('[PWA] Service Worker registered:', 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 + console.log('[PWA] New version available! Refresh to update.'); + // Optionally show a notification to the user + } + }); + } + }); + }) + .catch((error) => { + console.error('[PWA] Service Worker registration failed:', error); + }); + }); + } + } + + public async promptInstall(): Promise { + const event = this.promptEvent(); + if (!event) { + return false; + } + + // Show the install prompt + event.prompt(); + + // Wait for the user's response + const choiceResult = await event.userChoice; + + if (choiceResult.outcome === 'accepted') { + console.log('[PWA] User accepted the install prompt'); + this.promptEvent.set(null); + this.isInstallable.set(false); + return true; + } else { + console.log('[PWA] User dismissed the install prompt'); + return false; + } + } + + public clearCache(): void { + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ + type: 'CLEAR_CACHE' + }); + } + } +} diff --git a/apps/frontend/src/index.html b/apps/frontend/src/index.html index 41008e3..8d8b94b 100644 --- a/apps/frontend/src/index.html +++ b/apps/frontend/src/index.html @@ -5,7 +5,14 @@ Naomi's Library + + + + + + + + + diff --git a/apps/frontend/src/service-worker.js b/apps/frontend/src/service-worker.js new file mode 100644 index 0000000..9078e9e --- /dev/null +++ b/apps/frontend/src/service-worker.js @@ -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)) + ); + }) + ); + } +});