diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..739b4a1
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,108 @@
+/**
+ * Flimmer — Service Worker
+ *
+ * Bewusst handgeschrieben und knapp gehalten. Drei Strategien:
+ *
+ * Bilder und statische Dateien → zuerst aus dem Zwischenspeicher.
+ * Poster ändern sich unter einem TMDB-Pfad nie, das spart jede Anfrage.
+ * Seitenaufrufe → zuerst das Netz, sonst der Speicher,
+ * sonst die Offline-Seite. Ein Tracker, der veraltete Stände zeigt, wäre
+ * schlimmer als einer, der ehrlich sagt: gerade keine Verbindung.
+ * Alles Übrige (API, Aktionen) → gar kein Zwischenspeicher.
+ *
+ * Schreibende Anfragen laufen nie über den Service Worker.
+ */
+
+const VERSION = "flimmer-v1";
+const SHELL_CACHE = `${VERSION}-shell`;
+const IMAGE_CACHE = `${VERSION}-images`;
+const PAGE_CACHE = `${VERSION}-pages`;
+
+const OFFLINE_URL = "/offline";
+
+const SHELL = [OFFLINE_URL, "/favicon.svg", "/icons/icon-192.png", "/manifest.webmanifest"];
+
+self.addEventListener("install", (event) => {
+ event.waitUntil(
+ caches
+ .open(SHELL_CACHE)
+ .then((cache) => cache.addAll(SHELL))
+ .then(() => self.skipWaiting()),
+ );
+});
+
+self.addEventListener("activate", (event) => {
+ event.waitUntil(
+ caches
+ .keys()
+ .then((keys) =>
+ Promise.all(keys.filter((key) => !key.startsWith(VERSION)).map((key) => caches.delete(key))),
+ )
+ .then(() => self.clients.claim()),
+ );
+});
+
+/** Zwischenspeicher begrenzen, damit Poster ihn nicht endlos füllen. */
+async function trim(cacheName, maxEntries) {
+ const cache = await caches.open(cacheName);
+ const keys = await cache.keys();
+ if (keys.length <= maxEntries) return;
+ await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
+}
+
+async function cacheFirst(request, cacheName, maxEntries) {
+ const cache = await caches.open(cacheName);
+ const hit = await cache.match(request);
+ if (hit) return hit;
+
+ const response = await fetch(request);
+ if (response.ok) {
+ await cache.put(request, response.clone());
+ void trim(cacheName, maxEntries);
+ }
+ return response;
+}
+
+async function networkFirst(request) {
+ const cache = await caches.open(PAGE_CACHE);
+ try {
+ const response = await fetch(request);
+ if (response.ok) {
+ await cache.put(request, response.clone());
+ void trim(PAGE_CACHE, 40);
+ }
+ return response;
+ } catch (error) {
+ const hit = await cache.match(request);
+ if (hit) return hit;
+
+ const offline = await caches.match(OFFLINE_URL);
+ if (offline) return offline;
+ throw error;
+ }
+}
+
+self.addEventListener("fetch", (event) => {
+ const { request } = event;
+ if (request.method !== "GET") return;
+
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin) return;
+
+ // Bilder aus dem eigenen Cache-Endpunkt und statische Next-Dateien
+ if (url.pathname.startsWith("/api/image")) {
+ event.respondWith(cacheFirst(request, IMAGE_CACHE, 400));
+ return;
+ }
+ if (url.pathname.startsWith("/_next/static") || url.pathname.startsWith("/icons/")) {
+ event.respondWith(cacheFirst(request, SHELL_CACHE, 200));
+ return;
+ }
+
+ // Übrige API-Aufrufe nie zwischenspeichern
+ if (url.pathname.startsWith("/api/")) return;
+
+ if (request.mode === "navigate") {
+ event.respondWith(networkFirst(request));
+ }
+});
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index b3069d6..4f2cc51 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -2,6 +2,7 @@ import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google";
import { BrandDefs } from "@/components/brand/FlimmerLogo";
import { ToastProvider } from "@/components/Toasts";
+import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
import { env } from "@/lib/env";
import "./globals.css";
@@ -75,6 +76,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
Zum Inhalt springen
{children}
+