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
+
+
+
+
+
+
+
+