generated from nhcarrigan/template
feat: Multiple Features, Accessibility, Security, and UX Improvements #59
@@ -19,6 +19,21 @@
|
|||||||
{
|
{
|
||||||
"glob": "**/*",
|
"glob": "**/*",
|
||||||
"input": "apps/frontend/public"
|
"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"]
|
"styles": ["apps/frontend/src/styles.scss"]
|
||||||
|
|||||||
@@ -4,3 +4,4 @@
|
|||||||
</main>
|
</main>
|
||||||
<app-footer></app-footer>
|
<app-footer></app-footer>
|
||||||
<app-toast></app-toast>
|
<app-toast></app-toast>
|
||||||
|
<app-pwa-install></app-pwa-install>
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { RouterModule } from '@angular/router';
|
|||||||
import { HeaderComponent } from './components/header/header.component';
|
import { HeaderComponent } from './components/header/header.component';
|
||||||
import { FooterComponent } from './components/footer/footer.component';
|
import { FooterComponent } from './components/footer/footer.component';
|
||||||
import { ToastComponent } from './components/toast/toast.component';
|
import { ToastComponent } from './components/toast/toast.component';
|
||||||
|
import { PwaInstallComponent } from './components/pwa-install/pwa-install.component';
|
||||||
import { AnalyticsService } from './services/analytics.service';
|
import { AnalyticsService } from './services/analytics.service';
|
||||||
|
import { PwaService } from './services/pwa.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent],
|
imports: [RouterModule, HeaderComponent, FooterComponent, ToastComponent, PwaInstallComponent],
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
@@ -14,8 +16,10 @@ import { AnalyticsService } from './services/analytics.service';
|
|||||||
export class App implements OnInit {
|
export class App implements OnInit {
|
||||||
protected title = 'Naomi\'s Library';
|
protected title = 'Naomi\'s Library';
|
||||||
private analytics = inject(AnalyticsService);
|
private analytics = inject(AnalyticsService);
|
||||||
|
private pwa = inject(PwaService);
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.analytics.initialise();
|
this.analytics.initialise();
|
||||||
|
// PWA service automatically initializes on construction
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
<div class="install-banner">
|
||||||
|
<div class="install-content">
|
||||||
|
<div class="install-icon">📱</div>
|
||||||
|
<div class="install-text">
|
||||||
|
<h3>Install Naomi's Library</h3>
|
||||||
|
<p>Add to your home screen for quick access and offline support!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="install-actions">
|
||||||
|
<button (click)="install()" class="btn-install">Install</button>
|
||||||
|
<button (click)="dismiss()" class="btn-dismiss">Not Now</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
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<void> {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<any>(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<boolean> {
|
||||||
|
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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,14 @@
|
|||||||
<title>Naomi's Library</title>
|
<title>Naomi's Library</title>
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Naomi's curated collection of games, books, music, shows, manga, and art. Browse, engage, and suggest new additions!" />
|
||||||
|
<meta name="theme-color" content="#9d4edd" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/assets/icons/icon-192x192.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Library" />
|
||||||
<script defer src="https://analytics.nhcarrigan.com/js/pa-YUXAn1vhhRttySUAw_LMN.js"></script>
|
<script defer src="https://analytics.nhcarrigan.com/js/pa-YUXAn1vhhRttySUAw_LMN.js"></script>
|
||||||
<script>
|
<script>
|
||||||
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{
|
||||||
|
"name": "Naomi's Library",
|
||||||
|
"short_name": "Library",
|
||||||
|
"description": "Naomi's curated collection of games, books, music, shows, manga, and art. Browse, engage, and suggest new additions to the library!",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#1a1a2e",
|
||||||
|
"theme_color": "#9d4edd",
|
||||||
|
"orientation": "any",
|
||||||
|
"categories": ["entertainment", "lifestyle"],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/assets/icons/icon-maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - Naomi's Library</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-container {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-icon {
|
||||||
|
font-size: 5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: #9d4edd;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button {
|
||||||
|
background: linear-gradient(135deg, #9d4edd 0%, #c77dff 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(157, 78, 221, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #808080;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="offline-container">
|
||||||
|
<div class="offline-icon">📡</div>
|
||||||
|
<h1>You're Offline</h1>
|
||||||
|
<p>
|
||||||
|
Oops! It looks like you've lost your internet connection.
|
||||||
|
Some features of Naomi's Library require an active connection.
|
||||||
|
</p>
|
||||||
|
<button class="retry-button" onclick="location.reload()">
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
<p class="info-text">
|
||||||
|
Cached pages and data will be available once you're back online.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Auto-retry when back online
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user