diff --git a/eslint.config.mjs b/eslint.config.mjs index 719cea2..df2f176 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -11,6 +11,17 @@ const compat = new FlatCompat({ const eslintConfig = [ ...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: [ "node_modules/**", diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..a7ff39a Binary files /dev/null and b/public/favicon.png differ diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..cc0eb0a Binary files /dev/null and b/public/icons/apple-touch-icon.png differ diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..4f65f83 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..69dcaa8 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..68eedb8 Binary files /dev/null and b/public/icons/icon-maskable-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..062a079 --- /dev/null +++ b/public/manifest.webmanifest @@ -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" + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..b38cb19 --- /dev/null +++ b/public/sw.js @@ -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)); + } +}); diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..3846f19 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -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) => ` + + + + + + + + +`; + +/** + * 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) => ` + + + + + + + + +`; + +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)'); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2e04487..e8c1ba4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 {children} + ); diff --git a/src/app/offline/page.tsx b/src/app/offline/page.tsx new file mode 100644 index 0000000..cbf65fd --- /dev/null +++ b/src/app/offline/page.tsx @@ -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 ( +
+ +

Gerade offline

+

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

+

+ Sobald wieder Netz da ist, aktualisiert sich alles von selbst. +

+
+ ); +} diff --git a/src/components/layout/service-worker.tsx b/src/components/layout/service-worker.tsx new file mode 100644 index 0000000..cf402b2 --- /dev/null +++ b/src/components/layout/service-worker.tsx @@ -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 { + if (!('serviceWorker' in navigator)) return; + + const registration = await navigator.serviceWorker.getRegistration(); + registration?.active?.postMessage('bricktrack:clear-cache'); +} diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx index 8aa10df..14a6760 100644 --- a/src/components/layout/user-menu.tsx +++ b/src/components/layout/user-menu.tsx @@ -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(); } diff --git a/src/middleware.ts b/src/middleware.ts index 457809b..aa86f88 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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).*)', ], };