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:
@@ -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)');
|
||||
Reference in New Issue
Block a user