feat(pwa): installierbar mit manifest, icons und offline-katalog
Manifest, Icons (auch maskable und Apple-Touch) und ein Service Worker. Auf dem Handy laesst sich Bricktrack damit auf den Startbildschirm legen und startet ohne Browserleiste; die Verknuepfungen springen direkt zu "Fehlende Sets" und zur Sammlung. Der Service Worker liefert Seiten nach dem Muster "erst Netz, dann Cache": mit Verbindung ist alles aktuell, ohne Verbindung bleibt lesbar, was schon einmal geladen war - genau der Katalog, den man im Keller oder im Laden braucht. Statische Build-Dateien und Set-Bilder kommen aus dem Cache, weil ihre Namen ohnehin unveraenderlich sind. Bewusst nie aus dem Cache: /api/auth, /api/export und der Healthcheck, und nichts ausser GET - eine offline "reservierte" Wunschliste waere eine Luege. Beim Abmelden raeumt die App den Cache, damit auf einem geteilten Geraet keine lesbaren Seiten zurueckbleiben. Icons entstehen reproduzierbar aus einer SVG-Quelle (node scripts/generate-icons.mjs). Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import './globals.css';
|
||||
|
||||
@@ -9,6 +10,16 @@ export const metadata: Metadata = {
|
||||
},
|
||||
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
|
||||
applicationName: 'Bricktrack',
|
||||
manifest: '/manifest.webmanifest',
|
||||
icons: {
|
||||
icon: [{ url: '/favicon.png', type: 'image/png', sizes: '32x32' }],
|
||||
apple: [{ url: '/icons/apple-touch-icon.png', sizes: '180x180' }],
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
title: 'Bricktrack',
|
||||
statusBarStyle: 'default',
|
||||
},
|
||||
// A private, self-hosted instance never belongs in a search index - and the
|
||||
// public wishlist links are meant to be shared, not found.
|
||||
robots: { index: false, follow: false },
|
||||
@@ -30,6 +41,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
|
||||
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||
{children}
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
<ServiceWorkerRegistration />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { CloudOff } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Offline',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
/**
|
||||
* Shown when a page is requested that the device has never loaded before and
|
||||
* there is no connection. Pages that were visited once come from the cache
|
||||
* instead of landing here.
|
||||
*/
|
||||
export default function OfflinePage() {
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh max-w-sm flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<CloudOff className="size-10 text-muted-foreground" aria-hidden />
|
||||
<h1 className="text-xl font-semibold tracking-tight">Gerade offline</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Diese Seite war noch nie geladen und lässt sich ohne Verbindung nicht anzeigen. Seiten,
|
||||
die du schon einmal geöffnet hast – zum Beispiel der Katalog – funktionieren weiter.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sobald wieder Netz da ist, aktualisiert sich alles von selbst.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Registers the service worker.
|
||||
*
|
||||
* Only in production: in development the worker would serve stale chunks and
|
||||
* turn every edit into a puzzle.
|
||||
*/
|
||||
export function ServiceWorkerRegistration() {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== 'production') return;
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const register = () => {
|
||||
void navigator.serviceWorker.register('/sw.js').catch((error) => {
|
||||
console.error('[pwa] Service Worker konnte nicht registriert werden', error);
|
||||
});
|
||||
};
|
||||
|
||||
// Waiting for load keeps the worker from competing with the first render.
|
||||
if (document.readyState === 'complete') register();
|
||||
else window.addEventListener('load', register, { once: true });
|
||||
|
||||
return () => window.removeEventListener('load', register);
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Drops every cached page - called on sign-out. */
|
||||
export async function clearOfflineCaches(): Promise<void> {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
registration?.active?.postMessage('bricktrack:clear-cache');
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LogOut, Settings, UserRound } from 'lucide-react';
|
||||
import { clearOfflineCaches } from '@/components/layout/service-worker';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -20,6 +21,8 @@ export function UserMenu({ user }: { user: SessionUser }) {
|
||||
|
||||
async function signOut() {
|
||||
await authClient.signOut();
|
||||
// Cached pages must not stay readable on a shared device.
|
||||
await clearOfflineCaches();
|
||||
router.push('/anmelden');
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
+3
-1
@@ -24,9 +24,11 @@ export const config = {
|
||||
* Everything except the public surface:
|
||||
* - /anmelden, /registrieren, /einladung (auth pages)
|
||||
* - /w/... (public wishlists)
|
||||
* - /offline (shown by the service worker)
|
||||
* - /api/auth, /api/health, /api/files (handled by their own guards)
|
||||
* - sw.js, manifest, icons (must load before a session exists)
|
||||
* - Next internals and static assets
|
||||
*/
|
||||
'/((?!anmelden|registrieren|einladung|w/|api/auth|api/health|api/files|_next/static|_next/image|favicon.ico|icons/|manifest.webmanifest).*)',
|
||||
'/((?!anmelden|registrieren|einladung|offline|w/|api/auth|api/health|api/files|_next/static|_next/image|favicon.ico|favicon.png|icons/|sw.js|manifest.webmanifest).*)',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user