From 39271f8af71b0aa9d2a600420e4f6907835f5403 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 7 Sep 2026 07:27:12 +0200 Subject: [PATCH] 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) --- eslint.config.mjs | 11 ++ public/favicon.png | Bin 0 -> 332 bytes public/icons/apple-touch-icon.png | Bin 0 -> 2781 bytes public/icons/icon-192.png | Bin 0 -> 2384 bytes public/icons/icon-512.png | Bin 0 -> 7934 bytes public/icons/icon-maskable-512.png | Bin 0 -> 3911 bytes public/manifest.webmanifest | 45 +++++++++ public/sw.js | 122 +++++++++++++++++++++++ scripts/generate-icons.mjs | 67 +++++++++++++ src/app/layout.tsx | 12 +++ src/app/offline/page.tsx | 28 ++++++ src/components/layout/service-worker.tsx | 38 +++++++ src/components/layout/user-menu.tsx | 3 + src/middleware.ts | 4 +- 14 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 public/favicon.png create mode 100644 public/icons/apple-touch-icon.png create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-maskable-512.png create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.mjs create mode 100644 src/app/offline/page.tsx create mode 100644 src/components/layout/service-worker.tsx 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 0000000000000000000000000000000000000000..a7ff39ab327d843576c8b7154d52e658e3e8ad0f GIT binary patch literal 332 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=fdz&H|6fVg?3oVGw3ym^DWNDEQCQ z#W5t}@Z0ccz9s{KH~RhlUlZ8gGu&}{TD6G1(WOz}u3>J=B(14SILl1SCVO(+ed9Cz z(i@J2E&?pp=RX@Ay88WZ+lBQlZCTG+j4fL9GCn%22vfPrB-4`N=GT39`AN}|$sHTt zPIWE1+37CP7X8LcslO&$M+@RqQ z9##29zopr00BCQ3IG5A literal 0 HcmV?d00001 diff --git a/public/icons/apple-touch-icon.png b/public/icons/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cc0eb0aa637b6097bfc135396cef6b2cb2be7376 GIT binary patch literal 2781 zcmcIm`#Tei7k7!I$R&o?ok(bJluSr7W{5W=xmQ9VVajE5i`Ok=ZW$V(tfS&9--G&85v~ef535KYagz_j%4apL5PH=RD8(Jm;MBzZ{@qq6(sXe0*Xs8;H|B zw*FQT;eBg1B5be^hwj;2i{RrsEd5*g`Ou|ue0-ugFvvxhsG0S$OTjKvG6@)ntP$z{ z%jyGP9C3dtiGGZY^3ANuuT?qk7;|eQl(qIQD6eE>iw4l3~Q?Pef^a7k6ouI*zTl5>=-$HBC)du$~@;d3685y>k15+tS>0fW@apR+VJR*}YEdWck2eJ;O8k$` z0!N^k4yGtJOU#tI`!&Ms$uPZDcq*o&0~CU}CBCUMcmOJ=tFNzbPqkaTg%ZhTrS=x# zV9Fh7UvY7ul9EyyzPWa(r?V5ZCHE?H zK{TR1~#sFn`fROKubr8yKe*JoOhtufObN2>! zi7?imtrXFXE7&jHgU6dtICt*cS-udYMw*;-O?G&2_H7Cee-qcWN&LQqhQ!?WrC`H z2CVTf3pPVJF0kS4Q5Ex2ID+dL#1aBMma32rL`nuJb??uyYGt-9sHl?@6B8q0azfjo z^v|iKnE8kn)~CrybY@dSgILAGmfI6r@o{nU_5nFrS=EY_)tgOiK%?N#31jp{u+TN2fKwAQDWiov=PzWk zlyUdZUG^^f+uVvLjiTP_+85sTwK(JR_;~L9M==K7bqj#jU@y5gImsB9{)Mtiyvu1@ zth@>SZ<&=u!GopfxZ%-Ju9uhB^Y@x1C$s8<=Lw+hjwYd)@M~hW@tsM-#d*cfL33ba zjwcb@l>3jc%u^G*IF5{Ys96m$h5ne$!e>x#O!WgE<5fB0>aA0UxtMcp`9 zWMt%?E0<2Emxj!b&pzLB$ZHFl`db;(y;f`(us8cEl&WVw(y-=sE=dh8{3@pwawST|vjoa^0c+ zQ_QHugL;G3=NdJlQ;+N9Yuvisj48as+;K%Vd6? z#)*uC;T5r&V~SCuiByrSv42;TFoFKFE`8PK7Eog zw$?9Oi$P2^dKq4xAA!i!x3n0zU~&|*htq8{wpuRg)_uld{YxJ zUm=dG;BGQP%$?2{UbEF;*i6pM{PPY#%*~bTX>LBfC0Bw*FMoRO$q{%VBdm8N*XtNJ zm#9wJXHFax3PmI2cXv1n!BysZyH99(ux_6^i>;}zK2Oq+i5aD( z9&~&Es0f3(V5P1qnl|(Rp8Ql*UP6-OLW@U0>YEy z3j8dNJq}|uXKMejNI41SV9sZ}0 zU8VKB$1bV&GYv7l`&>(!AsS7#loGOzI#Idl&zwb@(#`fGP9D2e%du8Q`LW);(p>|S ztFeP;qBnprIb(;OqF`Me9UYVkM+GJb?GbU11pqEEs>yI&XDUu zYX}%w&61}6j@=b(G*_(c^s8jma)2>hY2Z5<1-i4r+j$5-klgXAvv(+RqBGmu2kWd# zJKd283<#)g@b1F9nSbH)a8xU}a%5@hpKR)#iJct8fLbAYQAfQWHUHr&cmij>ia|Y) zx*AP`r1`KWqFYX#h;l9Yh&z>Q;SDgavMgFx+>wq(cv!DeyH1=no3l=ewM^QrQ*ML? zK-r6s=sk-ZDvFy~LbyeCwHX7x4_()l;^^#b$}BPaAUBA9!k{m6JB75$bO^?4+}z&N iDsHLH*&-oO-d%x#d&q_b$Y$q+bOA-J8 zX|g@Z4O}_fhJb^!+lL?ta6yLIpNs$il>D|qfRY<%5QCCQmd9hp1UJ1x6;7b(E_Y_5 zxOEMO2C%6+2(XL?FRS{W5vx`lTV%yo886Vkr9CUJ5J)4_5AU2h{Tq2?(tSZQsRWS5 z`K5h0#l0QP^*U%-@ry}YDAUmC;!IRPRP@^$d`*`kKrsK~?T?-tM+_pe^wclZdj>^B zMy}SCl$3NV#cU>Tt_Q4ddYN}k8R(QT84~V#rQGPVjR{9?{cso7Z?5Z(@=G&oop#oH z=RKmB_r2z8c24e>cM}Wv5>Xc4HbeZ_B}81)~KysT|XT-{p8(ScPfgq5v#lN#pSFu9{+y18N6bsr2QJ9)tQaysTWhnOp_R) zQ%t>{hG(`vLYHk8>pRNP7lM%z)x-O&POR%!tv-%_PI*s0h7I}Jf7vrN$|T}*>pWWP z%P8;Ry?ghf-<_eyC!?RVOj(=!Wn^f`tR{P34h^BK zvAVh%X+ubyjr(c)SiNdQob|i5MyhdWY@-j-;w#1}CpubL0O1-d zzB3kaHRboW;%3%PY=&3*R&M4=y@ol8v*-9(m&CIV6JcoG8QWrW8fh7W4Pdyq#|?1r z)a=NUaJ?L2zh{v02cTrHLORNbSXUJ7Zs~!##)mmixb@i47s-mGf<^b(qy*}MAU;pr zb;=c+`+oUJZkd555}T+S7C1UN(&$Muk<5e1@e{4Bt^FF?M0T1ID!vopAIoYr;VQFm zFYi;EgiA}Pp%AAIcE}|mUn~!LKwSu1J76w$LqnVAd3a`O(tu-3hIFAREym(V2)|$G zwKDT8;~c$m{y3x!BgdPGj?-n;ve zJH4n#kx=MR7{upycV;NHPEY$!+R!KKwqo%gDwyGw< z;$uoymJE2!rx{Xw6t~j(h_2Sa3g+6jAY`RFIy#0GI-sB=FEW6wi&QGJ4qZ@OT+Fa_ zJd>dWaVK8g3fhnO@+wzj`@FwC;52G-V|8xZxn5H8_$g*(T5btTCj_A->WT8yg$XLG@Ipv z1axZw%|u{mXedX3!t4S9Y79|!bCQc-$w`CbwD(7>Av&P|7qmh7`qz%GfdYT1`q+@e@=K{1Tk8@-U@;C>j*c1E9ICB^K`m`B?}Ubf3>Bmk{-xFPQ1UAZYi4E! zl6*cmG<3dKhoP#fy6r`Y=@(}j64K{fPftu?yg58uw0O(_`fRZ%efh zFtEFS@9+sE-L^d!NScH7BRzt3)6&zCAf?-OcyE~2^|m&1D(V)CbuC7YBZ3Y*J)rz< zY|Nnr!Q10iF#42%R#DLeYlnN~n)aY<343ok$2mDUZR;~II(o6UeW0oR*Ci~G_k_jj z+Y{V=N@_G{cy3_<2ZmN_!Zsdwb6?5Jw+T3k%eR?@Z!0yG4r11+CTsT(XFH5~^#{)y zMRJ>rLbPoy&SbC@pgr%^V8kTTLrXEH)>w{B)!JYz9F14zX`PLQvq5CQfD>#cmYE|J zcATyg3pcjZ=eV|kQX5_+!;E9$-;XvEo9*GoZ=B^bI*sxXVY%P~Ep5Box`K!K>|a5q z%oOmh-$UTB!^&+)XjZ>G2TR$+k@PtP^CXSQhO{qP}D4X2(_V zz5A5FJX%|Lh3yiKnfx$visB3i;5TuWyq=jp`5OAz*n=}t)&+z_9UUEto(pxk3{F-c z6`=g9aiZBs->h?%FRtg;i~RFl@)Q4LX-Z*-g%)R*gZC_p4VMQlTM*enaQ;Q0f-Tl%Z~qe`LB-C=~o|w#_Z| zs!mbu@Ez^&+94%Ld*kHHTsH-gRmM`kN^;sB8t2af_K7Lmt7vRWxJ;y)?o08-zy*0) z7*py^|HL!=yq@oca8_`B{9V^5se-Rd@XHRqmB^E)H7G%4o=19R7=C7B7?u6hYxq99 zyL}PC07SUjSGSQnt)KDjlymZx(WGOT?CR75ulc1fhl}hrpW4gTTn`s_H;7DsJN{@A zHDA=g_HN*3PTvPyR0Jv1_N3I*)a3N=e9Z3Zj1}*No^SYHCr-Xxu_rV<5~eqrcmYdp2 S`Kc-RMFPk+&ZIgkAI4v3{QE2b literal 0 HcmV?d00001 diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..69dcaa8d65f2b75df2787d4e7a775b5c831eec35 GIT binary patch literal 7934 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 literal 0 HcmV?d00001 diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000000000000000000000000000000000000..68eedb8d3bc063c165118f500c958e9580ca74ad GIT binary patch literal 3911 zcmds4Yfw{37VaQw)B>Zp!z?Qgb#!!40t1MG4-hcI4oHa$!m2zNhnHXi_BNc6k3ViNkZ*Q;c?|+c5_cAp#UB;C>M#JXT*49e3TI{Cx6jI4cGCfBhn;HVO ze(kN@Uu}F+5j5cXH?u7+KWUS+nSi~MX3XJmj0Y@t-3Txde$nI7KDr!qG#BtTZLDXt zHa2Dyyt8?C*Y>UX)q`K!LBOCXA{YT_M-2a!-y`pWAw9zzR zWmi&CV%&3NEtxFGK<#)@%&Y+CddT<2JN=&znQK!Qf!E(kV=7#(ojTLh-HUCrR@2#8 zyX-<~@C~#?N8okV$V-~yq)hv^rnB=h0+QsRfBTB<%L%cGLT)UiG3Q)zo5NTv1p>C| zyyR-l#+7C#j^XA%%D>|r@P49DAQZ%D1fG$R8lvnQphNdaPj~cchWvEB3_@RyKhR znPmlsv7wo~vXMIyfZ@fKXoD#;sGJ>!?a~#ljfjAp^T`Gugo8T_Ax${laC-KP{7)Kp zG+^8QR5*a?k>42)98W2XJr)Q^ArgQoz?eK4mde>mEK7?(JMp*lj0s^%Lwz#@0w-kK zr%JeE+gxu*av7h<>lbd?#^Kp3I+G!=;YL$;SW_ zQ>|VzrW=WO><9+2M!aI;^eJS{cb3LoRs_HO4^0>V{ z;S#Id`${-Qq)-Qu#qjX3im5M9Dep)mJ_t+Uic{hv<>mHs$G`NXRdDn2e!_6uo#6s?_Zk6g;yN+xPK<^X0wAszPmtOx*^|3T0-(7b?ECx7SFoP0iKyPfUUt25i_K z-xJ&q^~(>9;q7%eySTWxw6v7;)fw-7>$&+)8Bp9fuXQ?$EnBwy5{&syX0f)W^mfH= zgvni~FhLQBhzbu6Pa;`ZT5_PimQ@gStr`venoJYMU@(TMkC3c>Pp8wBuU<8JzUDAu zV%+onkV4p=kBp40?&`YYu0LgCW3z2Nmye?791svt-Q0XCU6+|Gq^_RKm_CSEygT+5 zt6X*XXNQ!8{ffSJQv$dsT57y-p}A`7<=`8ulD*mz@4oc}l|`Pk?_+cH*DqW!M<~^0 zWo1f`EpBZ+i;^3+$;e`JL+9(Px;JkQ)bIToXHHj1s5s?l1L8|~KwzMG+q@tu6{R$% z!C!fZVDpG}dQ@EWOYfxAD-I?4eQABM371HjSeyab%nz1eOH}gq==)nLL@Rj;Ek`W& zLO}YC>$Fb^7mRO*G$J>f3KTYp8;WXlIuTws4qP9ox(;SWxcNST9M&Ktg8&soHbbC+ zs_R=6flGLLRi!d_v9$Re+CaE6)YlZvo2>KqrFHh>#f{wp2H=BL2{h4Xi} zB@9m}Y|b#Oc!!#3h=!A4Sx}8Nspa7AqTgEM@Ijc;s@%mfF)=8m&!oKxnChzICRFz+ z1IlVaOEfC1DN>M?PvSV-eKzay;n{oF1^WsHFuG*fS>o~c-@$oq-lLgL= ztEN(^)sqK<;fI=dPF!_XRu=Aq&R{gcv&7^%fofu6qI~)Cp4){SE|;sEgHtVV^^&En z^MXP`e#D5GliS?vKR2PFv8f52N4~J;N{&%^-5ptC_+fZ_RDb|<-r(_E?ndld`x&yj zT=(kL%~@j{cys*Vp3<5` zhz4QVPFZzoUQ|?+@w=Trhz>bz-uxXd*z4AhpO6UdFyt3rFo zX)4FnnAt^W%E|xFW#J>OZn`;u n#rgh9t{}+-3dc0BRHA3JbMxZ&x1Ikp<`8&!`g+{l7Mk`Ssk^;} literal 0 HcmV?d00001 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).*)', ], };