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:
@@ -11,6 +11,17 @@ const compat = new FlatCompat({
|
|||||||
|
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
// Destructuring a key out in order to drop it is how we omit fields
|
||||||
|
// (share tokens, reservations) from an export - the binding is
|
||||||
|
// deliberately unused.
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{ ignoreRestSiblings: true, argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 332 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "Bricktrack",
|
||||||
|
"short_name": "Bricktrack",
|
||||||
|
"description": "Sammlung und Wunschlisten für Spielzeug-Sets",
|
||||||
|
"lang": "de",
|
||||||
|
"dir": "ltr",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#0a0a0a",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-maskable-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Fehlende Sets",
|
||||||
|
"short_name": "Fehlt noch",
|
||||||
|
"url": "/katalog?besitz=fehlt"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sammlung",
|
||||||
|
"short_name": "Sammlung",
|
||||||
|
"url": "/sammlung"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* Bricktrack service worker.
|
||||||
|
*
|
||||||
|
* Goal: the catalog stays readable in the cellar, in the shop, and wherever
|
||||||
|
* else the phone loses its connection. Anything that changes data still needs
|
||||||
|
* the network - a wish reserved offline would be a lie.
|
||||||
|
*
|
||||||
|
* Caches are versioned; bumping VERSION drops the old ones on activation.
|
||||||
|
*/
|
||||||
|
const VERSION = 'v1';
|
||||||
|
const PAGE_CACHE = `bricktrack-pages-${VERSION}`;
|
||||||
|
const ASSET_CACHE = `bricktrack-assets-${VERSION}`;
|
||||||
|
const IMAGE_CACHE = `bricktrack-images-${VERSION}`;
|
||||||
|
const OFFLINE_URL = '/offline';
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(PAGE_CACHE)
|
||||||
|
.then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest']))
|
||||||
|
.then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
const keep = new Set([PAGE_CACHE, ASSET_CACHE, IMAGE_CACHE]);
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) => Promise.all(keys.filter((key) => !keep.has(key)).map((key) => caches.delete(key))))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signing out must not leave readable pages behind on a shared device, so the
|
||||||
|
* app tells the worker to drop everything it holds.
|
||||||
|
*/
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
if (event.data === 'bricktrack:clear-cache') {
|
||||||
|
event.waitUntil(caches.keys().then((keys) => Promise.all(keys.map((key) => caches.delete(key)))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Long-lived, content-addressed build output. */
|
||||||
|
function isStaticAsset(url) {
|
||||||
|
return url.pathname.startsWith('/_next/static/') || url.pathname.startsWith('/icons/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set pictures; their file names contain a UUID and never change. */
|
||||||
|
function isCacheableImage(url) {
|
||||||
|
return url.pathname.startsWith('/api/files/images/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything that must never be answered from a cache. */
|
||||||
|
function isNeverCached(url) {
|
||||||
|
return (
|
||||||
|
url.pathname.startsWith('/api/auth/') ||
|
||||||
|
url.pathname.startsWith('/api/export/') ||
|
||||||
|
url.pathname.startsWith('/api/health')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network first, cache as a fallback: the catalog is always current when there
|
||||||
|
* is a connection, and still readable when there is not.
|
||||||
|
*/
|
||||||
|
async function networkFirst(request, cacheName) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok) cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
const offline = await cache.match(OFFLINE_URL);
|
||||||
|
if (offline) return offline;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
|
||||||
|
// Only plain GETs are safe to serve from a cache.
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
if (isNeverCached(url)) return;
|
||||||
|
|
||||||
|
if (isStaticAsset(url)) {
|
||||||
|
event.respondWith(cacheFirst(request, ASSET_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCacheableImage(url)) {
|
||||||
|
event.respondWith(cacheFirst(request, IMAGE_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page loads and the RSC payloads behind client-side navigation.
|
||||||
|
if (request.mode === 'navigate' || url.searchParams.has('_rsc')) {
|
||||||
|
event.respondWith(networkFirst(request, PAGE_CACHE));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Renders the app icons from one SVG source.
|
||||||
|
*
|
||||||
|
* Run with `node scripts/generate-icons.mjs` after changing the mark; the
|
||||||
|
* results are committed so neither the build nor the container needs to draw
|
||||||
|
* anything at runtime.
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const OUT_DIR = path.resolve(process.cwd(), 'public/icons');
|
||||||
|
|
||||||
|
// Four studs on a rounded plate - readable down to 32px, which a detailed
|
||||||
|
// brick drawing would not be.
|
||||||
|
const mark = (background, foreground) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="112" fill="${background}"/>
|
||||||
|
<g fill="${foreground}">
|
||||||
|
<rect x="112" y="112" width="128" height="128" rx="28"/>
|
||||||
|
<rect x="272" y="112" width="128" height="128" rx="28"/>
|
||||||
|
<rect x="112" y="272" width="128" height="128" rx="28"/>
|
||||||
|
<rect x="272" y="272" width="128" height="128" rx="28"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A maskable icon has to survive being cropped to a circle, so the mark sits
|
||||||
|
* on a full-bleed background with generous padding.
|
||||||
|
*/
|
||||||
|
const maskable = (background, foreground) => `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" fill="${background}"/>
|
||||||
|
<g fill="${foreground}">
|
||||||
|
<rect x="146" y="146" width="100" height="100" rx="22"/>
|
||||||
|
<rect x="266" y="146" width="100" height="100" rx="22"/>
|
||||||
|
<rect x="146" y="266" width="100" height="100" rx="22"/>
|
||||||
|
<rect x="266" y="266" width="100" height="100" rx="22"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
const DARK = '#0a0a0a';
|
||||||
|
const LIGHT = '#fafafa';
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
{ file: 'icon-192.png', size: 192, svg: mark(DARK, LIGHT) },
|
||||||
|
{ file: 'icon-512.png', size: 512, svg: mark(DARK, LIGHT) },
|
||||||
|
{ file: 'icon-maskable-512.png', size: 512, svg: maskable(DARK, LIGHT) },
|
||||||
|
{ file: 'apple-touch-icon.png', size: 180, svg: mark(DARK, LIGHT) },
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
await sharp(Buffer.from(target.svg))
|
||||||
|
.resize(target.size, target.size)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(path.join(OUT_DIR, target.file));
|
||||||
|
console.log(`[icons] ${target.file} (${target.size}px)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The favicon keeps the same mark so the browser tab matches the home screen.
|
||||||
|
await sharp(Buffer.from(mark(DARK, LIGHT)))
|
||||||
|
.resize(32, 32)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(path.resolve(process.cwd(), 'public/favicon.png'));
|
||||||
|
console.log('[icons] favicon.png (32px)');
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
|
import { ServiceWorkerRegistration } from '@/components/layout/service-worker';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
@@ -9,6 +10,16 @@ export const metadata: Metadata = {
|
|||||||
},
|
},
|
||||||
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
|
description: 'Sammlung und Wunschlisten für Spielzeug-Sets',
|
||||||
applicationName: 'Bricktrack',
|
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
|
// A private, self-hosted instance never belongs in a search index - and the
|
||||||
// public wishlist links are meant to be shared, not found.
|
// public wishlist links are meant to be shared, not found.
|
||||||
robots: { index: false, follow: false },
|
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">
|
<body className="min-h-dvh bg-background text-foreground antialiased">
|
||||||
{children}
|
{children}
|
||||||
<Toaster position="top-center" richColors closeButton />
|
<Toaster position="top-center" richColors closeButton />
|
||||||
|
<ServiceWorkerRegistration />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { LogOut, Settings, UserRound } from 'lucide-react';
|
import { LogOut, Settings, UserRound } from 'lucide-react';
|
||||||
|
import { clearOfflineCaches } from '@/components/layout/service-worker';
|
||||||
import { authClient } from '@/lib/auth-client';
|
import { authClient } from '@/lib/auth-client';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,8 @@ export function UserMenu({ user }: { user: SessionUser }) {
|
|||||||
|
|
||||||
async function signOut() {
|
async function signOut() {
|
||||||
await authClient.signOut();
|
await authClient.signOut();
|
||||||
|
// Cached pages must not stay readable on a shared device.
|
||||||
|
await clearOfflineCaches();
|
||||||
router.push('/anmelden');
|
router.push('/anmelden');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -24,9 +24,11 @@ export const config = {
|
|||||||
* Everything except the public surface:
|
* Everything except the public surface:
|
||||||
* - /anmelden, /registrieren, /einladung (auth pages)
|
* - /anmelden, /registrieren, /einladung (auth pages)
|
||||||
* - /w/... (public wishlists)
|
* - /w/... (public wishlists)
|
||||||
|
* - /offline (shown by the service worker)
|
||||||
* - /api/auth, /api/health, /api/files (handled by their own guards)
|
* - /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
|
* - 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