32 Commits
Author SHA1 Message Date
Florian Wilde ff9d380d42 docs: Changelog, Release-Notes und DEVLOG fuer 0.6.0 2026-09-01 20:25:13 +02:00
Florian Wilde 8d6f1d044a docs: bebilderte Anleitung, API-Dokumentation und Screenshots
docs/ANLEITUNG.md führt einmal durch die ganze Anwendung — von der ersten
Anmeldung über das tägliche Abhaken bis zu Universen, Statistik und
Verwaltung, mit 18 Aufnahmen aus der laufenden Instanz.

Die Bilder entstehen mit npm run screenshots: aufgenommen mit doppelter
Pixeldichte für scharfe Schrift, abgelegt als WebP in halber Kantenlänge —
13 MB werden so zu 0,6 MB.
2026-09-01 20:25:13 +02:00
Florian Wilde cf38bd6854 feat: TMDB-Adresse konfigurierbar und lokaler Ersatz für die Suche
TMDB_BASE_URL hilft Instanzen hinter einem eigenen Proxy — und erlaubt es,
die Suche ohne Schlüssel aus den Beispieldaten zu bedienen. Damit lässt sich
die Oberfläche ausprobieren, bevor man einen Schlüssel besorgt, und die
Screenshots der Anleitung zeigen eine Suche, die tatsächlich etwas findet.
2026-09-01 20:25:12 +02:00
Florian Wilde 0adcccc01a test: Import, Export, Papierkorb und Doppelversandschutz absichern
Neun Tests: CSV mit Komma und Semikolon, Anführungszeichen und Zeilenumbrüchen,
Zuordnung zu vorhandenen Katalogtiteln, Trakt-Formen, Export in beiden
Formaten, Aufbewahrungsfrist des Papierkorbs und der Schutz davor, dieselbe
Meldung zweimal zu verschicken.
2026-09-01 20:25:12 +02:00
Florian Wilde f02e3597b9 feat: PWA-Feinschliff mit Service Worker und Offline-Seite
Drei Strategien: Poster und statische Dateien zuerst aus dem Zwischenspeicher,
Seiten zuerst aus dem Netz, API-Aufrufe gar nicht.

Ein Tracker, der veraltete Stände zeigt, wäre schlimmer als einer, der ehrlich
sagt: gerade keine Verbindung. Schreibende Anfragen laufen nie über den
Service Worker.
2026-09-01 20:25:11 +02:00
Florian Wilde a9314acbb4 feat: Einstellungen, Verwaltung und öffentliches Profil
Nach einem Passwortwechsel fliegen alle anderen Geräte raus; ein gesperrtes
Konto wird sofort abgemeldet. Die letzte Administration lässt sich nicht
entmachten, und sich selbst kann niemand sperren.

Das öffentliche Profil zeigt Zahlen und zuletzt Gesehenes — keine Notizen,
keine Watchlist, keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat.
Es braucht die Freigabe des Nutzers und die des Betreibers.
2026-09-01 20:25:11 +02:00
Florian Wilde bb63827bf7 feat: REST-API unter /api/v1 mit OpenAPI-Beschreibung
Dieselben Endpunkte bedienen Skripte und das eigene Frontend: erst wird ein
Bearer-Token geprüft, sonst das Sitzungscookie.

Sichtungsaktionen geben immer watchIds zurück — damit lässt sich genau diese
Aktion über DELETE zurücknehmen, ohne raten zu müssen, was dazugehörte.

Die OpenAPI-Beschreibung ist handgeschrieben statt aus Zod erzeugt: die
Schnittstelle ist überschaubar, und ein Generator brächte eine weitere
Abhängigkeit für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
2026-09-01 20:24:48 +02:00
Florian Wilde b25f0473fd feat: Import aus Trakt und CSV, Export als CSV und JSON
Grundhaltung beim Import: nichts überschreiben. Was schon in der Bibliothek
liegt, bleibt wie es ist; es kommt nur dazu, was fehlt. Ein Import soll
niemand versehentlich seinen Fortschritt kosten.

Trakt gibt je nach Ansicht unterschiedliche Formen aus. Gemeinsam ist ihnen
ein Array mit Einträgen, die entweder show oder movie tragen — genau darauf
stützt sich der Import, statt ein bestimmtes Exportformat zu verlangen.

Der CSV-Leser errät das Trennzeichen: deutsche Tabellenprogramme nehmen gern
das Semikolon.
2026-09-01 20:24:47 +02:00
Florian Wilde 969353ab32 feat: täglicher Abgleich mit TMDB
Frischt laufende Serien auf, meldet neue Episoden und neu verfügbare
Watchlist-Titel, räumt Papierkorb und abgelaufene Sitzungen.

Grundsatz: Der Lauf darf nie an einem einzelnen Titel scheitern. Fehler werden
gezählt und protokolliert, der Lauf macht weiter.

Neue Episoden werden nur gemeldet, wenn sie zeitlich nicht in ferner
Vergangenheit liegen — sonst löste der erste Abgleich einer alten Serie hundert
Meldungen aus.

Der Zeitplan ist ein einfacher Timer statt einer Cron-Bibliothek: Flimmer
braucht genau einen täglichen Lauf. Wer mehr will, stösst /api/cron/sync von
aussen an.
2026-09-01 20:24:47 +02:00
Florian Wilde f49ac17922 feat: Benachrichtigungen über E-Mail, ntfy, Gotify und Webhook
Vier Wege, ein Nachrichtenformat. Der Webhook trägt sowohl content als auch
text im Rumpf — damit bedient derselbe Kanal Discord, Slack und Mattermost,
ohne dass jemand ein Format auswählen muss.

Auch ein Fehlversuch wird protokolliert: sonst versucht es jeder nächste Lauf
erneut und der Kanal wird zugespammt, sobald er wieder erreichbar ist.

Die E-Mail-Vorlage ist schlichtes HTML mit Inline-Stilen — Mailprogramme
können weder externe Stylesheets noch CSS-Variablen. Das Logo steckt als
Inline-SVG drin.
2026-09-01 20:24:45 +02:00
Florian Wilde 7ac598ea81 docs: Changelog, Release-Notes und DEVLOG für 0.5.0 2026-09-01 19:54:17 +02:00
Florian Wilde 075b42584a fix: Kalender zeigt auch abgeschlossene, weiterlaufende Serien
Wer bei einer laufenden Serie auf dem Stand ist, gilt als „abgeschlossen" —
und verlor damit die kommenden Folgen aus dem Kalender, obwohl man genau die
dort sehen will. Jetzt faellt nur Abgebrochenes heraus.

Ausserdem: Der SVG-Titel des Diagramms brach die Hydration, weil React
<title> als Dokument-Metadatum behandelt und aus dem SVG heraushebt.
2026-09-01 19:54:16 +02:00
Florian Wilde 8c872b240a feat: Seiten für Übersicht, Kalender und Statistik
Der Kalender ist als Tabelle ausgezeichnet — ein Kalender ist eine Tabelle,
und mit Screenreader wird daraus dann „Woche 3, Donnerstag, 24." statt einer
ununterscheidbaren Kachelwüste.
2026-09-01 19:54:16 +02:00
Florian Wilde 0e51df7bad feat: Diagramme als handgeschriebenes SVG
Ohne Chart-Bibliothek: die Diagramme sind schlicht, sollen sich an die
Design-Tokens halten und in beiden Farbschemata funktionieren.

Die Serienfarben sind abgedunkelte Signaturfarben. Sie bestehen in beiden
Modi die Pruefungen auf Helligkeitsband, Saettigung, Farbfehlsichtigkeit
(schlechtestes Paar 12,1 unter Deuteranopie) und Kontrast; die vollen
Signaturtoene waeren auf dunklem Grund zu hell.

Markenfarben der Dienste kommen nur zum Zug, wenn sie auf hellem und dunklem
Grund mindestens 3:1 erreichen — sonst faellt der Balken je nach Farbschema
in den Hintergrund.
2026-09-01 19:54:15 +02:00
Florian Wilde b53ee828ba feat: Auswertungen für Übersicht, Kalender und Statistik
Alles wird aus den Sichtungen berechnet — es gibt keine fortgeschriebenen
Zähler, die auseinanderlaufen könnten. Zeitreihen laufen als SQL, weil Prisma
keine Gruppierung nach Monat kennt.

Der Monatsschlüssel kommt bewusst als Text aus der Datenbank: date_trunc
liefert Mitternacht in der Sitzungszeitzone, JavaScript legt beim Vergleich
die lokale Zone an — zwei Stunden Versatz genügen, damit kein Schlüssel mehr
passt und das Diagramm leer bleibt.
2026-09-01 19:54:15 +02:00
Florian Wilde ed6011384a feat: Beispieluniversum im Seed und Notizen fuer 0.4.0 2026-09-01 19:39:35 +02:00
Florian Wilde 5cf4fc48a6 fix: server-only-Kette aus dem Browser-Bundle lösen
Die Posterkachel zog über lib/images.ts die serverseitige Cache-Logik in den
Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert wurde
— der Build brach ab, sobald eine solche Seite dazukam.

Die reine URL-Bildung liegt jetzt in lib/image-url.ts und darf überall
verwendet werden; Abruf und Dateicache bleiben hinter server-only.
2026-09-01 19:39:34 +02:00
Florian Wilde 5ee6946fd5 feat: Oberfläche für Universen
Umsortieren geht per Ziehen und über Pfeilknöpfe: reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig — die Knöpfe sind der
verlässliche Weg, das Ziehen der bequeme.

Die Erscheinungsreihenfolge lässt sich gar nicht erst anfassen, weil sie sich
aus den Daten ergibt.
2026-09-01 19:39:32 +02:00
Florian Wilde 90497ddc3b test: Universen absichern
12 Tests für Reihenfolgen, Fortschritt über Filme und Serien hinweg,
Freigaben mit Lese- und Schreibrecht sowie die Rechteprüfung gegenüber
Fremden.
2026-09-01 19:39:32 +02:00
Florian Wilde 190d7b3796 feat: Universen mit mehreren Reihenfolgen und Fortschritt
Zwei Dinge unterscheiden ein Universum von einer Liste: es hat mehrere
Reihenfolgen, und der Fortschritt ist der des jeweiligen Betrachters.

Die Erscheinungsreihenfolge wird aus den Erscheinungsdaten berechnet und nie
gespeichert — sie stimmt damit immer, auch wenn später Titel dazukommen.
Alles andere ist frei sortierbar.

Ein geteiltes Universum zeigt jeder Person ihren eigenen Stand; bei Serien
schrumpft die Restlaufzeit anteilig mit den gesehenen Episoden.
2026-09-01 19:39:31 +02:00
Florian Wilde e2565b600f docs: Changelog, Release-Notes und DEVLOG für 0.3.0 2026-09-01 19:24:53 +02:00
Florian Wilde a3c74c157b fix: Build-Absturz durch zu viele Worker
Mit einem Build-Worker je Kern stürzte das Einsammeln der Seitendaten auf
einer 18-Kern-Maschine reproduzierbar ab (0xC0000409) — jeder Worker zieht
eine eigene Prisma-Engine hoch. Vier Worker sind schnell genug und laufen
zuverlässig.
2026-09-01 19:24:52 +02:00
Florian Wilde 6f5bdcc20c feat: Beispielbibliothek mit lokal erzeugten Postern
Flimmer ist eine posterlastige Anwendung — ohne Bilder sieht die
Beispielbibliothek aus wie ein Fehler statt wie eine Bibliothek. Echte
TMDB-Poster lassen sich nicht mitliefern, also entstehen abstrakte Poster im
Flimmer-Look direkt im Bild-Cache.

Der Katalog ist bewusst erfunden: das Seed-Skript soll ohne TMDB-Schlüssel
laufen und keine fremden Metadaten ins Repository ziehen. Die Sichtungen sind
über die letzten Monate verteilt, damit Kalender und Statistik später etwas
zu zeigen haben.
2026-09-01 19:24:52 +02:00
Florian Wilde f9ce2f7021 feat: Bibliotheksseite, Papierkorb und manuelles Anlegen
Die Detailseite hängt jetzt an der internen Titel-ID statt an der TMDB-ID —
nur so bekommen manuell angelegte Titel dieselbe Seite. Treffer aus der Suche
laufen über /tmdb/<art>/<id>, das den Titel holt und weiterleitet.

Der Fortschritt einer ganzen Liste wird in einer Abfrage gezählt statt je
Eintrag; bei 300 Titeln wären das sonst 300 Rundreisen zur Datenbank.

Entfernte Titel wandern für 30 Tage in den Papierkorb und behalten ihren
Fortschritt.
2026-09-01 19:24:51 +02:00
Florian Wilde f613136e8a feat: Bedienelemente für Bibliothek und Episodenfortschritt
Server-Aktionen prüfen die Sitzung jeweils selbst — auf Angaben aus dem
Formular ist beim Zugriffsschutz kein Verlass.

Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
widerspricht: Abhaken soll sich anfühlen wie ein Schalter, nicht wie ein
Formular. Jede Sichtungsaktion meldet sich unten kurz zurück und lässt sich
von dort zurücknehmen — die Wirkung passiert weit weg vom Klick.
2026-09-01 19:24:51 +02:00
Florian Wilde 4c3c878980 test: Kern-Tracking absichern
14 Tests für Aufnehmen, Abhaken, Fortschritt ohne Specials, Rewatch,
Papierkorb, Rückgängig und den Zugriffsschutz zwischen Nutzern.

Die Testdateien laufen jetzt seriell: parallel räumten sie sich gegenseitig
die gemeinsamen Katalog-Fixtures weg.
2026-09-01 19:24:50 +02:00
Florian Wilde bb6ce4ab35 feat: Kern-Tracking mit Sichtungen, Fortschritt und Rewatches
Grundgedanke: Eine Sichtung ist die einzige Wahrheit darüber, was jemand
gesehen hat. Fortschritt, „nächste Episode" und später die Statistik werden
daraus berechnet, nichts davon wird doppelt gespeichert.

Rewatches zählen über einen Durchlaufzähler am Eintrag. Ein neuer Durchlauf
setzt den Fortschritt zurück, die alten Sichtungen bleiben stehen — sonst
verlöre die Schauzeit-Statistik jedes Mal ihre Geschichte.

Der Status ergibt sich aus den Sichtungen: die erste Episode startet, die
letzte schließt ab. Pausiert und Abgebrochen bleiben unangetastet — wer
bewusst pausiert hat, will das nicht durch ein Häkchen aufgehoben bekommen.
2026-09-01 19:24:49 +02:00
Florian Wilde 9ecaaf03a9 docs: Changelog, Release-Notes und DEVLOG für 0.2.0 2026-09-01 19:00:14 +02:00
Florian Wilde 75128d1bf2 test: Importpfad gegen nachgebildete TMDB-Antworten prüfen
npm test läuft ohne TMDB-Schlüssel gegen die Entwicklungsdatenbank und deckt
Kopfdaten, Staffeln, Episoden, Streamingdienste je Region, wiederholten
Import und die Auffrischlogik ab.

Dabei aufgefallen: importSeries gab den Titel zurück, bevor die Staffel- und
Episodenzähler geschrieben waren — die Zähler standen auf 0.
2026-09-01 19:00:13 +02:00
Florian Wilde e11cb6abef feat: Suchseite und Titel-Detailseite
Die Suchanfrage steht in der URL: Treffer sind teilbar, der Zurück-Knopf
funktioniert, und die Suche selbst bleibt eine Server-Komponente.

Posterkacheln zeigen bei fehlendem Bild die Bildmarke über dem
Signaturverlauf. Die Rückfalldarstellung liegt dauerhaft unter dem Bild —
ein onError-Handler hilft nicht, wenn das Bild schon vor der Hydration
scheitert, und der Browser zeigt dann sein kaputtes Bildsymbol.
2026-09-01 19:00:13 +02:00
Florian Wilde cc0e498fe6 feat: Katalogschicht und lokaler Bild-Cache
Der Katalog ist bewusst gemeinsam für alle Nutzer und über (Typ, TMDB-ID)
dedupliziert: zehn Leute mit derselben Serie brauchen die Episodenliste nur
einmal. Persönliches steckt ausschließlich in LibraryEntry und Watch.

Poster laufen über /api/image statt direkt zu TMDB — einmal holen, danach
vom eigenen Server ausliefern. Größe und Pfad werden streng geprüft, damit
daraus kein offener Proxy wird.

Staffel 0 (Specials) wird importiert, zählt aber nicht zum Fortschritt;
Episoden ohne eigene Laufzeit fallen auf die durchschnittliche Episodenlänge
der Serie zurück.
2026-09-01 19:00:12 +02:00
Florian Wilde 4feb84c060 feat: TMDB-Client für Suche, Details und Episodendaten
Nur die Endpunkte, die Flimmer wirklich braucht, samt der Felder, die uns
interessieren. Antworten werden zehn Minuten zwischengespeichert, damit
Tippen im Suchfeld nicht jedes Zeichen einzeln nach draußen trägt.

Zugang wahlweise über den klassischen API-Schlüssel oder das Bearer-Token —
TMDB gibt beides aus, und Selbsthoster haben mal das eine, mal das andere.
2026-09-01 19:00:11 +02:00
121 changed files with 15973 additions and 78 deletions
+3
View File
@@ -45,6 +45,9 @@ TMDB_ACCESS_TOKEN=
# Sprache der Metadaten und Region für die Streaming-Verfügbarkeit # Sprache der Metadaten und Region für die Streaming-Verfügbarkeit
TMDB_LANGUAGE=de-DE TMDB_LANGUAGE=de-DE
TMDB_REGION=DE TMDB_REGION=DE
# Nur nötig, wenn TMDB über einen eigenen Proxy erreicht werden soll.
# TMDB_BASE_URL=https://api.themoviedb.org/3
# TMDB_IMAGE_BASE_URL=https://image.tmdb.org/t/p
# --- Hintergrundläufe -------------------------------------------------------- # --- Hintergrundläufe --------------------------------------------------------
# Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den # Eingebauter Scheduler (prüft laufende Serien auf neue Episoden, räumt den
+221 -1
View File
@@ -7,6 +7,221 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [0.6.0] — 2026-09-01
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
— und eine bebilderte Anleitung.
### Hinzugefügt
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
Logo und Farbwelt von Flimmer.
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
Universen.
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
API-Schlüssel sowie Import und Export.
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
Administration lässt sich nicht entmachten.
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
laufenden Instanz, aufgenommen von `npm run screenshots`.
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
## [0.5.0] — 2026-09-01
Übersicht, Kalender und Statistik.
### Hinzugefügt
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
verfügbar geworden sind.
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
zählen mit.
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
Standardton.
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
„Weiterschauen" und den Kalenderzeitraum.
### Behoben
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
(„2026-04") aus der Datenbank.
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
hängt jetzt als `aria-label` am `<svg>`.
## [0.4.0] — 2026-09-01
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
### Hinzugefügt
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
Wizarding World oder was auch immer zusammengehört.
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig.
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
vorkommen, lassen sich mit einem Klick übernehmen.
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
Serien hinweg, Freigaben und die Rechteprüfung.
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
Serien und einer abweichenden Story-Chronologie.
### Behoben
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
serverseitig.
## [0.3.0] — 2026-09-01
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
### Hinzugefügt
- **Bibliothek** als Posterraster mit Volltextsuche und Filtern nach Status,
Typ, Genre, Streamingdienst und Tag; Sortierung nach zuletzt aktualisiert,
hinzugefügt, Titel, Bewertung oder Erscheinungsjahr. Der gesamte Filterstand
steht in der URL und ist damit teilbar.
- **Fünf Status** je Eintrag (Schaue gerade, Pausiert, Abgeschlossen,
Abgebrochen, Watchlist). Der Status ergibt sich beim Abhaken von selbst:
die erste Episode startet einen Titel, die letzte schließt ihn ab.
Pausiert und Abgebrochen bleiben unangetastet — wer bewusst pausiert hat,
will das nicht durch eine Sichtung aufgehoben bekommen.
- **Episodenfortschritt**: einzeln abhaken, ganze Staffel auf einmal,
„bis hierhin gesehen" für den Einstieg mitten in einer Staffel, und ein
**„+1 Episode"**-Knopf, der genau die nächste offene Episode nimmt.
Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
widerspricht; beim Abhaken glimmt die Zeile einmal warm auf.
- **„Als Nächstes dran"** mit Episodencode, Titel und Ausstrahlungsdatum;
Fortschritt in Prozent auf Basis der echten Episodenzahl, dazu die
verbleibende Laufzeit.
- **Rewatch-Zählung**: ein neuer Durchlauf setzt den Fortschritt zurück, die
Sichtungen des vorherigen bleiben für die Statistik erhalten. Bei Filmen
zählt jeder Durchlauf einzeln.
- **Rückgängig** für jede Sichtungsaktion — die Rückmeldung am unteren Rand
nennt, was passiert ist, und nimmt es auf Klick zurück.
- **Pro Eintrag**: Bewertung von 1 bis 10, Notizfeld, eigene Tags und das Feld
„wo schaue ich das", das beim Aufnehmen einen Vorschlag aus der
Streaming-Verfügbarkeit erhält und jederzeit überschreibbar ist.
- **Papierkorb** statt hartem Löschen: entfernte Titel bleiben 30 Tage liegen
und behalten dabei ihren Fortschritt. Wird ein Titel erneut aufgenommen,
lebt derselbe Eintrag wieder auf.
- **Titel selbst anlegen** für alles, was TMDB nicht kennt. Solche Einträge
haben keine TMDB-ID und werden vom Abgleich nie angefasst.
- **Seed mit Beispielbibliothek**: erfundener Katalog aus 8 Serien und
10 Filmen samt lokal erzeugten Postern, dazu 18 Bibliothekseinträge und
85 Sichtungen über die letzten Monate verteilt.
- **21 Tests** decken Aufnehmen, Abhaken, Fortschritt, Rewatch, Papierkorb und
den Zugriffsschutz zwischen Nutzern ab.
### Geändert
- Die Detailseite liegt jetzt unter `/titel/<id>` und hängt an der internen
Titel-ID statt an der TMDB-ID — nur so bekommen manuell angelegte Titel
dieselbe Seite. Treffer aus der Suche laufen über `/tmdb/<art>/<id>`, das
den Titel holt und weiterleitet.
- Die Zahl der Build-Worker ist auf vier begrenzt: mit einem Worker je Kern
stürzte der Build auf einer 18-Kern-Maschine reproduzierbar ab, weil jeder
Worker eine eigene Prisma-Engine hochzieht.
## [0.2.0] — 2026-09-01
Metadaten: Flimmer spricht mit TMDB.
### Hinzugefügt
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
den klassischen API-Schlüssel oder das Bearer-Token.
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
streng geprüft, damit der Endpunkt kein offener Proxy wird.
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
Episodenliste.
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
### Behoben
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
geschrieben waren — die Zähler standen dadurch auf 0.
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
gescheiterten Bild gar nicht mehr feuert.
## [0.1.0] — 2026-09-01 ## [0.1.0] — 2026-09-01
Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung. Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
@@ -46,5 +261,10 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können. dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation. - **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.1.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.6.0...HEAD
[0.6.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.6.0
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0 [0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
+37 -1
View File
@@ -80,9 +80,45 @@ Statistiken, Benachrichtigungen, REST-API, PWA. Oberflächensprache **Deutsch**.
- [ ] Optional per ENV: OIDC/SSO (Authelia, Keycloak) - [ ] Optional per ENV: OIDC/SSO (Authelia, Keycloak)
- [ ] Registrierung durch Admin abschaltbar; erster Nutzer wird Admin - [ ] Registrierung durch Admin abschaltbar; erster Nutzer wird Admin
## Wichtige technische Merkposten
- **Next 16**: Request-APIs sind async (`cookies()`, `headers()`, `params`,
`searchParams`). `middleware.ts` heißt jetzt `proxy.ts`. Turbopack ist Standard.
Breaking Changes stehen in `node_modules/next/dist/docs/01-app/02-guides/upgrading/version-16.md`.
- **Prisma 7**: keine `url` im Schema — Treiber-Adapter (`@prisma/adapter-pg`)
im Client, URL in `prisma.config.mjs`. Generator ist `prisma-client`
(TypeScript-Ausgabe nach `src/generated/prisma`, gitignored).
Config bewusst `.mjs`, damit die CLI im Produktionsimage ohne TS-Loader läuft.
- **Tailwind v4**: `@theme inline` reicht die `--fl-*`-Variablen durch,
`@custom-variant dark` hängt an `[data-theme="dark"]`.
- **Zwei Verläufe**: `--fl-gradient` für Flächen, `--fl-gradient-ink` für Text
(im Light Mode abgedunkelt, sonst kein AA). Logo-Stops `--fl-brand-1/2/3`.
- **Dev-Umgebung**: Postgres auf `localhost:5439` (`docker-compose.dev.yml`),
Dev-Server auf **Port 3011** (3000 und 3010 sind von anderen Projekten belegt).
- **Screenshots**: `node scripts/shot.mjs <url> <ziel.png> [w] [h] [theme]`.
- Next hängt selbst einen Block an diese Datei an — der bleibt drin.
## Fortschritt ## Fortschritt
- Stand: **Etappe 1 läuft** (Start 2026-09-01 18:09) - ✅ **Etappe 1** (v0.1.0) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
- ✅ **Etappe 2** (v0.2.0) — TMDB: Suche, Import, Episoden, Bild-Cache. 14 min.
- ✅ **Etappe 3** (v0.3.0) — Bibliothek, Status, Episodenfortschritt, Papierkorb. 24 min.
- ✅ **Etappe 4** (v0.4.0) — Universen mit Reihenfolgen, Freigaben. 14 min.
- ✅ **Etappe 5** (v0.5.0) — Übersicht, Kalender, Statistik. 15 min.
- ✅ **Etappe 6** (v0.6.0) — Benachrichtigungen, Abgleich, Import/Export, REST-API, PWA, Anleitung. 31 min.
**Alle sechs Etappen abgeschlossen. Gesamtzeit: 2 h 16 min.**
### Nützliches für spätere Arbeiten
- Suche ohne TMDB-Schlüssel ausprobieren:
`npm run tmdb:stub` und dann
`TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 PORT=3011 npm run dev`
- Screenshots neu aufnehmen: `npm run screenshots` (nimmt auf und verkleinert)
- `npm test` läuft seriell gegen die Dev-Datenbank, braucht keinen TMDB-Schlüssel
- Deutsche Anführungszeichen (`„…"`) niemals in JS-**String-Literalen** —
das ASCII-Schlusszeichen beendet den String. In JSX-Text und Kommentaren ok.
- Heredocs im Bash-Tool sind bei langen TSX-Dateien unzuverlässig — dann das
Write-Werkzeug benutzen.
<!-- BEGIN:nextjs-agent-rules --> <!-- BEGIN:nextjs-agent-rules -->
+97 -1
View File
@@ -21,10 +21,106 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
--- ---
## Etappe 2 — TMDB-Anbindung (v0.2.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 2.1 | 18:47 | 18:51 | 4 min | TMDB-Client: Suche, Serien-, Film- und Staffelabruf, Collections, Anbieterlisten; kurzer Antwortspeicher |
| 2.2 | 18:51 | 18:54 | 3 min | Katalogschicht: Import von Titeln, Staffeln, Episoden und Streamingdiensten; Bild-Cache mit eigenem Ausliefer-Endpunkt |
| 2.3 | 18:54 | 18:57 | 3 min | Oberfläche: Suchseite mit Typfilter, Detailseite mit Staffeln, Poster- und Anbieterkomponenten |
| 2.4 | 18:57 | 19:01 | 4 min | Nachgebildete TMDB-Antworten und Testlauf (7 Tests); dabei zwei Fehler gefunden und behoben: veraltete Zähler nach dem Serienimport, kaputtes Bildsymbol statt Rückfallkachel |
**Etappe 2 gesamt: 14 min** (2026-09-01, 18:47 – 19:01)
---
## Etappe 3 — Kern-Tracking: Bibliothek, Status, Episodenfortschritt (v0.3.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 3.1 | 19:01 | 19:06 | 5 min | Domänenlogik: Aufnehmen, Status, Sichtungen, Rewatch-Durchläufe, Fortschritt, nächste Episode |
| 3.2 | 19:06 | 19:10 | 4 min | 14 Tests dazu; dabei ein Fehler im parallelen Testlauf gefunden (Testdateien räumten sich gegenseitig die Fixtures weg) |
| 3.3 | 19:10 | 19:14 | 4 min | Server-Aktionen, Rückmeldungen mit Rückgängig, Bedienelemente (Status, Bewertung, Notizen, Tags, Episodenliste, „+1 Episode") |
| 3.4 | 19:14 | 19:17 | 3 min | Bibliotheksseite mit Filtern und Facetten, Papierkorb, Umbau der Detailseite auf die interne Titel-ID |
| 3.5 | 19:17 | 19:23 | 6 min | Beispieldaten: erfundener Katalog mit lokal erzeugten Postern, gelebte Bibliothek mit Sichtungshistorie; Klickweg im Browser geprüft (+1 Episode, Rückgängig) |
| 3.6 | 19:23 | 19:25 | 2 min | Manuelles Anlegen ohne TMDB-Treffer; Build-Absturz durch zu viele Worker eingegrenzt und behoben |
**Etappe 3 gesamt: 24 min** (2026-09-01, 19:01 – 19:25)
---
## Etappe 4 — Universen mit Reihenfolgen und Fortschritt (v0.4.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 4.1 | 19:25 | 19:30 | 5 min | Domänenlogik: Universen, berechnete Erscheinungsreihenfolge, frei sortierbare Reihenfolgen, Fortschritt aus Sicht des Betrachters, Freigaben |
| 4.2 | 19:30 | 19:33 | 3 min | 12 Tests dazu; eine falsche Testannahme zur Reihenfolge korrigiert (nicht den Code) |
| 4.3 | 19:33 | 19:36 | 3 min | Server-Aktionen, sortierbare Liste mit Ziehen und Pfeilknöpfen, Reihenfolge-Umschalter, Aufnehmen, Teilen |
| 4.4 | 19:36 | 19:39 | 3 min | Seiten für Übersicht, Anlegen und Detailansicht; Build-Fehler durch `server-only` im Browser-Bundle gefunden und behoben; Umsortieren im Browser geprüft |
**Etappe 4 gesamt: 14 min** (2026-09-01, 19:25 – 19:39)
---
## Etappe 5 — Dashboard, Kalender, Statistiken (v0.5.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 5.1 | 19:39 | 19:43 | 4 min | Auswertungsschicht: Weiterschauen, kommende Episoden, neu verfügbar, Gesamtzahlen, Monatsverlauf, Verteilungen, Jahresrückblick |
| 5.2 | 19:43 | 19:46 | 3 min | Diagrammfarben gegen die Prüfungen auf Helligkeit, Sättigung, Farbfehlsichtigkeit und Kontrast gerechnet; SVG-Diagramme und Verteilungsbalken gebaut |
| 5.3 | 19:46 | 19:50 | 4 min | Seiten für Übersicht, Kalender und Statistik |
| 5.4 | 19:50 | 19:54 | 4 min | Drei Fehler gefunden und behoben: leeres Monatsdiagramm durch Zeitzonenversatz, Hydrationsbruch durch SVG-Titel, Kalender ohne abgeschlossene Serien; 7 Tests nachgezogen |
**Etappe 5 gesamt: 15 min** (2026-09-01, 19:39 – 19:54)
---
## Etappe 6 — Benachrichtigungen, Sync, Import/Export, REST-API, PWA (v0.6.0)
| # | Start | Ende | Dauer | Arbeitsschritt |
|---|---|---|---|---|
| 6.1 | 19:54 | 19:58 | 4 min | Benachrichtigungen über vier Wege samt E-Mail-Vorlage; Hintergrundlauf mit Zeitplan und Cron-Endpunkt |
| 6.2 | 19:58 | 20:02 | 4 min | Import (Trakt, CSV) und Export (CSV, JSON) |
| 6.3 | 20:02 | 20:07 | 5 min | REST-API `/api/v1` mit API-Schlüsseln, OpenAPI-Beschreibung |
| 6.4 | 20:07 | 20:12 | 5 min | Einstellungsseiten, Verwaltung, öffentliches Profil; Service Worker und Offline-Seite |
| 6.5 | 20:12 | 20:15 | 3 min | 9 Tests für Import, Export, Papierkorb und Doppelversandschutz |
| 6.6 | 20:15 | 20:20 | 5 min | TMDB-Ersatz für Screenshots, 18 Aufnahmen, Verkleinerung auf WebP (13 MB → 0,6 MB) |
| 6.7 | 20:20 | 20:25 | 5 min | `docs/ANLEITUNG.md` und `docs/API.md`, README erweitert, API von Hand durchgetestet |
**Etappe 6 gesamt: 31 min** (2026-09-01, 19:54 – 20:25)
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
|---|---|---| |---|---|---|
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min | | 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
| 2 — TMDB-Anbindung | 0.2.0 | 14 min |
| 3 — Kern-Tracking | 0.3.0 | 24 min |
| 4 — Universen | 0.4.0 | 14 min |
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
| 6 — Benachrichtigungen, Sync, Import/Export, API, PWA | 0.6.0 | 31 min |
**Bisher gesamt: 38 min** **Gesamtentwicklungszeit: 2 h 16 min** (136 min, 2026-09-01, 18:09 – 20:25)
### Womit die Zeit verging
| Anteil | Bereich |
|---|---|
| ~35 min | Oberfläche und Design (Branding, Komponenten, Seiten) |
| ~30 min | Domänenlogik (Tracking, Universen, Statistik, Abgleich) |
| ~25 min | Schnittstellen nach außen (TMDB, REST-API, Import/Export, Benachrichtigungen) |
| ~20 min | Tests — 49 Stück, die unterwegs sieben echte Fehler gefunden haben |
| ~15 min | Dokumentation, Screenshots, Changelog, Releases |
| ~11 min | Gerüst, Docker, Datenmodell, Werkzeugkette |
### Fehler, die die Tests gefunden haben
1. `importSeries` gab veraltete Staffel- und Episodenzähler zurück.
2. Testdateien räumten sich im parallelen Lauf gegenseitig die Fixtures weg.
3. Das Monatsdiagramm blieb wegen eines Zeitzonenversatzes leer.
4. Abgeschlossene, aber weiterlaufende Serien verschwanden aus dem Kalender.
5. Poster zeigten das kaputte Bildsymbol statt der Rückfallkachel.
6. Eine `server-only`-Kette landete im Browser-Bundle und brach den Build.
7. Der SVG-Titel eines Diagramms brach die Hydration.
+45 -5
View File
@@ -15,6 +15,14 @@ der Flimmerkiste und dem warmen Flimmern, das ein Bildschirm im dunklen Zimmer
verbreitet. Metadaten kommen von [TMDB](https://www.themoviedb.org/), die Daten verbreitet. Metadaten kommen von [TMDB](https://www.themoviedb.org/), die Daten
bleiben auf deinem Server. bleiben auf deinem Server.
<p align="center">
<img src="docs/screenshots/02-uebersicht.webp" alt="Die Übersicht von Flimmer" width="820">
</p>
> **Bebilderte Anleitung:** [docs/ANLEITUNG.md](docs/ANLEITUNG.md) —
> führt einmal durch die ganze Anwendung.
> **Schnittstelle:** [docs/API.md](docs/API.md).
## Was Flimmer kann ## Was Flimmer kann
- **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert, - **Tracking** — Serien und Filme mit Status (Schaue gerade, Pausiert,
@@ -29,8 +37,13 @@ bleiben auf deinem Server.
Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar. Gotify oder Webhook (Discord/Slack-kompatibel), pro Nutzer einstellbar.
- **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen - **Mehrere Nutzer** — jede Person hat ihre eigene Bibliothek; Universen lassen
sich teilen. Der erste registrierte Zugang wird Administrator. sich teilen. Der erste registrierte Zugang wird Administrator.
- **Übersicht, Kalender und Statistik** — was als Nächstes dran ist, wann welche
Episode läuft, und wie viel Zeit wohin geflossen ist.
- **Import und Export** — aus Trakt.tv oder CSV hinein, als CSV oder JSON hinaus.
- **REST-API und PWA** — dokumentierte Schnittstelle mit eigenen API-Schlüsseln, - **REST-API und PWA** — dokumentierte Schnittstelle mit eigenen API-Schlüsseln,
installierbar auf dem Startbildschirm. installierbar auf dem Startbildschirm, mit Offline-Rückfall.
- **Papierkorb statt Löschen** — 30 Tage Bedenkzeit, und jede Sichtung lässt sich
mit einem Klick zurücknehmen.
## Schnellstart ## Schnellstart
@@ -165,22 +178,49 @@ npm run dev
| `npm run db:migrate` | Migration erzeugen und anwenden | | `npm run db:migrate` | Migration erzeugen und anwenden |
| `npm run db:studio` | Prisma Studio | | `npm run db:studio` | Prisma Studio |
| `npm run db:seed` | Beispieldaten | | `npm run db:seed` | Beispieldaten |
| `npm test` | Testlauf gegen die Entwicklungsdatenbank (braucht keinen TMDB-Schlüssel) |
| `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen | | `npm run icons` | Favicon und PWA-Icons aus dem SVG-Logo neu erzeugen |
| `npm run screenshots` | Screenshots der Anleitung neu aufnehmen |
Ohne TMDB-Schlüssel lässt sich die Suche mit einem lokalen Ersatz ausprobieren,
der aus den Beispieldaten antwortet:
```bash
node --import tsx scripts/tmdb-stub.mjs 3099
TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
```
Aufbau des Projekts: Aufbau des Projekts:
``` ```
src/ src/
app/ Routen (App Router), API-Endpunkte app/ Routen (App Router), API-Endpunkte, Server-Aktionen
components/ Wiederverwendbare Bausteine components/ Wiederverwendbare Bausteine
lib/ Serverseitige Logik: DB, Auth, TMDB, Benachrichtigungen lib/ Serverseitige Logik: DB, Auth, TMDB, Tracking, Statistik,
Universen, Benachrichtigungen, Abgleich, Import/Export
i18n/ Oberflächentexte i18n/ Oberflächentexte
prisma/ Schema, Migrationen, Seed prisma/ Schema, Migrationen, Seed samt Beispielkatalog
tests/ Testlauf mit nachgebildeten TMDB-Antworten
public/brand/ Logo in allen Varianten public/brand/ Logo in allen Varianten
docker/ Entrypoint des Containers docker/ Entrypoint des Containers
docs/ Anleitung, Design-Dokumentation docs/ Anleitung mit Screenshots, API, Design-Dokumentation
``` ```
## Hintergrundlauf
Einmal täglich frischt Flimmer laufende Serien bei TMDB auf, meldet neue
Episoden, prüft die Verfügbarkeit von Watchlist-Titeln und leert den
Papierkorb. Gesteuert wird das über `ENABLE_SCHEDULER` und `SYNC_HOUR`.
Wer lieber einen eigenen Cron benutzt, setzt `ENABLE_SCHEDULER=false`, vergibt
ein `CRON_SECRET` und ruft auf:
```bash
curl -X POST -H "Authorization: Bearer $CRON_SECRET" https://flimmer.example.com/api/cron/sync
```
In der Verwaltung lässt sich der Lauf jederzeit von Hand anstoßen.
## Technik ## Technik
Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS. Next.js (App Router) mit TypeScript, PostgreSQL über Prisma, Tailwind CSS.
+360
View File
@@ -0,0 +1,360 @@
<p align="center">
<img src="../public/brand/flimmer-logo.svg" alt="Flimmer" width="320">
</p>
# Flimmer — Anleitung
Diese Anleitung führt einmal durch die ganze Anwendung: vom ersten Konto über
das tägliche Abhaken bis zu Universen, Statistik und Schnittstelle.
Die Bilder stammen aus einer Instanz mit Beispieldaten (`npm run db:seed`).
Die Poster darin sind erfunden — mit hinterlegtem TMDB-Schlüssel stehen dort
die echten.
## Inhalt
1. [Erster Start](#erster-start)
2. [Die Übersicht](#die-übersicht)
3. [Titel finden und aufnehmen](#titel-finden-und-aufnehmen)
4. [Eine Serie verfolgen](#eine-serie-verfolgen)
5. [Die Bibliothek](#die-bibliothek)
6. [Universen](#universen)
7. [Kalender](#kalender)
8. [Statistik](#statistik)
9. [Einstellungen](#einstellungen)
10. [Benachrichtigungen](#benachrichtigungen)
11. [Import und Export](#import-und-export)
12. [API-Schlüssel](#api-schlüssel)
13. [Papierkorb](#papierkorb)
14. [Verwaltung](#verwaltung)
15. [Auf dem Handy](#auf-dem-handy)
16. [Helles Farbschema](#helles-farbschema)
---
## Erster Start
![Anmeldung](screenshots/01-anmeldung.webp)
Nach der Installation ruf die Adresse deiner Instanz auf. Solange es noch kein
einziges Konto gibt, ist die Registrierung offen — **der erste Zugang wird
automatisch Administrator**. Lege ihn also gleich für dich an.
Danach kannst du die Registrierung in der [Verwaltung](#verwaltung) schließen,
damit sich niemand sonst anmeldet.
> Ist SSO eingerichtet (`OIDC_*` in der `.env`), erscheint hier zusätzlich ein
> Knopf für die Anmeldung über den eigenen Anbieter.
---
## Die Übersicht
![Übersicht](screenshots/02-uebersicht.webp)
Die Startseite beantwortet drei Fragen:
- **Weiterschauen** — welche Serien laufen gerade, und welche Episode ist als
Nächstes dran. Der Knopf **+1 Episode** hakt genau diese Folge ab; du musst
die Serie dafür nicht öffnen.
- **Demnächst** — welche Episoden deiner Serien in den nächsten vier Wochen
ausgestrahlt werden.
- **Neu verfügbar** — welche Titel von deiner Watchlist zuletzt auf einem
Streamingdienst in deiner Region aufgetaucht sind.
Ist die Bibliothek noch leer, steht hier stattdessen der Weg zur Suche.
---
## Titel finden und aufnehmen
![Suche](screenshots/03-suche.webp)
Über **Suchen** durchsuchst du TMDB. Die Treffer lassen sich auf *Alles*,
*Serien* oder *Filme* eingrenzen; die Suchanfrage steht in der Adresszeile und
ist damit teilbar.
Ein Klick auf einen Treffer holt die vollständigen Daten — bei Serien alle
Staffeln und Episoden — und öffnet die Detailseite. Von dort nimmst du den
Titel auf, wahlweise als **Ich schaue das gerade** oder **Auf die Watchlist**.
> **Nichts dabei?** Unter *Titel selbst anlegen* trägst du etwas ein, das TMDB
> nicht kennt — Heimvideos, Vorträge, regionale Produktionen. Solche Titel
> werden vom automatischen Abgleich nie angefasst.
---
## Eine Serie verfolgen
![Serie](screenshots/04-serie.webp)
Die Detailseite ist der Ort, an dem du eine Serie führst:
- **Fortschrittsbalken** mit gesehenen Episoden und verbleibender Laufzeit,
berechnet aus der echten Episodenanzahl.
- **Als Nächstes dran** nennt Staffel, Episode, Titel und Ausstrahlungsdatum.
Der Knopf **+1 Episode** hakt sie ab.
- Der **Status** (Schaue gerade, Watchlist, Pausiert, Abgeschlossen,
Abgebrochen) ergibt sich beim Abhaken von selbst: die erste Episode startet
die Serie, die letzte schließt sie ab. Pausiert und Abgebrochen bleiben
stehen — wer bewusst pausiert hat, will das nicht durch ein Häkchen
aufgehoben bekommen.
- In der **Staffelliste** hakst du einzelne Episoden ab, ganze Staffeln auf
einmal, oder über *Bis hierhin gesehen* alles Vorherige. Beim Abhaken glimmt
die Zeile kurz warm auf.
- Rechts stehen **Bewertung** (1 bis 10), **wo du das schaust** (mit Vorschlag
aus der Streaming-Verfügbarkeit), **Tags** und ein **Notizfeld**.
Jede Sichtung meldet sich unten kurz zurück und lässt sich mit **Rückgängig**
zurücknehmen.
Ist eine Serie durch, erscheint **Noch einmal schauen** — der Fortschritt
beginnt von vorn, die alten Sichtungen bleiben für die Statistik erhalten. Die
Karte zeigt dann „2. Durchlauf".
---
## Die Bibliothek
![Bibliothek](screenshots/05-bibliothek.webp)
Alles, was du verfolgst, als Posterraster. Jede Kachel zeigt Status,
Bewertung und — bei Serien — den Fortschritt.
Filtern kannst du nach:
| Filter | Wirkung |
|---|---|
| Volltextsuche | Titel und Originaltitel |
| Status | Schaue gerade, Watchlist, Pausiert, Abgeschlossen, Abgebrochen |
| Typ | nur Serien oder nur Filme |
| Genre | alle Genres, die in deiner Bibliothek vorkommen |
| Dienst | Streamingdienste deiner Titel |
| Tag | deine eigenen Schlagworte |
Sortieren geht nach zuletzt aktualisiert, zuletzt hinzugefügt, Titel, Bewertung
oder Erscheinungsjahr. Die gesamte Auswahl steht in der Adresszeile — ein
Lesezeichen auf „alle pausierten Krimis" ist damit möglich.
---
## Universen
![Universen](screenshots/06-universen.webp)
Ein Universum bündelt Titel, die zusammengehören — MCU, Star Wars, Wizarding
World — und zwar **Filme und Serien gemeinsam**.
![Universum](screenshots/07-universum.webp)
Auf der Detailseite steht oben der Gesamtfortschritt: wie viel abgeschlossen
ist, wie viel Zeit schon drinsteckt und wie lange es bis zum Ende noch dauert.
Der erste noch offene Eintrag ist als **Als Nächstes dran** hervorgehoben.
**Mehrere Reihenfolgen** sind der Kern der Sache:
- Die **Erscheinungsreihenfolge** ist immer da und wird aus den
Erscheinungsdaten berechnet — sie stimmt damit auch, wenn später etwas
dazukommt.
- Über **+ Reihenfolge** legst du weitere an, etwa *Chronologisch* oder
*Für Einsteiger*. Sortiert wird per Ziehen oder über die Pfeile neben jedem
Eintrag; gespeichert wird sofort.
Titel nimmst du rechts aus deiner Bibliothek auf. Gehört ein Film zu einer
TMDB-Reihe (etwa „Spider-Man Collection"), lässt sich die ganze Reihe mit einem
Klick übernehmen.
**Teilen** geht mit anderen Konten derselben Instanz, wahlweise nur zum Ansehen
oder zum gemeinsamen Pflegen. Der Fortschritt bleibt dabei persönlich: jede
Person sieht ihren eigenen Stand.
---
## Kalender
![Kalender](screenshots/09-kalender.webp)
Das Monatsraster zeigt, wann Episoden deiner Serien laufen. Tage mit Terminen
sind hinterlegt, der heutige Tag trägt den Signaturverlauf. Unter dem Raster
stehen dieselben Termine noch einmal als Liste mit vollem Datum.
Mit den Pfeilen blätterst du durch die Monate, **Heute** springt zurück.
Abgebrochene Serien tauchen hier nicht auf. Abgeschlossene schon — wer bei
einer laufenden Serie auf dem Stand ist, will die nächste Folge erst recht
sehen.
---
## Statistik
![Statistik](screenshots/10-statistik.webp)
Oben die **Gesamtschauzeit** in Stunden und umgerechnet in Tage am Stück, daneben
Episoden, Filme und abgeschlossene Titel.
- **Die letzten zwölf Monate** — abgehakte Episoden und Filme je Monat.
Beim Überfahren einer Säule erscheinen die genauen Zahlen samt Schauzeit.
- **Nach Streamingdienst** — wo du tatsächlich geschaut hast. Grundlage ist das
Feld *wo schaue ich das*, nicht die bloße Verfügbarkeit.
- **Nach Genre** — Titel mit mehreren Genres zählen in jedem mit.
- **Jahr in Zahlen** — Schauzeit, Episoden, Filme, neu Angefangenes,
Abgeschlossenes, der stärkste Monat und der Titel mit der meisten Zeit.
Über die Jahreszahlen wechselst du das Jahr.
- Darunter **wo die meiste Zeit hinging** und **deine besten Bewertungen**.
Alles ist aus den Sichtungen gerechnet; Rewatches zählen mit.
---
## Einstellungen
![Einstellungen](screenshots/11-einstellungen.webp)
Hier stellst du Anzeigename und **Region** ein — Letztere bestimmt, welche
Streamingdienste angezeigt werden (Standard: DE).
Darunter legst du fest, **wovon Flimmer dir berichten soll**: neue Episoden,
neue Releases, neu verfügbare Watchlist-Titel.
Wenn der Betreiber öffentliche Profile erlaubt, kannst du deins freischalten
und eine Adresse wählen. Die Profilseite zeigt Zahlen und zuletzt Gesehenes —
**keine** Notizen, keine Watchlist, keine E-Mail-Adresse.
Das Passwort änderst du im Abschnitt darunter; danach wirst du auf allen
Geräten abgemeldet.
---
## Benachrichtigungen
![Benachrichtigungen](screenshots/12-benachrichtigungen.webp)
*Wovon* berichtet wird, steht im Profil — *wohin*, hier. Vier Wege stehen zur
Wahl:
| Kanal | Nötige Angaben |
|---|---|
| **E-Mail** | nichts (oder eine abweichende Adresse); der SMTP-Zugang kommt vom Betreiber |
| **ntfy** | Server (Standard `https://ntfy.sh`) und Topic, optional ein Token |
| **Gotify** | Serveradresse und Anwendungs-Token |
| **Webhook** | die Zieladresse — funktioniert mit Discord, Slack und Mattermost |
Jeder Kanal lässt sich einzeln stummschalten und mit **Testen** ausprobieren.
Unten steht, was zuletzt verschickt wurde — auch fehlgeschlagene Versuche mit
Grund.
Dieselbe Meldung kommt nie zweimal: Flimmer merkt sich, was schon draußen ist.
---
## Import und Export
![Import und Export](screenshots/14-daten.webp)
**Import** aus zwei Quellen:
- **CSV**, wie es Tabellenprogramme und viele Tracker ausgeben. Die Kopfzeile
braucht mindestens eine Titelspalte (`Titel`, `title`, `name` oder `show`);
erkannt werden außerdem Typ, Status, Bewertung, TMDB-ID, Jahr und Notiz.
Komma und Semikolon als Trennzeichen funktionieren beide.
- **Trakt.tv** — ein JSON-Export aus Watchlist, History, Ratings oder
Collection. Flimmer sucht sich die Einträge selbst heraus.
Der Import **überschreibt nichts**. Titel, die schon in deiner Bibliothek
liegen, bleiben unangetastet; es kommt nur dazu, was fehlt.
**Export** als JSON (mit allen Sichtungen und Universen) oder als CSV für
Tabellenkalkulationen.
---
## API-Schlüssel
![API-Schlüssel](screenshots/13-api.webp)
Für Skripte und eigene Anwendungen. Ein Schlüssel ist **einmalig** sichtbar —
danach liegt in der Datenbank nur ein Hash. Er hat dieselben Rechte wie dein
Konto; zieh ihn zurück, sobald du ihn nicht mehr brauchst.
```bash
curl -H "Authorization: Bearer flm_…" \
https://flimmer.example.com/api/v1/bibliothek?status=WATCHING
```
Alle Endpunkte stehen in [`API.md`](API.md); die maschinenlesbare Fassung
liegt unter `/api/v1/openapi.json`.
---
## Papierkorb
![Papierkorb](screenshots/16-papierkorb.webp)
Entfernte Titel landen für **30 Tage** im Papierkorb und behalten dabei ihren
Fortschritt. Von dort lassen sie sich wiederherstellen oder sofort endgültig
löschen. Nimmst du denselben Titel erneut auf, lebt der alte Eintrag samt
Sichtungen wieder auf.
Nach Ablauf der Frist räumt der tägliche Hintergrundlauf auf.
---
## Verwaltung
![Verwaltung](screenshots/15-verwaltung.webp)
Nur für Administratoren. Hier lässt sich
- der **Name der Instanz** ändern,
- die **Registrierung** öffnen oder schließen,
- **öffentliche Profile** instanzweit erlauben oder abschalten,
- der **Abgleich** von Hand anstoßen,
- und der Zustand der Instanz ablesen: Anzahl Konten, Umfang des Katalogs und
ob TMDB, SMTP, Zeitplan und OIDC eingerichtet sind.
In der Kontenliste vergibst du Verwaltungsrechte und sperrst Konten. Ein
gesperrtes Konto wird sofort von allen Geräten abgemeldet. Die letzte
Administration lässt sich nicht entmachten, und sich selbst kann niemand
sperren.
---
## Auf dem Handy
![Auf dem Handy](screenshots/17-mobil-uebersicht.webp)
Die Oberfläche ist für schmale Geräte ausgelegt: die Navigation rutscht in eine
eigene, wischbare Zeile, Karten stapeln sich untereinander, und der Knopf
**+1 Episode** bleibt groß genug für den Daumen.
Flimmer ist als **PWA** installierbar — im Browsermenü *Zum Startbildschirm
hinzufügen*. Danach startet die App im eigenen Fenster ohne Adresszeile.
Bereits geladene Seiten und Poster bleiben auch ohne Verbindung sichtbar;
statt einer Fehlerseite erscheint dann ein Hinweis.
---
## Helles Farbschema
![Helles Farbschema](screenshots/08-hell.webp)
Über den Knopf im Kopfbereich wechselst du zwischen **Dunkel**, **Hell** und
**System**. Die Wahl bleibt auf dem Gerät gespeichert und greift, bevor die
Seite gezeichnet wird — es blitzt nichts auf.
---
## Und wenn etwas fehlt?
- **Die Suche findet nichts** → Es fehlt der TMDB-Schlüssel. Trag
`TMDB_API_KEY` in die `.env` ein und starte die Instanz neu.
- **Keine Streamingdienste** → Prüfe die Region in den Einstellungen. TMDB hat
nicht für jedes Land Daten zu jedem Titel.
- **Keine Benachrichtigungen** → Erst im Profil auswählen, *wovon* berichtet
werden soll, dann einen Kanal anlegen und mit *Testen* prüfen.
- **Neue Episoden fehlen** → Der Abgleich läuft einmal täglich. In der
Verwaltung lässt er sich sofort anstoßen.
Alles Weitere zur Installation, zum Aktualisieren und zum Backup steht in der
[README](../README.md).
+190
View File
@@ -0,0 +1,190 @@
# Flimmer — REST-API
Alles, was die Oberfläche kann, geht auch über die Schnittstelle: Bibliothek
lesen und pflegen, Episoden abhaken, suchen, Kalender und Statistik abrufen,
Universen verwalten, Daten exportieren.
Die maschinenlesbare Beschreibung liegt unter
`GET /api/v1/openapi.json` (OpenAPI 3.1) — damit lassen sich Clients erzeugen
oder die Endpunkte in Insomnia, Bruno oder Postman importieren.
## Anmeldung
Jede Anfrage trägt einen persönlichen API-Schlüssel im `Authorization`-Kopf:
```bash
curl -H "Authorization: Bearer flm_a1b2c3d4.xxxxxxxxxxxx" \
https://flimmer.example.com/api/v1/me
```
Schlüssel legst du unter **Einstellungen → API-Schlüssel** an. Der Klartext ist
**einmalig** sichtbar; danach liegt in der Datenbank nur ein Hash. Ein Schlüssel
hat dieselben Rechte wie dein Konto — behandle ihn wie ein Passwort und zieh ihn
zurück, sobald du ihn nicht mehr brauchst.
Aus dem Browser heraus funktioniert stattdessen das Sitzungscookie; dieselben
Endpunkte bedienen also Skripte und das eigene Frontend.
## Antwortformat
Alle Antworten sind JSON. Zeitangaben in ISO 8601, Laufzeiten in Minuten.
Fehler tragen immer ein Feld `error`:
```json
{ "error": "Diesen Eintrag gibt es nicht." }
```
| Status | Bedeutung |
|---|---|
| 200 | In Ordnung |
| 401 | Kein oder ungültiger Schlüssel |
| 404 | Nicht vorhanden (oder nicht deins) |
| 422 | Anfrage unvollständig — `details` nennt die Felder |
| 502 / 503 | TMDB nicht erreichbar bzw. kein Schlüssel hinterlegt |
## Endpunkte
### Konto
```http
GET /api/v1/me
```
### Bibliothek
```http
GET /api/v1/bibliothek?status=WATCHING&typ=SERIES&genre=Drama&q=nord&limit=50&offset=0
POST /api/v1/bibliothek
```
Filter: `q`, `status`, `typ`, `genre`, `tag`, `dienst`, `limit`, `offset`.
Status: `WATCHING`, `PAUSED`, `COMPLETED`, `DROPPED`, `WATCHLIST`.
Aufnehmen — entweder mit interner Kennung oder direkt mit der TMDB-Kennung:
```bash
curl -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"tmdbId": 1396, "type": "SERIES", "status": "WATCHING"}' \
https://flimmer.example.com/api/v1/bibliothek
```
```http
GET /api/v1/bibliothek/{entryId}
PATCH /api/v1/bibliothek/{entryId}
DELETE /api/v1/bibliothek/{entryId}
```
`GET` liefert den Eintrag samt `progress` und `nextEpisode`.
`PATCH` ändert `status`, `rating` (1–10), `notes`, `whereToWatch`, `favorite`.
`DELETE` legt den Eintrag in den Papierkorb — dort bleibt er 30 Tage.
### Sichtungen
```http
GET /api/v1/bibliothek/{entryId}/sichtungen
POST /api/v1/bibliothek/{entryId}/sichtungen
DELETE /api/v1/bibliothek/{entryId}/sichtungen
```
`POST` kennt fünf Formen:
| Rumpf | Wirkung |
|---|---|
| `{"action":"next"}` | Die nächste offene Episode abhaken |
| `{"action":"movie"}` | Einen Film als gesehen markieren |
| `{"action":"episodes","episodeIds":["…"]}` | Eine Auswahl abhaken |
| `{"action":"season","seasonId":"…"}` | Eine ganze Staffel |
| `{"action":"upTo","episodeId":"…"}` | Alles bis einschließlich dieser Episode |
Die Antwort trägt `watchIds` — damit lässt sich genau diese Aktion zurücknehmen:
```bash
# Nächste Episode abhaken
IDS=$(curl -s -X POST -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d '{"action":"next"}' \
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen | jq -c .watchIds)
# … und wieder zurücknehmen
curl -X DELETE -H "Authorization: Bearer $KEY" -H "content-type: application/json" \
-d "{\"watchIds\": $IDS}" \
https://flimmer.example.com/api/v1/bibliothek/$ENTRY/sichtungen
```
Alternativ nimmt `DELETE` auch `{"episodeIds":["…"]}` — dann verschwindet die
Markierung dieser Episoden im aktuellen Durchlauf.
### Suche
```http
GET /api/v1/suche?q=nordlicht&typ=serie
```
Durchsucht TMDB. Die Treffer tragen `tmdbId` und `type` — direkt verwendbar für
`POST /api/v1/bibliothek`. Ohne hinterlegten TMDB-Schlüssel antwortet der
Endpunkt mit 503.
### Kalender
```http
GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
```
Kommende Episoden der getrackten Serien. Ohne Zeitraum: die nächsten 30 Tage.
### Statistik
```http
GET /api/v1/statistik?jahr=2026
```
Liefert `totals`, `monthly`, `providers`, `genres` und `yearReview` auf einmal.
### Universen
```http
GET /api/v1/universen
POST /api/v1/universen
GET /api/v1/universen/{id}?reihenfolge={orderId}
POST /api/v1/universen/{id}
```
`GET /api/v1/universen/{id}` gibt die Einträge in der gewählten Reihenfolge
zurück, dazu `progress` und `nextUp`. Ohne `reihenfolge` gilt die
Erscheinungsreihenfolge.
### Export
```http
GET /api/v1/export?format=json
GET /api/v1/export?format=csv
```
JSON enthält zusätzlich alle Sichtungen und Universen.
## Beispiel: Serien mit offener Episode auflisten
```bash
#!/usr/bin/env bash
set -euo pipefail
KEY="flm_…"
HOST="https://flimmer.example.com"
curl -s -H "Authorization: Bearer $KEY" \
"$HOST/api/v1/bibliothek?status=WATCHING&typ=SERIES" \
| jq -r '.items[] | "\(.title): \(.watched)/\(.total)"'
```
## Grenzen
Es gibt bewusst keine Ratenbegrenzung: Flimmer läuft auf deinem eigenen Server,
und ein Limit gegen dich selbst hilft niemandem. Wer die Instanz öffentlich
erreichbar macht, setzt so etwas besser im Reverse Proxy.
Der Abgleich mit TMDB lässt sich über einen eigenen Endpunkt anstoßen — der
nimmt allerdings das Cron-Geheimnis, nicht einen API-Schlüssel:
```bash
curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
https://flimmer.example.com/api/cron/sync
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+8
View File
@@ -4,6 +4,14 @@ const nextConfig: NextConfig = {
// Schlankes Docker-Image: Next kopiert nur die wirklich benötigten Dateien. // Schlankes Docker-Image: Next kopiert nur die wirklich benötigten Dateien.
output: "standalone", output: "standalone",
reactStrictMode: true, reactStrictMode: true,
experimental: {
// Begrenzte Zahl an Build-Workern. Mit der Voreinstellung (ein Worker je
// Kern) stürzte der Build auf einer 18-Kern-Maschine beim Einsammeln der
// Seitendaten reproduzierbar ab (Windows-Fehler 0xC0000409); jeder Worker
// zieht eine eigene Prisma-Engine hoch. Vier Worker sind schnell genug und
// laufen zuverlässig.
cpus: 4,
},
poweredByHeader: false, poweredByHeader: false,
serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"], serverExternalPackages: ["@prisma/client", "bcryptjs", "nodemailer"],
// Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über // Poster/Backdrops laufen über unseren eigenen Cache-Endpunkt, nicht über
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.1.0", "version": "0.6.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
@@ -15,7 +15,9 @@
"db:studio": "prisma studio", "db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts", "db:seed": "tsx prisma/seed.ts",
"icons": "tsx scripts/generate-icons.ts", "icons": "tsx scripts/generate-icons.ts",
"screenshots": "tsx scripts/screenshots.ts" "screenshots": "node scripts/screenshots.mjs && node scripts/optimize-screenshots.mjs",
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts",
"tmdb:stub": "node --import tsx scripts/tmdb-stub.mjs"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.0", "@prisma/adapter-pg": "^7.10.0",
+444
View File
@@ -0,0 +1,444 @@
/**
* Beispielkatalog für die Entwicklung.
*
* Bewusst **erfundene** Titel statt echter TMDB-Daten: das Seed-Skript soll
* ohne TMDB-Schlüssel laufen, keine fremden Metadaten ins Repository ziehen
* und trotzdem eine Bibliothek zeigen, die sich wie eine echte anfühlt —
* laufende Serien, abgeschlossene, ein Universum, unterschiedliche Dienste.
*
* Die Poster erzeugt `prisma/demo/posters.ts` lokal aus diesen Angaben.
*/
export type DemoSeason = {
seasonNumber: number;
name: string;
airDate: string;
/** Episodentitel; die Nummern ergeben sich aus der Reihenfolge. */
episodes: string[];
runtime: number;
};
export type DemoSeries = {
key: string;
type: "SERIES";
title: string;
originalTitle?: string;
tagline?: string;
overview: string;
firstAirDate: string;
lastAirDate?: string;
status: string;
inProduction: boolean;
genres: string[];
runtime: number;
voteAverage: number;
providers: { name: string; kind: "FLATRATE" | "FREE" | "RENT" | "BUY" | "ADS" }[];
seasons: DemoSeason[];
};
export type DemoMovie = {
key: string;
type: "MOVIE";
title: string;
originalTitle?: string;
tagline?: string;
overview: string;
releaseDate: string;
runtime: number;
genres: string[];
voteAverage: number;
collection?: string;
providers: { name: string; kind: "FLATRATE" | "FREE" | "RENT" | "BUY" | "ADS" }[];
};
export type DemoTitle = DemoSeries | DemoMovie;
/** Erzeugt Episodentitel im Muster „Kapitel 1 … n" für längere Staffeln. */
function chapters(prefix: string, count: number): string[] {
return Array.from({ length: count }, (_, index) => `${prefix} ${index + 1}`);
}
export const DEMO_SERIES: DemoSeries[] = [
{
key: "nordlicht",
type: "SERIES",
title: "Nordlicht",
originalTitle: "Nordlys",
tagline: "Manche Lichter wollen nicht gesehen werden.",
overview:
"Eine Meteorologin kehrt in ihr Heimatdorf am Polarkreis zurück und stößt auf ein Wetterphänomen, das es nicht geben dürfte. Je näher sie der Erklärung kommt, desto weniger will das Dorf sie hören.",
firstAirDate: "2023-03-14",
lastAirDate: "2025-05-02",
status: "Returning Series",
inProduction: true,
genres: ["Drama", "Mystery"],
runtime: 48,
voteAverage: 8.1,
providers: [
{ name: "Netflix", kind: "FLATRATE" },
{ name: "WOW", kind: "FLATRATE" },
],
seasons: [
{
seasonNumber: 1,
name: "Staffel 1",
airDate: "2023-03-14",
runtime: 48,
episodes: [
"Ankunft",
"Der Messwert",
"Polarnacht",
"Was der Schnee behält",
"Drei Grad zu warm",
"Das Gerät",
"Nordlys",
"Rückkehr des Lichts",
],
},
{
seasonNumber: 2,
name: "Staffel 2",
airDate: "2025-04-11",
runtime: 50,
episodes: [
"Der lange Winter",
"Zweiter Frühling",
"Messreihe",
"Der Bruder",
"Vermisst",
"Weißes Rauschen",
],
},
],
},
{
key: "kaffeesatz",
type: "SERIES",
title: "Kaffeesatz",
tagline: "Jeder Tisch hat eine Geschichte. Manche stimmen sogar.",
overview:
"In einem Kölner Café laufen die Leben von sechs Stammgästen zusammen. Halbstünder über Nachbarschaft, Missverständnisse und den Versuch, erwachsen zu wirken.",
firstAirDate: "2021-09-02",
lastAirDate: "2024-11-21",
status: "Ended",
inProduction: false,
genres: ["Comedy", "Drama"],
runtime: 26,
voteAverage: 7.6,
providers: [
{ name: "ARD Mediathek", kind: "FREE" },
{ name: "Joyn", kind: "ADS" },
],
seasons: [
{ seasonNumber: 1, name: "Staffel 1", airDate: "2021-09-02", runtime: 26, episodes: chapters("Runde", 10) },
{ seasonNumber: 2, name: "Staffel 2", airDate: "2022-10-06", runtime: 26, episodes: chapters("Nachschlag", 10) },
{ seasonNumber: 3, name: "Staffel 3", airDate: "2024-09-19", runtime: 27, episodes: chapters("Letzte Bestellung", 8) },
],
},
{
key: "sternenpfad-serie",
type: "SERIES",
title: "Sternenpfad: Die Grenzwacht",
tagline: "Am Rand der Karte beginnt die Verantwortung.",
overview:
"Zwischen dem dritten und dem vierten Film der Sternenpfad-Reihe: eine Grenzstation, zwei verfeindete Flotten und eine Kommandantin, die beide Seiten kennt.",
firstAirDate: "2022-11-16",
lastAirDate: "2024-01-24",
status: "Ended",
inProduction: false,
genres: ["Science Fiction", "Abenteuer", "Drama"],
runtime: 42,
voteAverage: 8.4,
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
seasons: [
{ seasonNumber: 1, name: "Staffel 1", airDate: "2022-11-16", runtime: 42, episodes: chapters("Wache", 8) },
{ seasonNumber: 2, name: "Staffel 2", airDate: "2024-01-24", runtime: 44, episodes: chapters("Ablösung", 8) },
],
},
{
key: "sternenpfad-akademie",
type: "SERIES",
title: "Sternenpfad: Akademie",
tagline: "Vor der Flotte kommt die Prüfung.",
overview:
"Jugendserie im Sternenpfad-Universum: der erste Jahrgang einer neu gegründeten Flottenakademie, kurz nach den Ereignissen des zweiten Films.",
firstAirDate: "2026-02-06",
status: "Returning Series",
inProduction: true,
genres: ["Science Fiction", "Abenteuer"],
runtime: 38,
voteAverage: 7.2,
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
seasons: [
{ seasonNumber: 1, name: "Staffel 1", airDate: "2026-02-06", runtime: 38, episodes: chapters("Semester", 10) },
],
},
{
key: "aktenlage",
type: "SERIES",
title: "Aktenlage",
tagline: "Die Wahrheit liegt im Anhang.",
overview:
"Eine Verwaltungsjuristin deckt in einem Landesministerium einen Vorgang auf, den es offiziell nie gab. Bürokratie-Thriller ohne einen einzigen Schusswechsel.",
firstAirDate: "2024-10-03",
status: "Returning Series",
inProduction: true,
genres: ["Thriller", "Drama"],
runtime: 52,
voteAverage: 8.0,
providers: [
{ name: "ZDF Mediathek", kind: "FREE" },
{ name: "Amazon Prime Video", kind: "FLATRATE" },
],
seasons: [
{ seasonNumber: 1, name: "Staffel 1", airDate: "2024-10-03", runtime: 52, episodes: chapters("Vorgang", 6) },
{ seasonNumber: 2, name: "Staffel 2", airDate: "2026-09-24", runtime: 52, episodes: chapters("Widerspruch", 6) },
],
},
{
key: "tiefenschaerfe",
type: "SERIES",
title: "Tiefenschärfe",
tagline: "Sehen ist nicht dasselbe wie erkennen.",
overview:
"Eine Tatortfotografin merkt, dass auf ihren eigenen Aufnahmen jemand steht, der dort nicht war. Sechs Teile, ein einziger Fall.",
firstAirDate: "2025-01-09",
lastAirDate: "2025-02-13",
status: "Ended",
inProduction: false,
genres: ["Krimi", "Mystery"],
runtime: 45,
voteAverage: 7.9,
providers: [{ name: "Netflix", kind: "FLATRATE" }],
seasons: [
{ seasonNumber: 1, name: "Miniserie", airDate: "2025-01-09", runtime: 45, episodes: chapters("Blende", 6) },
],
},
{
key: "gartenjahr",
type: "SERIES",
title: "Ein Gartenjahr",
tagline: "Zwölf Monate, ein Beet, kein Drehbuch.",
overview:
"Dokureihe: eine Familie legt auf einer Brache in Leipzig einen Gemeinschaftsgarten an. Jede Folge ein Monat.",
firstAirDate: "2025-03-01",
lastAirDate: "2026-02-01",
status: "Ended",
inProduction: false,
genres: ["Dokumentation"],
runtime: 30,
voteAverage: 7.4,
providers: [{ name: "ARTE", kind: "FREE" }],
seasons: [
{
seasonNumber: 1,
name: "Staffel 1",
airDate: "2025-03-01",
runtime: 30,
episodes: [
"März: Umgraben",
"April: Aussaat",
"Mai: Frost",
"Juni: Schnecken",
"Juli: Zu viel Zucchini",
"August: Dürre",
"September: Ernte",
"Oktober: Einkochen",
"November: Laub",
"Dezember: Planung",
"Januar: Katalog",
"Februar: Vorziehen",
],
},
],
},
{
key: "zwischenstopp",
type: "SERIES",
title: "Zwischenstopp",
tagline: "Zwanzig Minuten Aufenthalt.",
overview:
"Anthologieserie: jede Folge spielt vollständig auf einem anderen Bahnhof, in Echtzeit, zwischen zwei Zügen.",
firstAirDate: "2026-05-14",
status: "Returning Series",
inProduction: true,
genres: ["Drama", "Anthologie"],
runtime: 22,
voteAverage: 8.6,
providers: [{ name: "Apple TV+", kind: "FLATRATE" }],
seasons: [
{
seasonNumber: 1,
name: "Staffel 1",
airDate: "2026-05-14",
runtime: 22,
episodes: [
"Hamm (Westf)",
"Bad Bentheim",
"Offenburg",
"Bebra",
"Neustadt (Schwarzwald)",
"Probstzella",
"Zoo Berlin",
"Emmerich",
],
},
],
},
];
export const DEMO_MOVIES: DemoMovie[] = [
// --- Sternenpfad-Reihe (Grundlage für das Beispiel-Universum) -------------
{
key: "sternenpfad-1",
type: "MOVIE",
title: "Sternenpfad",
tagline: "Ein Kurs. Kein Zurück.",
overview:
"Eine Frachterpilotin nimmt einen Auftrag an, der sie über die Grenze der bekannten Karte hinausführt — und findet dort eine Flotte, die es nicht geben sollte.",
releaseDate: "2016-12-15",
runtime: 137,
genres: ["Science Fiction", "Abenteuer"],
voteAverage: 7.8,
collection: "Sternenpfad-Reihe",
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
},
{
key: "sternenpfad-2",
type: "MOVIE",
title: "Sternenpfad II: Die stille Flotte",
tagline: "Schweigen ist auch eine Antwort.",
overview:
"Zwei Jahre später: die Flotte antwortet nicht mehr. Eine Delegation soll herausfinden, warum — und findet eine Antwort, die niemand hören will.",
releaseDate: "2019-07-04",
runtime: 146,
genres: ["Science Fiction", "Abenteuer", "Drama"],
voteAverage: 8.2,
collection: "Sternenpfad-Reihe",
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
},
{
key: "sternenpfad-3",
type: "MOVIE",
title: "Sternenpfad III: Heimkehr",
tagline: "Manche Wege führen nur in eine Richtung.",
overview:
"Der Abschluss der ersten Trilogie. Die Rückkehr gelingt — aber nicht allen, und nicht dorthin, wo sie aufgebrochen sind.",
releaseDate: "2021-12-16",
runtime: 158,
genres: ["Science Fiction", "Abenteuer"],
voteAverage: 7.5,
collection: "Sternenpfad-Reihe",
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
},
{
key: "sternenpfad-4",
type: "MOVIE",
title: "Sternenpfad IV: Die Kartografin",
tagline: "Wer die Karte zeichnet, bestimmt den Rand.",
overview:
"Eine Generation später. Eine junge Kartografin zeichnet die Grenze neu — und macht damit aus einer Legende ein politisches Problem.",
releaseDate: "2024-12-19",
runtime: 149,
genres: ["Science Fiction", "Drama"],
voteAverage: 8.0,
collection: "Sternenpfad-Reihe",
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
},
{
key: "sternenpfad-null",
type: "MOVIE",
title: "Sternenpfad: Null",
tagline: "Bevor jemand hinaussah.",
overview:
"Vorgeschichte der Reihe: der Bau des ersten Fernaufklärers und die Entscheidung, die alles auslöste. Chronologisch der Anfang, im Kino der fünfte Teil.",
releaseDate: "2026-07-16",
runtime: 132,
genres: ["Science Fiction", "Drama"],
voteAverage: 7.1,
collection: "Sternenpfad-Reihe",
providers: [{ name: "Disney Plus", kind: "FLATRATE" }],
},
// --- Einzelfilme ----------------------------------------------------------
{
key: "stille-bilder",
type: "MOVIE",
title: "Die Stille zwischen den Bildern",
originalTitle: "The Silence Between Frames",
tagline: "24 Bilder pro Sekunde. Eins davon lügt.",
overview:
"Ein Filmvorführer entdeckt in einer alten Rolle Bilder, die dort nicht sein können — und niemand außer ihm sieht sie.",
releaseDate: "2024-10-17",
runtime: 118,
genres: ["Horror", "Thriller"],
voteAverage: 7.4,
collection: "Vorführer-Reihe",
providers: [
{ name: "WOW", kind: "FLATRATE" },
{ name: "Google Play Movies", kind: "RENT" },
],
},
{
key: "stille-bilder-2",
type: "MOVIE",
title: "Die Stille zwischen den Bildern II",
tagline: "Diesmal sieht es zurück.",
overview: "Fortsetzung: dieselbe Rolle, ein anderes Kino, dreißig Jahre später.",
releaseDate: "2026-02-06",
runtime: 126,
genres: ["Horror", "Thriller"],
voteAverage: 6.9,
collection: "Vorführer-Reihe",
providers: [{ name: "Google Play Movies", kind: "RENT" }],
},
{
key: "hochwasser",
type: "MOVIE",
title: "Hochwasser",
tagline: "Das Wasser fragt nicht.",
overview:
"Drei Tage in einer Kleinstadt an der Ahr. Kammerspiel über Nachbarschaft, wenn alles gleichzeitig passiert.",
releaseDate: "2023-08-24",
runtime: 104,
genres: ["Drama"],
voteAverage: 8.3,
providers: [{ name: "ARD Mediathek", kind: "FREE" }],
},
{
key: "pfand",
type: "MOVIE",
title: "Pfand",
tagline: "Fünfundzwanzig Cent Unterschied.",
overview:
"Komödie über zwei Rentner, die in Hamburg-Barmbek einen Automatenkrieg anzetteln — ohne es zu merken.",
releaseDate: "2025-06-12",
runtime: 96,
genres: ["Komödie"],
voteAverage: 7.0,
providers: [{ name: "Joyn", kind: "ADS" }],
},
{
key: "die-vermessung",
type: "MOVIE",
title: "Die Vermessung des Feierabends",
tagline: "Achtzehn Uhr. Und dann?",
overview:
"Dokumentarfilm über vier Menschen und die Frage, was von einem Tag übrig bleibt, wenn die Arbeit vorbei ist.",
releaseDate: "2022-04-07",
runtime: 88,
genres: ["Dokumentation"],
voteAverage: 7.7,
providers: [{ name: "ARTE", kind: "FREE" }],
},
];
export const DEMO_TITLES: DemoTitle[] = [...DEMO_SERIES, ...DEMO_MOVIES];
/** TMDB-IDs der Demo-Titel liegen bewusst weit oberhalb echter IDs. */
export const DEMO_TMDB_BASE = 9_100_000;
export function demoTmdbId(key: string): number {
const index = DEMO_TITLES.findIndex((title) => title.key === key);
return DEMO_TMDB_BASE + (index >= 0 ? index : 999);
}
+195
View File
@@ -0,0 +1,195 @@
/**
* Erzeugt Poster und Hintergrundbilder für die Demo-Titel.
*
* Warum überhaupt: Flimmer ist eine posterlastige Anwendung. Ohne Bilder sieht
* die Beispielbibliothek aus wie ein Fehler statt wie eine Bibliothek. Echte
* TMDB-Poster lassen sich nicht mitliefern (Rechte, und es gäbe sie ohne
* Schlüssel gar nicht), also entstehen hier abstrakte Poster im Flimmer-Look —
* warmes Glühen auf dunklem Grund, Titel darauf.
*
* Die Dateien landen direkt im Bild-Cache unter demselben Namen, den
* `src/lib/images.ts` berechnen würde. Damit funktioniert die Auslieferung über
* /api/image ohne jede Sonderbehandlung.
*/
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import sharp from "sharp";
/** Muss zu `cacheFile()` in src/lib/images.ts passen. */
function cacheFileName(size: string, path: string): string {
const key = createHash("sha256").update(`${size}${path}`).digest("hex");
return `${key}${path.slice(path.lastIndexOf("."))}`;
}
/** Deterministischer Farbton je Titel — derselbe Titel bekommt immer dasselbe Poster. */
function hue(seed: string): number {
const hash = createHash("sha256").update(seed).digest();
return (hash[0]! << 8 | hash[1]!) % 360;
}
function escapeXml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/** Bricht den Titel auf mehrere Zeilen um, damit er auf das Poster passt. */
function wrap(text: string, maxChars: number, maxLines: number): string[] {
const words = text.split(/\s+/);
const lines: string[] = [];
let current = "";
for (const word of words) {
const candidate = current ? `${current} ${word}` : word;
if (candidate.length <= maxChars) {
current = candidate;
} else {
if (current) lines.push(current);
current = word;
}
}
if (current) lines.push(current);
if (lines.length > maxLines) {
const kept = lines.slice(0, maxLines);
kept[maxLines - 1] = `${kept[maxLines - 1]!.slice(0, maxChars - 1)}…`;
return kept;
}
return lines;
}
type PosterInput = {
title: string;
subtitle: string;
seed: string;
};
function posterSvg({ title, subtitle, seed }: PosterInput, width: number, height: number): string {
const base = hue(seed);
const warm = `hsl(${(base + 20) % 360} 92% 62%)`;
const cool = `hsl(${(base + 320) % 360} 88% 58%)`;
const scale = width / 342;
const fontSize = Math.round(30 * scale);
const lines = wrap(title, 17, 3);
const blockBottom = height - Math.round(52 * scale);
const firstLineY = blockBottom - (lines.length - 1) * Math.round(fontSize * 1.14);
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#171227"/>
<stop offset="1" stop-color="#0a0810"/>
</linearGradient>
<radialGradient id="glow" cx="${width * 0.72}" cy="${height * 0.24}" r="${width * 0.85}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${warm}" stop-opacity="0.85"/>
<stop offset="0.45" stop-color="${cool}" stop-opacity="0.32"/>
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
</radialGradient>
<linearGradient id="shade" x1="0" y1="${height * 0.42}" x2="0" y2="${height}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0a0810" stop-opacity="0"/>
<stop offset="1" stop-color="#0a0810" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="rule" x1="0" y1="0" x2="${width}" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${warm}"/>
<stop offset="1" stop-color="${cool}"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" fill="url(#bg)"/>
<rect width="${width}" height="${height}" fill="url(#glow)"/>
<g opacity="0.16" stroke="${warm}" fill="none" stroke-width="${1.4 * scale}">
${Array.from({ length: 7 }, (_, index) => {
const r = (0.18 + index * 0.13) * width;
return `<circle cx="${width * 0.72}" cy="${height * 0.26}" r="${r.toFixed(1)}"/>`;
}).join("\n ")}
</g>
<rect width="${width}" height="${height}" fill="url(#shade)"/>
<rect x="${24 * scale}" y="${blockBottom - fontSize * lines.length - 22 * scale}" width="${44 * scale}" height="${3 * scale}" rx="${1.5 * scale}" fill="url(#rule)"/>
<text x="${24 * scale}" y="${firstLineY}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${fontSize}" font-weight="700" fill="#f4f1fa">
${lines
.map(
(line, index) =>
`<tspan x="${24 * scale}" dy="${index === 0 ? 0 : Math.round(fontSize * 1.14)}">${escapeXml(line)}</tspan>`,
)
.join("\n ")}
</text>
<text x="${24 * scale}" y="${blockBottom + Math.round(26 * scale)}" font-family="Segoe UI, Inter, Helvetica, Arial, sans-serif" font-size="${Math.round(15 * scale)}" fill="#aca5c2">${escapeXml(subtitle)}</text>
</svg>`;
}
function backdropSvg({ seed }: PosterInput, width: number, height: number): string {
const base = hue(seed);
const warm = `hsl(${(base + 20) % 360} 92% 62%)`;
const cool = `hsl(${(base + 320) % 360} 88% 58%)`;
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="${width}" y2="${height}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#171227"/>
<stop offset="1" stop-color="#0a0810"/>
</linearGradient>
<radialGradient id="glow" cx="${width * 0.3}" cy="${height * 0.2}" r="${width * 0.7}" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="${warm}" stop-opacity="0.7"/>
<stop offset="0.5" stop-color="${cool}" stop-opacity="0.28"/>
<stop offset="1" stop-color="${cool}" stop-opacity="0"/>
</radialGradient>
</defs>
<rect width="${width}" height="${height}" fill="url(#bg)"/>
<rect width="${width}" height="${height}" fill="url(#glow)"/>
</svg>`;
}
/** Größen, die das UI anfragt. */
const POSTER_SIZES: { size: string; width: number }[] = [
{ size: "w92", width: 92 },
{ size: "w154", width: 154 },
{ size: "w185", width: 185 },
{ size: "w342", width: 342 },
{ size: "w500", width: 500 },
];
const BACKDROP_SIZES: { size: string; width: number }[] = [
{ size: "w780", width: 780 },
{ size: "w1280", width: 1280 },
];
export type GeneratedArt = { posterPath: string; backdropPath: string };
/**
* Erzeugt Poster und Backdrop für einen Titel und legt sie im Bild-Cache ab.
* Zurück kommen die TMDB-artigen Pfade, die in der Datenbank landen.
*/
export async function generateArtwork(
dataDir: string,
input: PosterInput,
): Promise<GeneratedArt> {
const stem = createHash("sha256").update(input.seed).digest("hex").slice(0, 24);
const posterPath = `/${stem}p.jpg`;
const backdropPath = `/${stem}b.jpg`;
const dir = join(dataDir, "images");
await mkdir(dir, { recursive: true });
for (const { size, width } of POSTER_SIZES) {
const height = Math.round(width * 1.5);
const svg = posterSvg(input, width, height);
const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 88 }).toBuffer();
await writeFile(join(dir, cacheFileName(size, posterPath)), buffer);
}
for (const { size, width } of BACKDROP_SIZES) {
const height = Math.round((width * 9) / 16);
const svg = backdropSvg(input, width, height);
const buffer = await sharp(Buffer.from(svg)).jpeg({ quality: 84 }).toBuffer();
await writeFile(join(dir, cacheFileName(size, backdropPath)), buffer);
}
return { posterPath, backdropPath };
}
+541 -11
View File
@@ -3,13 +3,19 @@
* *
* npm run db:seed * npm run db:seed
* *
* Das Skript ist idempotent: mehrfaches Ausführen legt nichts doppelt an. * Legt zwei Demo-Konten, einen erfundenen Katalog mit lokal erzeugten Postern
* Es fasst ausschließlich die Demo-Konten und deren Daten an — eine echte * und eine gelebte Bibliothek an: laufende Serien, abgeschlossene, ein
* Instanz nimmt dadurch keinen Schaden, sollte es versehentlich laufen. * Rewatch, eine Watchlist und Sichtungen über die letzten Monate verteilt —
* damit Übersicht, Kalender und Statistik etwas zu zeigen haben.
*
* Das Skript ist idempotent und fasst ausschließlich die Demo-Daten an
* (Konten auf @flimmer.local, Titel mit TMDB-IDs ab 9.100.000).
*/ */
import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaPg } from "@prisma/adapter-pg";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { PrismaClient } from "../src/generated/prisma/client"; import { PrismaClient } from "../src/generated/prisma/client";
import { DEMO_MOVIES, DEMO_SERIES, demoTmdbId, type DemoMovie, type DemoSeries } from "./demo/catalog";
import { generateArtwork } from "./demo/posters";
try { try {
process.loadEnvFile(".env"); process.loadEnvFile(".env");
@@ -22,8 +28,507 @@ const prisma = new PrismaClient({
}); });
const DEMO_PASSWORD = "flimmer-demo-2026"; const DEMO_PASSWORD = "flimmer-demo-2026";
const DATA_DIR = process.env.DATA_DIR || "./data";
const REGION = process.env.TMDB_REGION || "DE";
async function seedUsers() { const PROVIDER_SEED: Record<string, { tmdbId: number; color: string }> = {
Netflix: { tmdbId: 8, color: "#e50914" },
"Amazon Prime Video": { tmdbId: 119, color: "#00a8e1" },
"Disney Plus": { tmdbId: 337, color: "#0e47a1" },
"Apple TV+": { tmdbId: 350, color: "#8f8f8f" },
WOW: { tmdbId: 30, color: "#00a4e4" },
Joyn: { tmdbId: 305, color: "#f5008c" },
"ARD Mediathek": { tmdbId: 216, color: "#0a3b78" },
"ZDF Mediathek": { tmdbId: 217, color: "#fa7d19" },
ARTE: { tmdbId: 234, color: "#ff6600" },
"Google Play Movies": { tmdbId: 3, color: "#34a853" },
};
function date(value: string): Date {
return new Date(`${value}T00:00:00.000Z`);
}
/** Tage vor heute — für Sichtungen, die sich über die Zeit verteilen sollen. */
function daysAgo(days: number, hour = 21): Date {
const result = new Date();
result.setDate(result.getDate() - days);
result.setHours(hour, 15, 0, 0);
return result;
}
// ---------------------------------------------------------------------------
// Katalog
// ---------------------------------------------------------------------------
async function seedProviders() {
const map = new Map<string, string>();
for (const [name, info] of Object.entries(PROVIDER_SEED)) {
const row = await prisma.watchProvider.upsert({
where: { tmdbId: info.tmdbId },
// logoPath bleibt leer: für die erfundenen Demo-Dienste gibt es keine
// echten Logos, und ein toter Pfad löst bei jedem Aufruf eine 404 aus.
create: { tmdbId: info.tmdbId, name, color: info.color, logoPath: null },
update: { name, color: info.color, logoPath: null },
select: { id: true },
});
map.set(name, row.id);
}
return map;
}
async function linkProviders(
titleId: string,
providers: { name: string; kind: string }[],
providerIds: Map<string, string>,
) {
for (const entry of providers) {
const providerId = providerIds.get(entry.name);
if (!providerId) continue;
await prisma.titleProvider.upsert({
where: {
titleId_providerId_region_kind: {
titleId,
providerId,
region: REGION,
kind: entry.kind as "FLATRATE",
},
},
create: { titleId, providerId, region: REGION, kind: entry.kind as "FLATRATE" },
update: {},
});
}
}
async function seedSeries(series: DemoSeries, providerIds: Map<string, string>) {
const art = await generateArtwork(DATA_DIR, {
title: series.title,
subtitle: `${series.firstAirDate.slice(0, 4)} · ${series.genres[0] ?? "Serie"}`,
seed: series.key,
});
const tmdbId = demoTmdbId(series.key);
const common = {
title: series.title,
originalTitle: series.originalTitle ?? null,
overview: series.overview,
tagline: series.tagline ?? null,
posterPath: art.posterPath,
backdropPath: art.backdropPath,
releaseDate: date(series.firstAirDate),
lastAirDate: series.lastAirDate ? date(series.lastAirDate) : null,
runtime: series.runtime,
status: series.status,
inProduction: series.inProduction,
genres: series.genres,
originalLang: "de",
voteAverage: series.voteAverage,
syncedAt: new Date(),
};
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "SERIES", tmdbId } },
create: { type: "SERIES", tmdbId, ...common },
update: common,
});
let episodeCount = 0;
for (const season of series.seasons) {
const seasonRow = await prisma.season.upsert({
where: { titleId_seasonNumber: { titleId: title.id, seasonNumber: season.seasonNumber } },
create: {
titleId: title.id,
seasonNumber: season.seasonNumber,
name: season.name,
airDate: date(season.airDate),
episodeCount: season.episodes.length,
posterPath: art.posterPath,
},
update: { name: season.name, episodeCount: season.episodes.length },
select: { id: true },
});
for (const [index, name] of season.episodes.entries()) {
// Eine Episode pro Woche ab dem Staffelstart.
const airDate = new Date(date(season.airDate).getTime() + index * 7 * 86_400_000);
const payload = {
seasonId: seasonRow.id,
name,
overview: null,
airDate,
runtime: season.runtime,
};
await prisma.episode.upsert({
where: {
titleId_seasonNumber_episodeNumber: {
titleId: title.id,
seasonNumber: season.seasonNumber,
episodeNumber: index + 1,
},
},
create: {
titleId: title.id,
seasonNumber: season.seasonNumber,
episodeNumber: index + 1,
...payload,
},
update: payload,
});
episodeCount += 1;
}
}
await prisma.title.update({
where: { id: title.id },
data: { seasonCount: series.seasons.length, episodeCount },
});
await linkProviders(title.id, series.providers, providerIds);
return title;
}
async function seedMovie(movie: DemoMovie, providerIds: Map<string, string>) {
const art = await generateArtwork(DATA_DIR, {
title: movie.title,
subtitle: `${movie.releaseDate.slice(0, 4)} · ${movie.genres[0] ?? "Film"}`,
seed: movie.key,
});
const tmdbId = demoTmdbId(movie.key);
const collectionId = movie.collection ? 9_500_000 + (movie.collection.length % 100) : null;
const common = {
title: movie.title,
originalTitle: movie.originalTitle ?? null,
overview: movie.overview,
tagline: movie.tagline ?? null,
posterPath: art.posterPath,
backdropPath: art.backdropPath,
releaseDate: date(movie.releaseDate),
runtime: movie.runtime,
status: "Released",
genres: movie.genres,
originalLang: "de",
voteAverage: movie.voteAverage,
tmdbCollectionId: collectionId,
tmdbCollectionName: movie.collection ?? null,
syncedAt: new Date(),
};
const title = await prisma.title.upsert({
where: { type_tmdbId: { type: "MOVIE", tmdbId } },
create: { type: "MOVIE", tmdbId, ...common },
update: common,
});
await linkProviders(title.id, movie.providers, providerIds);
return title;
}
// ---------------------------------------------------------------------------
// Bibliothek
// ---------------------------------------------------------------------------
type EntryPlan = {
key: string;
status: "WATCHING" | "PAUSED" | "COMPLETED" | "DROPPED" | "WATCHLIST";
rating?: number;
notes?: string;
tags?: string[];
favorite?: boolean;
/** Wie viele reguläre Episoden im aktuellen Durchlauf gesehen sind. */
watchedEpisodes?: number;
/** Vollständig gesehene frühere Durchläufe. */
previousRuns?: number;
/** Film gesehen? */
seen?: boolean;
/** Erste Sichtung vor wie vielen Tagen (die übrigen folgen im Abstand). */
startedDaysAgo?: number;
/** Abstand zwischen zwei Episoden in Tagen. */
spacingDays?: number;
};
const ENTRY_PLANS: EntryPlan[] = [
{
key: "nordlicht",
status: "WATCHING",
rating: 9,
favorite: true,
tags: ["abends", "mit anna"],
notes: "Staffel 2 läuft noch — bloß nichts spoilern lassen.",
watchedEpisodes: 10,
startedDaysAgo: 120,
spacingDays: 9,
},
{
key: "kaffeesatz",
status: "COMPLETED",
rating: 8,
tags: ["nebenbei"],
watchedEpisodes: 28,
startedDaysAgo: 300,
spacingDays: 8,
},
{
key: "sternenpfad-serie",
status: "COMPLETED",
rating: 9,
tags: ["sternenpfad"],
watchedEpisodes: 16,
startedDaysAgo: 210,
spacingDays: 6,
},
{
key: "sternenpfad-akademie",
status: "WATCHING",
tags: ["sternenpfad"],
watchedEpisodes: 4,
startedDaysAgo: 26,
spacingDays: 6,
},
{
key: "aktenlage",
status: "PAUSED",
rating: 7,
notes: "Gut, aber ich brauche dafür Ruhe. Später weiterschauen.",
watchedEpisodes: 4,
startedDaysAgo: 95,
spacingDays: 4,
},
{
key: "tiefenschaerfe",
status: "WATCHING",
rating: 8,
tags: ["mit anna"],
notes: "Zweiter Durchlauf — diesmal auf die Fotos achten.",
previousRuns: 1,
watchedEpisodes: 2,
startedDaysAgo: 14,
spacingDays: 3,
},
{
key: "gartenjahr",
status: "WATCHING",
rating: 7,
tags: ["nebenbei"],
watchedEpisodes: 8,
startedDaysAgo: 70,
spacingDays: 7,
},
{ key: "zwischenstopp", status: "WATCHLIST", tags: ["vorgemerkt"] },
{ key: "sternenpfad-1", status: "COMPLETED", rating: 8, seen: true, tags: ["sternenpfad"], startedDaysAgo: 340 },
{ key: "sternenpfad-2", status: "COMPLETED", rating: 9, seen: true, favorite: true, tags: ["sternenpfad"], startedDaysAgo: 320 },
{ key: "sternenpfad-3", status: "COMPLETED", rating: 7, seen: true, tags: ["sternenpfad"], startedDaysAgo: 250 },
{ key: "sternenpfad-4", status: "WATCHLIST", tags: ["sternenpfad"] },
{ key: "sternenpfad-null", status: "WATCHLIST", tags: ["sternenpfad", "kino"] },
{
key: "stille-bilder",
status: "COMPLETED",
rating: 8,
seen: true,
previousRuns: 1,
notes: "Beim zweiten Mal deutlich besser.",
startedDaysAgo: 180,
},
{ key: "stille-bilder-2", status: "WATCHLIST" },
{ key: "hochwasser", status: "COMPLETED", rating: 10, seen: true, favorite: true, startedDaysAgo: 60 },
{ key: "pfand", status: "DROPPED", rating: 4, notes: "Nach vierzig Minuten abgebrochen.", startedDaysAgo: 40 },
{ key: "die-vermessung", status: "COMPLETED", rating: 7, seen: true, startedDaysAgo: 400 },
];
async function seedLibrary(userId: string, titleIds: Map<string, string>) {
for (const plan of ENTRY_PLANS) {
const titleId = titleIds.get(plan.key);
if (!titleId) continue;
const title = await prisma.title.findUniqueOrThrow({
where: { id: titleId },
select: { id: true, type: true, runtime: true },
});
const currentRun = plan.previousRuns ?? 0;
const entry = await prisma.libraryEntry.upsert({
where: { userId_titleId: { userId, titleId } },
create: {
userId,
titleId,
status: plan.status,
rating: plan.rating ?? null,
notes: plan.notes ?? null,
favorite: plan.favorite ?? false,
currentRun,
startedAt: plan.startedDaysAgo ? daysAgo(plan.startedDaysAgo) : null,
finishedAt: plan.status === "COMPLETED" ? daysAgo(Math.max(1, (plan.startedDaysAgo ?? 30) - 20)) : null,
},
update: {
status: plan.status,
rating: plan.rating ?? null,
notes: plan.notes ?? null,
favorite: plan.favorite ?? false,
currentRun,
},
});
// „Wo schaue ich das" aus der Verfügbarkeit vorbelegen
const provider = await prisma.titleProvider.findFirst({
where: { titleId, region: REGION, kind: { in: ["FLATRATE", "FREE", "ADS"] } },
include: { provider: { select: { name: true } } },
});
if (provider) {
await prisma.libraryEntry.update({
where: { id: entry.id },
data: { whereToWatch: provider.provider.name },
});
}
if (plan.tags?.length) {
for (const name of plan.tags) {
const tag = await prisma.tag.upsert({
where: { userId_name: { userId, name } },
create: { userId, name },
update: {},
select: { id: true },
});
await prisma.tagOnEntry.upsert({
where: { tagId_entryId: { tagId: tag.id, entryId: entry.id } },
create: { tagId: tag.id, entryId: entry.id },
update: {},
});
}
}
await prisma.watch.deleteMany({ where: { userId, entryId: entry.id } });
if (title.type === "MOVIE") {
if (!plan.seen) continue;
for (let run = 0; run <= currentRun; run += 1) {
await prisma.watch.create({
data: {
userId,
entryId: entry.id,
titleId,
run,
watchedAt: daysAgo((plan.startedDaysAgo ?? 30) - run * 25),
minutes: title.runtime ?? 0,
},
});
}
continue;
}
const episodes = await prisma.episode.findMany({
where: { titleId, seasonNumber: { gt: 0 } },
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
select: { id: true, runtime: true },
});
// Frühere Durchläufe: vollständig, weiter in der Vergangenheit.
for (let run = 0; run < currentRun; run += 1) {
for (const [index, episode] of episodes.entries()) {
await prisma.watch.create({
data: {
userId,
entryId: entry.id,
titleId,
episodeId: episode.id,
run,
watchedAt: daysAgo((plan.startedDaysAgo ?? 60) + 120 - index * 2),
minutes: episode.runtime ?? title.runtime ?? 0,
},
});
}
}
const watched = episodes.slice(0, plan.watchedEpisodes ?? 0);
const start = plan.startedDaysAgo ?? 60;
const spacing = plan.spacingDays ?? 5;
for (const [index, episode] of watched.entries()) {
await prisma.watch.create({
data: {
userId,
entryId: entry.id,
titleId,
episodeId: episode.id,
run: currentRun,
watchedAt: daysAgo(Math.max(0, start - index * spacing)),
minutes: episode.runtime ?? title.runtime ?? 0,
},
});
}
}
}
// ---------------------------------------------------------------------------
// Universum
// ---------------------------------------------------------------------------
/**
* Die Erscheinungsreihenfolge berechnet Flimmer selbst — hier steht nur die
* abweichende Story-Chronologie, damit die Seite zwei echte Reihenfolgen zeigt.
*/
const CHRONOLOGICAL = [
"sternenpfad-null",
"sternenpfad-1",
"sternenpfad-akademie",
"sternenpfad-2",
"sternenpfad-serie",
"sternenpfad-3",
"sternenpfad-4",
];
async function seedUniverse(ownerId: string, titleIds: Map<string, string>) {
const existing = await prisma.universe.findUnique({
where: { ownerId_slug: { ownerId, slug: "sternenpfad" } },
select: { id: true },
});
if (existing) {
await prisma.universe.delete({ where: { id: existing.id } });
}
const universe = await prisma.universe.create({
data: {
ownerId,
name: "Sternenpfad",
slug: "sternenpfad",
description:
"Fünf Filme und zwei Serien. Die Kinoreihenfolge und die Story-Chronologie gehen auseinander — deshalb zwei Reihenfolgen.",
orders: {
create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }],
},
},
select: { id: true },
});
const itemIdByKey = new Map<string, string>();
for (const key of CHRONOLOGICAL) {
const titleId = titleIds.get(key);
if (!titleId) continue;
const item = await prisma.universeItem.create({
data: { universeId: universe.id, titleId },
select: { id: true },
});
itemIdByKey.set(key, item.id);
}
const order = await prisma.universeOrder.create({
data: { universeId: universe.id, name: "Chronologisch", kind: "CUSTOM" },
select: { id: true },
});
await prisma.universeOrderItem.createMany({
data: CHRONOLOGICAL.flatMap((key, index) => {
const itemId = itemIdByKey.get(key);
return itemId ? [{ orderId: order.id, itemId, position: index }] : [];
}),
});
return universe;
}
// ---------------------------------------------------------------------------
async function main() {
console.log("Seed: Demo-Konten ...");
const passwordHash = await bcrypt.hash(DEMO_PASSWORD, 12); const passwordHash = await bcrypt.hash(DEMO_PASSWORD, 12);
const admin = await prisma.user.upsert({ const admin = await prisma.user.upsert({
@@ -34,7 +539,7 @@ async function seedUsers() {
name: "Demo Nutzer", name: "Demo Nutzer",
passwordHash, passwordHash,
role: "ADMIN", role: "ADMIN",
region: "DE", region: REGION,
publicProfile: true, publicProfile: true,
profileSlug: "demo", profileSlug: "demo",
}, },
@@ -48,19 +553,44 @@ async function seedUsers() {
name: "Gast Nutzerin", name: "Gast Nutzerin",
passwordHash, passwordHash,
role: "USER", role: "USER",
region: "DE", region: REGION,
}, },
}); });
return { admin, friend }; console.log("Seed: Streamingdienste ...");
const providerIds = await seedProviders();
console.log("Seed: Katalog mit Postern ...");
const titleIds = new Map<string, string>();
for (const series of DEMO_SERIES) {
const row = await seedSeries(series, providerIds);
titleIds.set(series.key, row.id);
console.log(` Serie ${series.title}`);
}
for (const movie of DEMO_MOVIES) {
const row = await seedMovie(movie, providerIds);
titleIds.set(movie.key, row.id);
console.log(` Film ${movie.title}`);
} }
async function main() { console.log("Seed: Bibliothek und Sichtungen ...");
console.log("Seed: lege Demo-Konten an ..."); await seedLibrary(admin.id, titleIds);
const { admin, friend } = await seedUsers();
console.log("Seed: Universum ...");
const universe = await seedUniverse(admin.id, titleIds);
// Mit dem zweiten Konto teilen, damit die Freigabe sichtbar ist.
await prisma.universeMember.upsert({
where: { universeId_userId: { universeId: universe.id, userId: friend.id } },
create: { universeId: universe.id, userId: friend.id, permission: "READ" },
update: {},
});
const watches = await prisma.watch.count({ where: { userId: admin.id } });
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
console.log(""); console.log("");
console.log("Fertig. Anmelden mit:"); console.log(`Fertig: ${entries} Einträge, ${watches} Sichtungen.`);
console.log("Anmelden mit:");
console.log(` ${admin.email} / ${DEMO_PASSWORD} (Administrator)`); console.log(` ${admin.email} / ${DEMO_PASSWORD} (Administrator)`);
console.log(` ${friend.email} / ${DEMO_PASSWORD}`); console.log(` ${friend.email} / ${DEMO_PASSWORD}`);
} }
+108
View File
@@ -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));
}
});
+33
View File
@@ -0,0 +1,33 @@
# Flimmer 0.2.0
Metadaten: Flimmer spricht mit TMDB.
### Hinzugefügt
- **TMDB-Anbindung** für Suche (gemeinsam über Serien und Filme oder getrennt),
Detailabruf, Staffeln und Episoden, offizielle Collections sowie die
Streaming-Verfügbarkeit in der eingestellten Region. Zugang wahlweise über
den klassischen API-Schlüssel oder das Bearer-Token.
- **Gemeinsamer Katalog**: Titel werden über (Typ, TMDB-ID) dedupliziert — bei
mehreren Nutzern liegt jede Serie samt Episodenliste nur einmal in der
Datenbank. `ensureTitle` holt einen Titel nur, wenn er fehlt oder veraltet ist,
und fällt bei einem TMDB-Ausfall auf den lokalen Stand zurück.
- **Bild-Cache**: Poster und Hintergrundbilder werden einmal geholt und danach
über `/api/image` vom eigenen Server ausgeliefert. Größe und Pfad werden
streng geprüft, damit der Endpunkt kein offener Proxy wird.
- **Suchseite** mit Typfilter; die Anfrage steht in der URL und ist damit
teilbar. Getippte Eingaben werden kurz gesammelt, bevor gesucht wird.
- **Detailseite** je Titel mit Backdrop, Kopfdaten, Genres, Streamingdiensten
(mit den Akzentfarben der großen Anbieter) und aufklappbaren Staffeln samt
Episodenliste.
- **Testlauf ohne Schlüssel**: nachgebildete TMDB-Antworten unter `tests/` und
`npm test` prüfen den kompletten Importpfad gegen eine echte Datenbank.
### Behoben
- `importSeries` gab den Titel zurück, bevor Staffel- und Episodenzähler
geschrieben waren — die Zähler standen dadurch auf 0.
- Poster und Dienstlogos zeigten das kaputte Bildsymbol des Browsers, wenn ein
TMDB-Pfad ins Leere lief. Die Rückfalldarstellung liegt jetzt dauerhaft
darunter statt an einem `onError`-Handler zu hängen, der bei einem früh
gescheiterten Bild gar nicht mehr feuert.
+51
View File
@@ -0,0 +1,51 @@
# Flimmer 0.3.0
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
### Hinzugefügt
- **Bibliothek** als Posterraster mit Volltextsuche und Filtern nach Status,
Typ, Genre, Streamingdienst und Tag; Sortierung nach zuletzt aktualisiert,
hinzugefügt, Titel, Bewertung oder Erscheinungsjahr. Der gesamte Filterstand
steht in der URL und ist damit teilbar.
- **Fünf Status** je Eintrag (Schaue gerade, Pausiert, Abgeschlossen,
Abgebrochen, Watchlist). Der Status ergibt sich beim Abhaken von selbst:
die erste Episode startet einen Titel, die letzte schließt ihn ab.
Pausiert und Abgebrochen bleiben unangetastet — wer bewusst pausiert hat,
will das nicht durch eine Sichtung aufgehoben bekommen.
- **Episodenfortschritt**: einzeln abhaken, ganze Staffel auf einmal,
„bis hierhin gesehen" für den Einstieg mitten in einer Staffel, und ein
**„+1 Episode"**-Knopf, der genau die nächste offene Episode nimmt.
Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
widerspricht; beim Abhaken glimmt die Zeile einmal warm auf.
- **„Als Nächstes dran"** mit Episodencode, Titel und Ausstrahlungsdatum;
Fortschritt in Prozent auf Basis der echten Episodenzahl, dazu die
verbleibende Laufzeit.
- **Rewatch-Zählung**: ein neuer Durchlauf setzt den Fortschritt zurück, die
Sichtungen des vorherigen bleiben für die Statistik erhalten. Bei Filmen
zählt jeder Durchlauf einzeln.
- **Rückgängig** für jede Sichtungsaktion — die Rückmeldung am unteren Rand
nennt, was passiert ist, und nimmt es auf Klick zurück.
- **Pro Eintrag**: Bewertung von 1 bis 10, Notizfeld, eigene Tags und das Feld
„wo schaue ich das", das beim Aufnehmen einen Vorschlag aus der
Streaming-Verfügbarkeit erhält und jederzeit überschreibbar ist.
- **Papierkorb** statt hartem Löschen: entfernte Titel bleiben 30 Tage liegen
und behalten dabei ihren Fortschritt. Wird ein Titel erneut aufgenommen,
lebt derselbe Eintrag wieder auf.
- **Titel selbst anlegen** für alles, was TMDB nicht kennt. Solche Einträge
haben keine TMDB-ID und werden vom Abgleich nie angefasst.
- **Seed mit Beispielbibliothek**: erfundener Katalog aus 8 Serien und
10 Filmen samt lokal erzeugten Postern, dazu 18 Bibliothekseinträge und
85 Sichtungen über die letzten Monate verteilt.
- **21 Tests** decken Aufnehmen, Abhaken, Fortschritt, Rewatch, Papierkorb und
den Zugriffsschutz zwischen Nutzern ab.
### Geändert
- Die Detailseite liegt jetzt unter `/titel/<id>` und hängt an der internen
Titel-ID statt an der TMDB-ID — nur so bekommen manuell angelegte Titel
dieselbe Seite. Treffer aus der Suche laufen über `/tmdb/<art>/<id>`, das
den Titel holt und weiterleitet.
- Die Zahl der Build-Worker ist auf vier begrenzt: mit einem Worker je Kern
stürzte der Build auf einer 18-Kern-Maschine reproduzierbar ab, weil jeder
Worker eine eigene Prisma-Engine hochzieht.
+35
View File
@@ -0,0 +1,35 @@
# Flimmer 0.4.0
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
### Hinzugefügt
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
Wizarding World oder was auch immer zusammengehört.
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig.
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
vorkommen, lassen sich mit einem Klick übernehmen.
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
Serien hinweg, Freigaben und die Rechteprüfung.
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
Serien und einer abweichenden Story-Chronologie.
### Behoben
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
serverseitig.
+45
View File
@@ -0,0 +1,45 @@
# Flimmer 0.5.0
Übersicht, Kalender und Statistik.
### Hinzugefügt
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
verfügbar geworden sind.
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
zählen mit.
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
Standardton.
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
„Weiterschauen" und den Kalenderzeitraum.
### Behoben
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
(„2026-04") aus der Datenbank.
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
hängt jetzt als `aria-label` am `<svg>`.
+46
View File
@@ -0,0 +1,46 @@
# Flimmer 0.6.0
Der Rest: Benachrichtigungen, Abgleich, Import und Export, Schnittstelle, PWA
— und eine bebilderte Anleitung.
### Hinzugefügt
- **Benachrichtigungen** über vier Wege: E-Mail (SMTP), ntfy, Gotify und ein
generischer Webhook, der zugleich Discord, Slack und Mattermost bedient.
Jeder Nutzer richtet seine Kanäle selbst ein, kann sie einzeln stummschalten
und mit einer Testnachricht ausprobieren. Dieselbe Meldung geht nie zweimal
raus — auch ein Fehlversuch wird protokolliert, sonst spammt der nächste Lauf
den Kanal voll, sobald er wieder erreichbar ist. Die E-Mail-Vorlage trägt
Logo und Farbwelt von Flimmer.
- **Täglicher Abgleich**: frischt laufende Serien bei TMDB auf, meldet neue
Episoden und neu verfügbare Watchlist-Titel, räumt Papierkorb und abgelaufene
Sitzungen. Eingebauter Zeitplan über `ENABLE_SCHEDULER` und `SYNC_HOUR`, von
außen anstoßbar über `/api/cron/sync` mit `CRON_SECRET`, und in der
Verwaltung per Knopfdruck. Ein einzelner Titel kann den Lauf nicht kippen.
- **Import** aus Trakt.tv (JSON, egal ob Watchlist, History, Ratings oder
Collection) und aus CSV mit Komma oder Semikolon. Der Import überschreibt
nichts: was schon in der Bibliothek liegt, bleibt unangetastet.
- **Export** der eigenen Daten als CSV oder als JSON mit allen Sichtungen und
Universen.
- **REST-API** unter `/api/v1` mit persönlichen API-Schlüsseln: Bibliothek
lesen und pflegen, Episoden abhaken und zurücknehmen, suchen, Kalender,
Statistik, Universen, Export. Beschreibung als OpenAPI 3.1 unter
`/api/v1/openapi.json`, dazu `docs/API.md` mit Beispielen.
- **Einstellungsseiten** für Profil, Region, Passwort, Benachrichtigungen,
API-Schlüssel sowie Import und Export.
- **Verwaltungsbereich**: Instanzname, Registrierung öffnen und schließen,
öffentliche Profile erlauben, Konten sperren und Rechte vergeben, Abgleich
anstoßen und der Zustand der Instanz auf einen Blick. Die letzte
Administration lässt sich nicht entmachten.
- **Öffentliche Profilseite** unter `/u/<adresse>` — Zahlen und zuletzt
Gesehenes, aber keine Notizen, keine Watchlist, keine E-Mail-Adresse.
Abschaltbar durch den Nutzer *und* instanzweit durch den Betreiber.
- **PWA-Feinschliff**: eigener Service Worker (Poster aus dem Zwischenspeicher,
Seiten zuerst aus dem Netz, Offline-Rückfall) und eine Offline-Seite.
- **`TMDB_BASE_URL`** für Instanzen hinter einem eigenen Proxy — und für den
mitgelieferten TMDB-Ersatz `scripts/tmdb-stub.mjs`, mit dem sich die Suche
ohne Schlüssel aus den Beispieldaten ausprobieren lässt.
- **Bebilderte Anleitung** `docs/ANLEITUNG.md` mit 18 Screenshots aus der
laufenden Instanz, aufgenommen von `npm run screenshots`.
- **9 weitere Tests** für CSV- und Trakt-Import, Export, das Aufräumen des
Papierkorbs und den Schutz vor Doppelversand — zusammen 49.
+34
View File
@@ -0,0 +1,34 @@
import sharp from "sharp";
import { readdir, unlink, stat } from "node:fs/promises";
import { join } from "node:path";
/**
* Verkleinert die aufgenommenen Screenshots für das Repository.
* Die Aufnahme läuft mit doppelter Pixeldichte (scharfe Schrift), abgelegt
* wird die halbe Kantenlänge als WebP — das spart rund 90 Prozent.
*/
const DIR = "docs/screenshots";
const files = (await readdir(DIR)).filter((name) => name.endsWith(".png"));
let before = 0;
let after = 0;
for (const name of files) {
const source = join(DIR, name);
before += (await stat(source)).size;
const image = sharp(source);
const meta = await image.metadata();
const target = join(DIR, name.replace(/\.png$/, ".webp"));
await image
.resize({ width: Math.round((meta.width ?? 1400) / 2) })
.webp({ quality: 82, effort: 5 })
.toFile(target);
after += (await stat(target)).size;
await unlink(source);
}
const mb = (bytes) => (bytes / 1024 / 1024).toFixed(1);
console.log(`${files.length} Bilder: ${mb(before)} MB -> ${mb(after)} MB`);
+99
View File
@@ -0,0 +1,99 @@
import { chromium } from "playwright";
import fs from "node:fs";
const OUT = "docs/screenshots";
const BASE = "http://localhost:3011";
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch();
const errs = [];
async function session(width, height, theme) {
const ctx = await browser.newContext({
viewport: { width, height },
deviceScaleFactor: 2,
colorScheme: theme === "light" ? "light" : "dark",
locale: "de-DE",
timezoneId: "Europe/Berlin",
});
await ctx.addInitScript((mode) => localStorage.setItem("flimmer-theme", mode), theme);
// Der Entwicklungs-Indikator von Next hat in einer Anleitung nichts verloren.
await ctx.addStyleTag?.({ content: "nextjs-portal { display: none !important; }" });
const page = await ctx.newPage();
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
page.on("pageerror", (e) => errs.push(String(e).slice(0, 160)));
return { ctx, page };
}
async function login(page) {
await page.goto(`${BASE}/anmelden`);
await page.fill("#email", "demo@flimmer.local");
await page.fill("#password", "flimmer-demo-2026");
await Promise.all([page.waitForURL(`${BASE}/`), page.click("button[type=submit]")]);
}
async function shot(page, name, url, opts = {}) {
await page.goto(`${BASE}${url}`, { waitUntil: "networkidle" });
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }).catch(() => {});
await page.waitForTimeout(opts.wait ?? 900);
if (opts.before) await opts.before(page);
await page.screenshot({ path: `${OUT}/${name}.png`, fullPage: Boolean(opts.full) });
console.log(" ", name);
}
// --- Anmeldung (ohne Sitzung) ---
{
const { ctx, page } = await session(1280, 800, "dark");
await shot(page, "01-anmeldung", "/anmelden");
await ctx.close();
}
// --- Angemeldet, dunkel ---
{
const { ctx, page } = await session(1400, 900, "dark");
await login(page);
await shot(page, "02-uebersicht", "/", { full: true });
await shot(page, "03-suche", "/suche?q=sternenpfad");
await shot(page, "05-bibliothek", "/bibliothek");
await shot(page, "09-kalender", "/kalender");
await shot(page, "10-statistik", "/statistik", { full: true });
await shot(page, "11-einstellungen", "/einstellungen");
await shot(page, "12-benachrichtigungen", "/einstellungen/benachrichtigungen");
await shot(page, "13-api", "/einstellungen/api");
await shot(page, "14-daten", "/einstellungen/daten");
await shot(page, "15-verwaltung", "/verwaltung", { full: true });
await shot(page, "16-papierkorb", "/papierkorb");
// Detailseite und Universum brauchen IDs aus der Bibliothek
await page.goto(`${BASE}/bibliothek?q=nordlicht`, { waitUntil: "networkidle" });
const titleHref = await page.locator("a[href^='/titel/']").first().getAttribute("href");
if (titleHref) await shot(page, "04-serie", titleHref, { full: true });
await page.goto(`${BASE}/universen`, { waitUntil: "networkidle" });
await shot(page, "06-universen", "/universen");
const uniHref = await page.locator("a[href^='/universen/']").first().getAttribute("href");
if (uniHref) await shot(page, "07-universum", uniHref, { full: true });
await ctx.close();
}
// --- Hell ---
{
const { ctx, page } = await session(1400, 900, "light");
await login(page);
await shot(page, "08-hell", "/bibliothek");
await ctx.close();
}
// --- Mobil ---
{
const { ctx, page } = await session(430, 900, "dark");
await login(page);
await shot(page, "17-mobil-uebersicht", "/");
await shot(page, "18-mobil-serie", "/bibliothek");
await ctx.close();
}
console.log("Fehler:", errs.length ? [...new Set(errs)].slice(0, 5) : "keine");
await browser.close();
+98
View File
@@ -0,0 +1,98 @@
/**
* Kleiner TMDB-Ersatz für Screenshots und manuelles Ausprobieren.
*
* node scripts/tmdb-stub.mjs [port]
*
* Beantwortet die Suchendpunkte aus dem lokalen Katalog (den das Seed-Skript
* angelegt hat) im Format der TMDB-API. Damit lässt sich die Suchseite auch
* ohne echten TMDB-Schlüssel zeigen — für Screenshots der Anleitung und um
* die Oberfläche auszuprobieren, bevor man einen Schlüssel besorgt.
*
* Verwendung zusammen mit:
* TMDB_API_KEY=stub TMDB_BASE_URL=http://127.0.0.1:3099/3 npm run dev
*/
import { createServer } from "node:http";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../src/generated/prisma/client.ts";
try {
process.loadEnvFile(".env");
} catch {
// Werte kommen aus der Umgebung.
}
const port = Number(process.argv[2] ?? 3099);
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
});
function toResult(title, mediaType) {
const date = title.releaseDate?.toISOString().slice(0, 10) ?? null;
return {
id: title.tmdbId,
media_type: mediaType,
name: mediaType === "tv" ? title.title : undefined,
title: mediaType === "movie" ? title.title : undefined,
original_name: title.originalTitle ?? undefined,
original_title: title.originalTitle ?? undefined,
overview: title.overview ?? "",
poster_path: title.posterPath,
backdrop_path: title.backdropPath,
first_air_date: mediaType === "tv" ? date : undefined,
release_date: mediaType === "movie" ? date : undefined,
vote_average: title.voteAverage ?? 0,
};
}
async function search(query, type) {
const titles = await prisma.title.findMany({
where: {
tmdbId: { not: null },
...(type ? { type } : {}),
OR: [
{ title: { contains: query, mode: "insensitive" } },
{ originalTitle: { contains: query, mode: "insensitive" } },
{ genres: { has: query } },
],
},
orderBy: { releaseDate: "desc" },
take: 20,
});
return titles.map((title) => toResult(title, title.type === "SERIES" ? "tv" : "movie"));
}
const server = createServer(async (request, response) => {
const url = new URL(request.url ?? "/", `http://localhost:${port}`);
const query = url.searchParams.get("query") ?? "";
const send = (body, status = 200) => {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
response.end(JSON.stringify(body));
};
try {
if (url.pathname.startsWith("/3/search/")) {
const kind = url.pathname.split("/").pop();
const type = kind === "tv" ? "SERIES" : kind === "movie" ? "MOVIE" : null;
const results = await search(query, type);
return send({ page: 1, results, total_pages: 1, total_results: results.length });
}
return send({ status_message: `Kein Ersatz für ${url.pathname}` }, 404);
} catch (error) {
return send({ status_message: String(error) }, 500);
}
});
server.listen(port, "127.0.0.1", () => {
console.log(`TMDB-Ersatz läuft auf http://127.0.0.1:${port}/3`);
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
server.close();
void prisma.$disconnect();
process.exit(0);
});
}
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
const [,, SP, ...specs] = process.argv;
const b = await chromium.launch();
const c = await b.newContext({ viewport: { width: 1280, height: 950 }, deviceScaleFactor: 2, colorScheme: "dark" });
const p = await c.newPage();
const errs = [];
p.on("pageerror", e => errs.push(String(e).slice(0,200)));
p.on("console", m => { if (m.type()==="error") errs.push("console: "+m.text().slice(0,160)); });
await p.goto("http://localhost:3011/anmelden");
await p.fill("#email", "demo@flimmer.local");
await p.fill("#password", "flimmer-demo-2026");
await Promise.all([p.waitForURL("http://localhost:3011/"), p.click("button[type=submit]")]);
for (const spec of specs) {
const [name, url, full] = spec.split("|");
await p.goto(`http://localhost:3011${url}`, { waitUntil: "networkidle" });
await p.waitForTimeout(900);
await p.screenshot({ path: `${SP}/${name}.png`, fullPage: full === "full" });
}
console.log("Fehler:", errs.length ? errs.slice(0,4) : "keine");
await b.close();
+285
View File
@@ -0,0 +1,285 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import {
addToLibrary,
LibraryError,
markEpisodesWatched,
markMovieWatched,
markSeasonWatched,
markUpTo,
moveToTrash,
purgeEntry,
restoreFromTrash,
setStatus,
startRewatch,
undoWatches,
unmarkEpisodes,
unmarkSeason,
updateEntryDetails,
watchNextEpisode,
type WatchResult,
} from "@/lib/library";
import { prisma } from "@/lib/db";
/**
* Server-Aktionen rund um die Bibliothek.
*
* Jede Aktion holt sich die Sitzung selbst — auf Angaben aus dem Formular ist
* beim Zugriffsschutz kein Verlass. Zurück kommt immer dieselbe Hülle, damit
* das UI Erfolg, Fehler und Rückgängig-Möglichkeit einheitlich behandeln kann.
*/
export type ActionResult = {
ok: boolean;
message?: string;
/** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */
watchIds?: string[];
};
async function run(
fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>,
revalidate: string[] = [],
): Promise<ActionResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
for (const path of revalidate) revalidatePath(path, "layout");
return { ok: true, message: result.message, watchIds: result.watchIds };
} catch (error) {
if (error instanceof LibraryError) return { ok: false, message: error.message };
console.error("Bibliotheksaktion fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
const PATHS = ["/", "/bibliothek", "/titel", "/papierkorb", "/kalender", "/statistik"];
const cuid = z.string().min(1).max(64);
const statusSchema = z.enum(["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"]);
// ---------------------------------------------------------------------------
// Aufnehmen und entfernen
// ---------------------------------------------------------------------------
export async function addTitleAction(titleId: string, status: string = "WATCHLIST") {
return run(async (userId) => {
const parsedStatus = statusSchema.parse(status);
await addToLibrary(userId, cuid.parse(titleId), parsedStatus);
return {
message:
parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt",
};
}, PATHS);
}
export async function trashEntryAction(entryId: string) {
return run(async (userId) => {
await moveToTrash(userId, cuid.parse(entryId));
return { message: "In den Papierkorb gelegt" };
}, PATHS);
}
export async function restoreEntryAction(entryId: string) {
return run(async (userId) => {
await restoreFromTrash(userId, cuid.parse(entryId));
return { message: "Wiederhergestellt" };
}, PATHS);
}
export async function purgeEntryAction(entryId: string) {
return run(async (userId) => {
await purgeEntry(userId, cuid.parse(entryId));
return { message: "Endgültig gelöscht" };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Status und Angaben
// ---------------------------------------------------------------------------
export async function setStatusAction(entryId: string, status: string) {
return run(async (userId) => {
await setStatus(userId, cuid.parse(entryId), statusSchema.parse(status));
return { message: "Status geändert" };
}, PATHS);
}
const detailsSchema = z.object({
rating: z.number().int().min(1).max(10).nullable().optional(),
notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(),
});
export async function updateEntryAction(entryId: string, data: unknown) {
return run(async (userId) => {
await updateEntryDetails(userId, cuid.parse(entryId), detailsSchema.parse(data));
return { message: "Gespeichert" };
}, PATHS);
}
export async function startRewatchAction(entryId: string) {
return run(async (userId) => {
const entry = await startRewatch(userId, cuid.parse(entryId));
return { message: `Durchlauf ${entry.currentRun + 1} gestartet` };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Sichtungen
// ---------------------------------------------------------------------------
function toResult(result: WatchResult) {
return { message: result.label, watchIds: result.watchIds };
}
export async function toggleEpisodeAction(entryId: string, episodeId: string, watched: boolean) {
return run(async (userId) => {
if (watched) {
return toResult(await markEpisodesWatched(userId, cuid.parse(entryId), [cuid.parse(episodeId)]));
}
await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]);
return { message: "Markierung entfernt" };
}, PATHS);
}
export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) {
return run(async (userId) => {
if (watched) {
return toResult(await markSeasonWatched(userId, cuid.parse(entryId), cuid.parse(seasonId)));
}
await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId));
return { message: "Staffel zurückgesetzt" };
}, PATHS);
}
export async function markUpToAction(entryId: string, episodeId: string) {
return run(
async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))),
PATHS,
);
}
export async function watchNextAction(entryId: string) {
return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS);
}
export async function markMovieAction(entryId: string) {
return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS);
}
export async function undoAction(entryId: string, watchIds: string[]) {
return run(async (userId) => {
await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds));
return { message: "Rückgängig gemacht" };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Tags
// ---------------------------------------------------------------------------
export async function setTagsAction(entryId: string, tagNames: string[]) {
return run(async (userId) => {
const id = cuid.parse(entryId);
const names = z
.array(z.string().trim().min(1).max(40))
.max(20)
.parse(tagNames)
.map((name) => name.toLocaleLowerCase("de-DE"));
const entry = await prisma.libraryEntry.findFirst({
where: { id, userId },
select: { id: true },
});
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
const tags = await Promise.all(
names.map((name) =>
prisma.tag.upsert({
where: { userId_name: { userId, name } },
create: { userId, name },
update: {},
select: { id: true },
}),
),
);
await prisma.$transaction([
prisma.tagOnEntry.deleteMany({
where: { entryId: id, tagId: { notIn: tags.map((tag) => tag.id) } },
}),
...tags.map((tag) =>
prisma.tagOnEntry.upsert({
where: { tagId_entryId: { tagId: tag.id, entryId: id } },
create: { tagId: tag.id, entryId: id },
update: {},
}),
),
]);
return { message: "Tags gespeichert" };
}, PATHS);
}
// ---------------------------------------------------------------------------
// Manuell angelegte Titel
// ---------------------------------------------------------------------------
const manualSchema = z.object({
type: z.enum(["SERIES", "MOVIE"]),
title: z.string().trim().min(1).max(200),
year: z.number().int().min(1880).max(2200).nullable().optional(),
runtime: z.number().int().min(1).max(2000).nullable().optional(),
overview: z.string().trim().max(4000).optional(),
genres: z.array(z.string().trim().min(1).max(40)).max(10).optional(),
status: statusSchema.default("WATCHLIST"),
});
/**
* Legt einen Titel ohne TMDB-Treffer an.
*
* Für alles, was TMDB nicht kennt: Heimvideos, Vorträge, regionale
* Produktionen. Solche Titel haben keine `tmdbId` und werden vom Sync-Lauf
* daher nie angefasst.
*/
export async function createManualTitleAction(data: unknown): Promise<ActionResult & { titleId?: string }> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
const parsed = manualSchema.safeParse(data);
if (!parsed.success) {
return { ok: false, message: "Bitte gib mindestens einen Titel an." };
}
const { type, title, year, runtime, overview, genres, status } = parsed.data;
try {
const created = await prisma.title.create({
data: {
type,
title,
overview: overview || null,
runtime: runtime ?? null,
releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null,
genres: genres ?? [],
status: "Manuell angelegt",
syncedAt: new Date(),
},
select: { id: true },
});
await addToLibrary(user.id, created.id, status);
for (const path of PATHS) revalidatePath(path, "layout");
return { ok: true, message: `„${title}" angelegt`, titleId: created.id };
} catch (error) {
console.error("Manuellen Titel anlegen fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
+306
View File
@@ -0,0 +1,306 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import {
checkPasswordStrength,
createApiKey,
destroyAllSessions,
getSessionUser,
hashPassword,
verifyPassword,
} from "@/lib/auth";
import { prisma } from "@/lib/db";
import { setSetting } from "@/lib/settings";
import { sendTestNotification } from "@/lib/notify";
import { importCsv, importTrakt, type ImportSummary } from "@/lib/transfer";
import { runSync } from "@/lib/sync";
/** Server-Aktionen für Einstellungen, Kanäle, API-Schlüssel und Verwaltung. */
export type SettingsResult = { ok: boolean; message?: string; secret?: string };
async function run(
fn: (userId: string) => Promise<{ message?: string; secret?: string } | void>,
): Promise<SettingsResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
revalidatePath("/einstellungen", "layout");
revalidatePath("/", "layout");
return { ok: true, ...result };
} catch (error) {
console.error("Einstellungs-Aktion fehlgeschlagen:", error);
return {
ok: false,
message: error instanceof Error ? error.message : "Das hat nicht geklappt.",
};
}
}
async function requireAdminUser() {
const user = await getSessionUser();
if (!user || user.role !== "ADMIN") {
throw new Error("Dafür fehlen dir die Rechte.");
}
return user;
}
// ---------------------------------------------------------------------------
// Profil
// ---------------------------------------------------------------------------
const profileSchema = z.object({
name: z.string().trim().min(1).max(80),
region: z.string().trim().length(2).toUpperCase(),
publicProfile: z.boolean(),
profileSlug: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-z0-9-]{3,40}$/, "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 40 Zeichen.")
.nullable(),
notifyNewEpisode: z.boolean(),
notifyRelease: z.boolean(),
notifyAvailability: z.boolean(),
});
export async function updateProfileAction(data: unknown) {
return run(async (userId) => {
const parsed = profileSchema.parse(data);
if (parsed.publicProfile && !parsed.profileSlug) {
throw new Error("Für ein öffentliches Profil braucht es eine Adresse.");
}
if (parsed.profileSlug) {
const taken = await prisma.user.findFirst({
where: { profileSlug: parsed.profileSlug, id: { not: userId } },
select: { id: true },
});
if (taken) throw new Error("Diese Profiladresse ist schon vergeben.");
}
await prisma.user.update({ where: { id: userId }, data: parsed });
return { message: "Gespeichert" };
});
}
const passwordSchema = z.object({
current: z.string().min(1),
next: z.string().min(1),
repeat: z.string().min(1),
});
export async function changePasswordAction(data: unknown) {
return run(async (userId) => {
const parsed = passwordSchema.parse(data);
if (parsed.next !== parsed.repeat) throw new Error("Die neuen Passwörter stimmen nicht überein.");
const weak = checkPasswordStrength(parsed.next);
if (weak) throw new Error(weak);
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
select: { passwordHash: true },
});
if (!user.passwordHash) {
throw new Error("Dieses Konto meldet sich über SSO an und hat kein Passwort.");
}
if (!(await verifyPassword(parsed.current, user.passwordHash))) {
throw new Error("Das bisherige Passwort stimmt nicht.");
}
await prisma.user.update({
where: { id: userId },
data: { passwordHash: await hashPassword(parsed.next) },
});
// Nach einem Passwortwechsel fliegen alle anderen Geräte raus.
await destroyAllSessions(userId);
return { message: "Passwort geändert — bitte neu anmelden." };
});
}
// ---------------------------------------------------------------------------
// API-Schlüssel
// ---------------------------------------------------------------------------
export async function createApiKeyAction(name: string) {
return run(async (userId) => {
const clean = z.string().trim().min(1).max(60).parse(name);
const { plain } = await createApiKey(userId, clean);
// Der Klartext existiert nur hier — danach steht in der Datenbank nur der Hash.
return { message: "Schlüssel angelegt", secret: plain };
});
}
export async function revokeApiKeyAction(keyId: string) {
return run(async (userId) => {
await prisma.apiKey.updateMany({
where: { id: keyId, userId },
data: { revokedAt: new Date() },
});
return { message: "Schlüssel zurückgezogen" };
});
}
// ---------------------------------------------------------------------------
// Benachrichtigungskanäle
// ---------------------------------------------------------------------------
const channelSchema = z.object({
kind: z.enum(["EMAIL", "NTFY", "GOTIFY", "WEBHOOK"]),
name: z.string().trim().min(1).max(60),
config: z.record(z.string(), z.string().max(500)).default({}),
});
export async function saveChannelAction(data: unknown, channelId?: string) {
return run(async (userId) => {
const parsed = channelSchema.parse(data);
if (channelId) {
await prisma.notificationChannel.updateMany({
where: { id: channelId, userId },
data: { kind: parsed.kind, name: parsed.name, config: parsed.config },
});
return { message: "Kanal gespeichert" };
}
await prisma.notificationChannel.create({
data: { userId, kind: parsed.kind, name: parsed.name, config: parsed.config },
});
return { message: "Kanal angelegt" };
});
}
export async function toggleChannelAction(channelId: string, enabled: boolean) {
return run(async (userId) => {
await prisma.notificationChannel.updateMany({
where: { id: channelId, userId },
data: { enabled },
});
return { message: enabled ? "Kanal aktiv" : "Kanal ruht" };
});
}
export async function deleteChannelAction(channelId: string) {
return run(async (userId) => {
await prisma.notificationChannel.deleteMany({ where: { id: channelId, userId } });
return { message: "Kanal entfernt" };
});
}
export async function testChannelAction(channelId: string) {
return run(async (userId) => {
await sendTestNotification(userId, channelId);
return { message: "Testnachricht verschickt" };
});
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
export type ImportResult = { ok: boolean; message?: string; summary?: ImportSummary };
export async function importDataAction(
format: "csv" | "trakt",
content: string,
): Promise<ImportResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
if (content.length > 8_000_000) {
return { ok: false, message: "Die Datei ist zu groß (mehr als 8 MB)." };
}
try {
const summary = format === "csv" ? await importCsv(user.id, content) : await importTrakt(user.id, content);
revalidatePath("/bibliothek", "layout");
revalidatePath("/", "layout");
return {
ok: true,
message: `${summary.added} aufgenommen, ${summary.skipped} übersprungen`,
summary,
};
} catch (error) {
console.error("Import fehlgeschlagen:", error);
return { ok: false, message: error instanceof Error ? error.message : "Import fehlgeschlagen." };
}
}
// ---------------------------------------------------------------------------
// Verwaltung
// ---------------------------------------------------------------------------
export async function setRegistrationAction(enabled: boolean) {
return run(async () => {
await requireAdminUser();
await setSetting("registrationEnabled", enabled);
return { message: enabled ? "Registrierung offen" : "Registrierung geschlossen" };
});
}
export async function setInstanceNameAction(name: string) {
return run(async () => {
await requireAdminUser();
await setSetting("instanceName", z.string().trim().min(1).max(60).parse(name));
return { message: "Name gespeichert" };
});
}
export async function setPublicProfilesAction(enabled: boolean) {
return run(async () => {
await requireAdminUser();
await setSetting("publicProfilesEnabled", enabled);
return { message: enabled ? "Öffentliche Profile erlaubt" : "Öffentliche Profile abgeschaltet" };
});
}
export async function setUserActiveAction(targetId: string, active: boolean) {
return run(async (userId) => {
await requireAdminUser();
if (targetId === userId) throw new Error("Dich selbst kannst du nicht sperren.");
await prisma.user.update({ where: { id: targetId }, data: { isActive: active } });
if (!active) await destroyAllSessions(targetId);
revalidatePath("/verwaltung", "layout");
return { message: active ? "Konto freigegeben" : "Konto gesperrt" };
});
}
export async function setUserRoleAction(targetId: string, role: "ADMIN" | "USER") {
return run(async (userId) => {
await requireAdminUser();
if (targetId === userId) throw new Error("Deine eigene Rolle kannst du nicht ändern.");
if (role === "USER") {
const admins = await prisma.user.count({ where: { role: "ADMIN", isActive: true } });
if (admins <= 1) throw new Error("Es muss mindestens eine Administration geben.");
}
await prisma.user.update({ where: { id: targetId }, data: { role } });
revalidatePath("/verwaltung", "layout");
return { message: role === "ADMIN" ? "Zur Administration ernannt" : "Rechte entzogen" };
});
}
export async function runSyncAction() {
return run(async () => {
await requireAdminUser();
const stats = await runSync({ limit: 40 });
revalidatePath("/verwaltung", "layout");
return {
message:
stats.errors.length === 0
? `${stats.titlesUpdated} Titel aufgefrischt, ${stats.newEpisodes} neue Episoden, ${stats.notificationsSent} Benachrichtigungen`
: `Mit ${stats.errors.length} Fehlern beendet: ${stats.errors[0]}`,
};
});
}
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import {
addCollection,
addTitle,
createOrder,
createUniverse,
deleteOrder,
removeTitle,
renameOrder,
renameUniverse,
setOrderPositions,
shareUniverse,
trashUniverse,
unshareUniverse,
UniverseError,
} from "@/lib/universes";
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
async function run(
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
): Promise<UniverseActionResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
revalidatePath("/universen", "layout");
revalidatePath("/", "layout");
return { ok: true, message: result.message, id: result.id };
} catch (error) {
if (error instanceof UniverseError) return { ok: false, message: error.message };
console.error("Universums-Aktion fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
const id = z.string().min(1).max(64);
const name = z.string().trim().min(1).max(120);
export async function createUniverseAction(rawName: string, description?: string) {
return run(async (userId) => {
const universe = await createUniverse(userId, name.parse(rawName), description);
return { message: `„${universe.name}" angelegt`, id: universe.id };
});
}
export async function renameUniverseAction(
universeId: string,
data: { name?: string; description?: string | null },
) {
return run(async (userId) => {
await renameUniverse(userId, id.parse(universeId), {
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
...(data.description !== undefined
? { description: z.string().max(2000).nullable().parse(data.description) }
: {}),
});
return { message: "Gespeichert" };
});
}
export async function trashUniverseAction(universeId: string) {
return run(async (userId) => {
await trashUniverse(userId, id.parse(universeId));
return { message: "Universum gelöscht" };
});
}
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
return run(async (userId) => {
await addTitle(userId, id.parse(universeId), id.parse(titleId));
return { message: "Aufgenommen" };
});
}
export async function removeFromUniverseAction(universeId: string, itemId: string) {
return run(async (userId) => {
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
return { message: "Aus dem Universum entfernt" };
});
}
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
return run(async (userId) => {
const added = await addCollection(
userId,
id.parse(universeId),
z.number().int().parse(tmdbCollectionId),
);
return {
message:
added === 0
? "Alles daraus war schon drin"
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
};
});
}
export async function createOrderAction(universeId: string, rawName: string) {
return run(async (userId) => {
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
return { message: "Reihenfolge angelegt", id: order.id };
});
}
export async function renameOrderAction(orderId: string, rawName: string) {
return run(async (userId) => {
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
return { message: "Umbenannt" };
});
}
export async function deleteOrderAction(orderId: string) {
return run(async (userId) => {
await deleteOrder(userId, id.parse(orderId));
return { message: "Reihenfolge gelöscht" };
});
}
export async function reorderAction(orderId: string, itemIds: string[]) {
return run(async (userId) => {
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
return { message: "Reihenfolge gespeichert" };
});
}
export async function shareUniverseAction(
universeId: string,
email: string,
permission: "READ" | "WRITE",
) {
return run(async (userId) => {
const target = await shareUniverse(
userId,
id.parse(universeId),
z.string().trim().email().parse(email),
z.enum(["READ", "WRITE"]).parse(permission),
);
return { message: `Mit ${target.name} geteilt` };
});
}
export async function unshareUniverseAction(universeId: string, memberId: string) {
return run(async (userId) => {
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
return { message: "Freigabe aufgehoben" };
});
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import { getSessionUser } from "@/lib/auth";
import { runSync } from "@/lib/sync";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
/**
* Abgleich von außen anstoßen.
*
* curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
* https://flimmer.example/api/cron/sync
*
* Zugelassen ist entweder das Cron-Geheimnis oder eine angemeldete
* Administration — Letzteres für den Knopf im Verwaltungsbereich.
*/
async function authorize(request: Request): Promise<boolean> {
const header = request.headers.get("authorization");
if (env.scheduler.cronSecret && header === `Bearer ${env.scheduler.cronSecret}`) return true;
const user = await getSessionUser();
return user?.role === "ADMIN";
}
export async function POST(request: Request) {
if (!(await authorize(request))) {
return NextResponse.json({ error: "Nicht berechtigt." }, { status: 401 });
}
const stats = await runSync();
return NextResponse.json({ ok: stats.errors.length === 0, ...stats });
}
/** GET erlaubt einfache Uptime-Cron-Dienste, die kein POST können. */
export async function GET(request: Request) {
return POST(request);
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getCachedImage, isValidImagePath, isValidSize } from "@/lib/images";
/**
* Ausliefern zwischengespeicherter TMDB-Bilder.
*
* /api/image?size=w342&path=/abc123.jpg
*
* Größe und Pfad werden streng geprüft — der Endpunkt soll kein offener Proxy
* für beliebige Adressen werden.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const size = url.searchParams.get("size") ?? "w342";
const path = url.searchParams.get("path") ?? "";
if (!isValidSize(size) || !isValidImagePath(path)) {
return NextResponse.json({ error: "Ungültige Bildanfrage." }, { status: 400 });
}
try {
const image = await getCachedImage(size, path);
return new NextResponse(new Uint8Array(image.data), {
headers: {
"content-type": image.contentType,
// Bilder unter einem TMDB-Pfad ändern sich nicht mehr.
"cache-control": "public, max-age=31536000, immutable",
"x-flimmer-cache": image.fromCache ? "hit" : "miss",
},
});
} catch {
return NextResponse.json({ error: "Bild nicht verfügbar." }, { status: 404 });
}
}
@@ -0,0 +1,85 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { prisma } from "@/lib/db";
import {
getNextEpisode,
getProgress,
moveToTrash,
setStatus,
updateEntryDetails,
} from "@/lib/library";
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
/** GET /api/v1/bibliothek/{id} — ein Eintrag mit Fortschritt und nächster Episode. */
export const GET = withApi(async (_request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const entry = await prisma.libraryEntry.findFirst({
where: { id: entryId, userId: user.id },
include: {
title: true,
tags: { include: { tag: { select: { name: true } } } },
},
});
if (!entry) return apiError("Diesen Eintrag gibt es nicht.", 404);
const [progress, next] = await Promise.all([
getProgress(user.id, entry.id),
getNextEpisode(user.id, entry.id),
]);
return {
id: entry.id,
status: entry.status,
rating: entry.rating,
notes: entry.notes,
whereToWatch: entry.whereToWatch,
favorite: entry.favorite,
currentRun: entry.currentRun,
tags: entry.tags.map((link) => link.tag.name),
title: {
id: entry.title.id,
title: entry.title.title,
type: entry.title.type,
tmdbId: entry.title.tmdbId,
year: entry.title.releaseDate?.getFullYear() ?? null,
genres: entry.title.genres,
},
progress,
nextEpisode: next,
};
});
const patchSchema = z.object({
status: z.enum(STATUSES).optional(),
rating: z.number().int().min(1).max(10).nullable().optional(),
notes: z.string().max(5000).nullable().optional(),
whereToWatch: z.string().max(120).nullable().optional(),
favorite: z.boolean().optional(),
});
/** PATCH /api/v1/bibliothek/{id} — Status und Angaben ändern. */
export const PATCH = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const { status, ...details } = await readJson(request, patchSchema);
if (status) await setStatus(user.id, entryId, status);
if (Object.keys(details).length > 0) {
await updateEntryDetails(user.id, entryId, details);
}
return { ok: true };
});
/** DELETE /api/v1/bibliothek/{id} — in den Papierkorb legen. */
export const DELETE = withApi(async (_request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
await moveToTrash(user.id, entryId);
return { ok: true, message: "In den Papierkorb gelegt." };
});
@@ -0,0 +1,93 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { prisma } from "@/lib/db";
import {
markEpisodesWatched,
markMovieWatched,
markSeasonWatched,
markUpTo,
undoWatches,
unmarkEpisodes,
watchNextEpisode,
} from "@/lib/library";
/** GET — die Sichtungen eines Eintrags, jüngste zuerst. */
export const GET = withApi(async (_request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const watches = await prisma.watch.findMany({
where: { userId: user.id, entryId },
orderBy: { watchedAt: "desc" },
take: 500,
include: { episode: { select: { seasonNumber: true, episodeNumber: true, name: true } } },
});
return {
total: watches.length,
items: watches.map((watch) => ({
id: watch.id,
run: watch.run,
watchedAt: watch.watchedAt,
minutes: watch.minutes,
episode: watch.episode,
})),
};
});
const postSchema = z.union([
z.object({ action: z.literal("next") }),
z.object({ action: z.literal("movie") }),
z.object({
action: z.literal("episodes"),
episodeIds: z.array(z.string().min(1)).min(1).max(500),
}),
z.object({ action: z.literal("season"), seasonId: z.string().min(1) }),
z.object({ action: z.literal("upTo"), episodeId: z.string().min(1) }),
]);
/**
* POST — etwas als gesehen markieren.
*
* Die Antwort enthält immer `watchIds`. Damit lässt sich die Aktion über
* DELETE gezielt zurücknehmen, ohne raten zu müssen, was dazugehörte.
*/
export const POST = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const body = await readJson(request, postSchema);
switch (body.action) {
case "next":
return watchNextEpisode(user.id, entryId);
case "movie":
return markMovieWatched(user.id, entryId);
case "episodes":
return markEpisodesWatched(user.id, entryId, body.episodeIds);
case "season":
return markSeasonWatched(user.id, entryId, body.seasonId);
case "upTo":
return markUpTo(user.id, entryId, body.episodeId);
}
});
const deleteSchema = z.union([
z.object({ watchIds: z.array(z.string().min(1)).min(1).max(500) }),
z.object({ episodeIds: z.array(z.string().min(1)).min(1).max(500) }),
]);
/** DELETE — Sichtungen zurücknehmen, über watchIds oder episodeIds. */
export const DELETE = withApi(async (request, user, context) => {
const { entryId } = await context.params;
if (!entryId) return apiError("Es fehlt die Kennung des Eintrags.", 400);
const body = await readJson(request, deleteSchema);
if ("watchIds" in body) {
await undoWatches(user.id, entryId, body.watchIds);
} else {
await unmarkEpisodes(user.id, entryId, body.episodeIds);
}
return { ok: true };
});
+63
View File
@@ -0,0 +1,63 @@
import { z } from "zod";
import { readJson, readPaging, withApi } from "@/lib/api";
import { getLibrary } from "@/lib/queries";
import { addToLibrary } from "@/lib/library";
import { ensureTitle } from "@/lib/catalog";
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"] as const;
/** GET /api/v1/bibliothek — Bibliothek lesen, mit denselben Filtern wie im UI. */
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const { limit, offset } = readPaging(request);
const status = url.searchParams.get("status");
const type = url.searchParams.get("typ");
const items = await getLibrary(user.id, {
query: url.searchParams.get("q") ?? undefined,
status: STATUSES.includes(status as EntryStatus) ? (status as EntryStatus) : undefined,
type: type === "SERIES" || type === "MOVIE" ? (type as TitleType) : undefined,
genre: url.searchParams.get("genre") ?? undefined,
tag: url.searchParams.get("tag") ?? undefined,
provider: url.searchParams.get("dienst") ?? undefined,
});
return {
total: items.length,
limit,
offset,
items: items.slice(offset, offset + limit),
};
});
const addSchema = z
.object({
titleId: z.string().min(1).optional(),
tmdbId: z.number().int().positive().optional(),
type: z.enum(["SERIES", "MOVIE"]).optional(),
status: z.enum(STATUSES).default("WATCHLIST"),
})
.refine((value) => Boolean(value.titleId) || Boolean(value.tmdbId && value.type), {
message: "Entweder titleId oder tmdbId zusammen mit type angeben.",
});
/** POST /api/v1/bibliothek — Titel aufnehmen, per interner ID oder TMDB-ID. */
export const POST = withApi(async (request, user) => {
const body = await readJson(request, addSchema);
let titleId = body.titleId;
if (!titleId && body.tmdbId && body.type) {
const title = await ensureTitle(body.type, body.tmdbId, { region: user.region });
titleId = title.id;
}
if (!titleId) {
throw new z.ZodError([
{ code: "custom", path: ["titleId"], message: "Kein Titel angegeben." },
]);
}
const entry = await addToLibrary(user.id, titleId, body.status);
return { id: entry.id, titleId: entry.titleId, status: entry.status };
});
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { withApi } from "@/lib/api";
import { exportCsv, exportJson } from "@/lib/transfer";
/**
* GET /api/v1/export?format=json|csv
*
* Vollständiger Export der eigenen Daten. JSON enthält zusätzlich Sichtungen
* und Universen, CSV ist die schlanke Tabellenform für Tabellenkalkulationen.
*/
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const format = url.searchParams.get("format") === "csv" ? "csv" : "json";
const stamp = new Date().toISOString().slice(0, 10);
if (format === "csv") {
const csv = await exportCsv(user.id);
return new NextResponse(csv, {
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": `attachment; filename="flimmer-${stamp}.csv"`,
},
});
}
const data = await exportJson(user.id);
return new NextResponse(JSON.stringify(data, null, 2), {
headers: {
"content-type": "application/json; charset=utf-8",
"content-disposition": `attachment; filename="flimmer-${stamp}.json"`,
},
});
});
+29
View File
@@ -0,0 +1,29 @@
import { withApi } from "@/lib/api";
import { getUpcomingEpisodes } from "@/lib/stats";
/**
* GET /api/v1/kalender?von=2026-09-01&bis=2026-09-30
*
* Kommende Episoden der getrackten Serien. Ohne Angabe: die nächsten 30 Tage.
*/
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const parse = (value: string | null, fallback: Date) => {
if (!value) return fallback;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? fallback : date;
};
const from = parse(url.searchParams.get("von"), new Date());
const to = parse(url.searchParams.get("bis"), new Date(Date.now() + 30 * 86_400_000));
const episodes = await getUpcomingEpisodes(user.id, from, to);
return {
from: from.toISOString(),
to: to.toISOString(),
total: episodes.length,
items: episodes,
};
});
+19
View File
@@ -0,0 +1,19 @@
import { withApi } from "@/lib/api";
import { prisma } from "@/lib/db";
/** GET /api/v1/me — wer bin ich, und wie groß ist meine Bibliothek? */
export const GET = withApi(async (_request, user) => {
const [entries, watches] = await Promise.all([
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
prisma.watch.count({ where: { userId: user.id } }),
]);
return {
id: user.id,
name: user.name,
email: user.email,
role: user.role,
region: user.region,
library: { entries, watches },
};
});
+310
View File
@@ -0,0 +1,310 @@
import { NextResponse } from "next/server";
import { env } from "@/lib/env";
import pkg from "../../../../../package.json";
/**
* Maschinenlesbare Beschreibung der REST-API.
*
* Handgeschrieben statt aus Zod erzeugt: die Schnittstelle ist überschaubar,
* und ein Generator brächte eine weitere Abhängigkeit samt Versionsrhythmus
* für einen Nutzen, den ein Dutzend Zeilen JSON auch hergeben.
*/
export const dynamic = "force-dynamic";
export function GET() {
const document = {
openapi: "3.1.0",
info: {
title: "Flimmer API",
version: pkg.version,
description:
"REST-Schnittstelle einer Flimmer-Instanz. Anmeldung über einen persönlichen " +
"API-Schlüssel (Einstellungen → API-Schlüssel) als Bearer-Token. Alle Antworten " +
"sind JSON; Zeitangaben in ISO 8601, Laufzeiten in Minuten.",
license: { name: "MIT" },
},
servers: [{ url: `${env.appUrl}/api/v1` }],
security: [{ bearerAuth: [] }],
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer", description: "Persönlicher API-Schlüssel" },
},
schemas: {
Fehler: {
type: "object",
properties: { error: { type: "string" }, details: {} },
required: ["error"],
},
Sichtungsergebnis: {
type: "object",
properties: {
watchIds: {
type: "array",
items: { type: "string" },
description: "Kennungen der neu angelegten Sichtungen — für DELETE zum Zurücknehmen.",
},
status: { type: "string" },
label: { type: "string" },
},
},
},
},
paths: {
"/me": {
get: {
summary: "Angemeldetes Konto",
responses: { "200": { description: "Konto samt Umfang der Bibliothek" } },
},
},
"/bibliothek": {
get: {
summary: "Bibliothek lesen",
parameters: [
{ name: "q", in: "query", schema: { type: "string" }, description: "Volltextsuche" },
{
name: "status",
in: "query",
schema: {
type: "string",
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
},
},
{ name: "typ", in: "query", schema: { type: "string", enum: ["SERIES", "MOVIE"] } },
{ name: "genre", in: "query", schema: { type: "string" } },
{ name: "tag", in: "query", schema: { type: "string" } },
{ name: "dienst", in: "query", schema: { type: "string" } },
{ name: "limit", in: "query", schema: { type: "integer", default: 50, maximum: 200 } },
{ name: "offset", in: "query", schema: { type: "integer", default: 0 } },
],
responses: { "200": { description: "Einträge mit Fortschritt" } },
},
post: {
summary: "Titel aufnehmen",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
titleId: { type: "string", description: "Interne Kennung" },
tmdbId: { type: "integer", description: "Alternativ: TMDB-Kennung" },
type: { type: "string", enum: ["SERIES", "MOVIE"] },
status: {
type: "string",
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
default: "WATCHLIST",
},
},
},
},
},
},
responses: { "200": { description: "Angelegter oder vorhandener Eintrag" } },
},
},
"/bibliothek/{entryId}": {
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
get: { summary: "Eintrag mit Fortschritt", responses: { "200": { description: "Eintrag" } } },
patch: {
summary: "Status, Bewertung, Notiz ändern",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
status: {
type: "string",
enum: ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"],
},
rating: { type: "integer", minimum: 1, maximum: 10, nullable: true },
notes: { type: "string", nullable: true },
whereToWatch: { type: "string", nullable: true },
favorite: { type: "boolean" },
},
},
},
},
},
responses: { "200": { description: "Gespeichert" } },
},
delete: {
summary: "In den Papierkorb legen",
responses: { "200": { description: "Verschoben" } },
},
},
"/bibliothek/{entryId}/sichtungen": {
parameters: [{ name: "entryId", in: "path", required: true, schema: { type: "string" } }],
get: { summary: "Sichtungen auflisten", responses: { "200": { description: "Sichtungen" } } },
post: {
summary: "Als gesehen markieren",
description:
"action=next hakt die nächste offene Episode ab, movie einen Film, " +
"episodes eine Auswahl, season eine ganze Staffel, upTo alles bis zur genannten Episode.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
oneOf: [
{
type: "object",
properties: { action: { const: "next" } },
required: ["action"],
},
{
type: "object",
properties: { action: { const: "movie" } },
required: ["action"],
},
{
type: "object",
properties: {
action: { const: "episodes" },
episodeIds: { type: "array", items: { type: "string" } },
},
required: ["action", "episodeIds"],
},
{
type: "object",
properties: { action: { const: "season" }, seasonId: { type: "string" } },
required: ["action", "seasonId"],
},
{
type: "object",
properties: { action: { const: "upTo" }, episodeId: { type: "string" } },
required: ["action", "episodeId"],
},
],
},
},
},
},
responses: {
"200": {
description: "Angelegte Sichtungen",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Sichtungsergebnis" },
},
},
},
},
},
delete: {
summary: "Sichtungen zurücknehmen",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
oneOf: [
{
type: "object",
properties: { watchIds: { type: "array", items: { type: "string" } } },
required: ["watchIds"],
},
{
type: "object",
properties: { episodeIds: { type: "array", items: { type: "string" } } },
required: ["episodeIds"],
},
],
},
},
},
},
responses: { "200": { description: "Zurückgenommen" } },
},
},
"/suche": {
get: {
summary: "TMDB durchsuchen",
parameters: [
{ name: "q", in: "query", required: true, schema: { type: "string", minLength: 2 } },
{ name: "typ", in: "query", schema: { type: "string", enum: ["serie", "film"] } },
],
responses: {
"200": { description: "Treffer mit TMDB-Kennung" },
"503": { description: "Kein TMDB-Schlüssel hinterlegt" },
},
},
},
"/kalender": {
get: {
summary: "Kommende Episoden",
parameters: [
{ name: "von", in: "query", schema: { type: "string", format: "date" } },
{ name: "bis", in: "query", schema: { type: "string", format: "date" } },
],
responses: { "200": { description: "Termine im Zeitraum" } },
},
},
"/statistik": {
get: {
summary: "Kennzahlen und Verteilungen",
parameters: [{ name: "jahr", in: "query", schema: { type: "integer" } }],
responses: { "200": { description: "Statistik" } },
},
},
"/universen": {
get: { summary: "Universen auflisten", responses: { "200": { description: "Universen" } } },
post: {
summary: "Universum anlegen",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { name: { type: "string" }, description: { type: "string" } },
required: ["name"],
},
},
},
},
responses: { "200": { description: "Angelegt" } },
},
},
"/universen/{id}": {
parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
get: {
summary: "Universum mit Reihenfolge und Fortschritt",
parameters: [{ name: "reihenfolge", in: "query", schema: { type: "string" } }],
responses: { "200": { description: "Universum" }, "404": { description: "Unbekannt" } },
},
post: {
summary: "Titel ins Universum aufnehmen",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: { titleId: { type: "string" } },
required: ["titleId"],
},
},
},
},
responses: { "200": { description: "Aufgenommen" } },
},
},
"/export": {
get: {
summary: "Eigene Daten exportieren",
parameters: [
{ name: "format", in: "query", schema: { type: "string", enum: ["json", "csv"] } },
],
responses: { "200": { description: "Datei" } },
},
},
},
};
return NextResponse.json(document, {
headers: { "cache-control": "public, max-age=300" },
});
}
+25
View File
@@ -0,0 +1,25 @@
import { withApi } from "@/lib/api";
import {
getGenreDistribution,
getMonthlyActivity,
getProviderDistribution,
getTotals,
getYearReview,
} from "@/lib/stats";
/** GET /api/v1/statistik?jahr=2026 — alle Kennzahlen auf einmal. */
export const GET = withApi(async (request, user) => {
const url = new URL(request.url);
const parsed = Number.parseInt(url.searchParams.get("jahr") ?? "", 10);
const year = Number.isFinite(parsed) ? parsed : new Date().getFullYear();
const [totals, monthly, providers, genres, review] = await Promise.all([
getTotals(user.id),
getMonthlyActivity(user.id, 12),
getProviderDistribution(user.id),
getGenreDistribution(user.id),
getYearReview(user.id, year),
]);
return { totals, monthly, providers, genres, yearReview: review };
});
+53
View File
@@ -0,0 +1,53 @@
import { apiError, withApi } from "@/lib/api";
import { env } from "@/lib/env";
import { searchMovies, searchMulti, searchSeries, TmdbNotConfiguredError } from "@/lib/tmdb";
/**
* GET /api/v1/suche?q=…&typ=serie|film
*
* Durchsucht TMDB. Die Treffer tragen die TMDB-ID — damit lässt sich der
* Titel über POST /api/v1/bibliothek direkt aufnehmen.
*/
export const GET = withApi(async (request) => {
const url = new URL(request.url);
const query = (url.searchParams.get("q") ?? "").trim();
const type = url.searchParams.get("typ");
if (query.length < 2) {
return apiError("Der Suchbegriff braucht mindestens zwei Zeichen.", 400);
}
if (!env.tmdb.isConfigured) {
return apiError("Auf dieser Instanz ist kein TMDB-Schlüssel hinterlegt.", 503);
}
try {
const data =
type === "serie"
? await searchSeries(query)
: type === "film"
? await searchMovies(query)
: await searchMulti(query);
return {
query,
total: data.total_results,
items: data.results.map((item) => {
const isSeries =
item.media_type === "tv" || (type === "serie" && item.media_type === undefined);
const date = isSeries ? item.first_air_date : item.release_date;
return {
tmdbId: item.id,
type: isSeries ? "SERIES" : "MOVIE",
title: item.name ?? item.title ?? "Ohne Titel",
overview: item.overview ?? null,
posterPath: item.poster_path ?? null,
year: date ? Number.parseInt(date.slice(0, 4), 10) : null,
voteAverage: item.vote_average ?? null,
};
}),
};
} catch (error) {
if (error instanceof TmdbNotConfiguredError) return apiError(error.message, 503);
return apiError(error instanceof Error ? error.message : "Die Suche ist fehlgeschlagen.", 502);
}
});
+27
View File
@@ -0,0 +1,27 @@
import { z } from "zod";
import { apiError, readJson, withApi } from "@/lib/api";
import { addTitle, getUniverse } from "@/lib/universes";
/** GET /api/v1/universen/{id}?reihenfolge=… — Inhalt und Fortschritt. */
export const GET = withApi(async (request, user, context) => {
const { id } = await context.params;
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
const url = new URL(request.url);
const universe = await getUniverse(user.id, id, url.searchParams.get("reihenfolge") ?? undefined);
if (!universe) return apiError("Dieses Universum gibt es nicht.", 404);
return universe;
});
const addSchema = z.object({ titleId: z.string().min(1) });
/** POST /api/v1/universen/{id} — Titel aufnehmen. */
export const POST = withApi(async (request, user, context) => {
const { id } = await context.params;
if (!id) return apiError("Es fehlt die Kennung des Universums.", 400);
const body = await readJson(request, addSchema);
await addTitle(user.id, id, body.titleId);
return { ok: true };
});
+21
View File
@@ -0,0 +1,21 @@
import { z } from "zod";
import { readJson, withApi } from "@/lib/api";
import { createUniverse, listUniverses } from "@/lib/universes";
/** GET /api/v1/universen — eigene und geteilte Universen. */
export const GET = withApi(async (_request, user) => {
const universes = await listUniverses(user.id);
return { total: universes.length, items: universes };
});
const createSchema = z.object({
name: z.string().trim().min(1).max(120),
description: z.string().max(2000).optional(),
});
/** POST /api/v1/universen — neues Universum anlegen. */
export const POST = withApi(async (request, user) => {
const body = await readJson(request, createSchema);
const universe = await createUniverse(user.id, body.name, body.description);
return { id: universe.id, name: universe.name, slug: universe.slug };
});
+93
View File
@@ -0,0 +1,93 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getLibrary, getLibraryFacets, type LibraryFilters as Filters, type LibrarySort } from "@/lib/queries";
import { AppShell } from "@/components/AppShell";
import { LibraryCard } from "@/components/library/LibraryCard";
import { LibraryFilters } from "@/components/library/LibraryFilters";
import type { EntryStatus, TitleType } from "@/generated/prisma/client";
export const metadata: Metadata = { title: "Bibliothek" };
const STATUSES = ["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"];
const SORTS = ["aktualisiert", "hinzugefuegt", "titel", "bewertung", "jahr"];
export default async function LibraryPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | undefined>>;
}) {
const user = await requireUser("/bibliothek");
const settings = await getSettings();
const params = await searchParams;
const filters: Filters = {
query: params.q?.trim() || undefined,
status: STATUSES.includes(params.status ?? "") ? (params.status as EntryStatus) : undefined,
type: params.typ === "SERIES" || params.typ === "MOVIE" ? (params.typ as TitleType) : undefined,
genre: params.genre || undefined,
tag: params.tag || undefined,
provider: params.dienst || undefined,
sort: SORTS.includes(params.sortierung ?? "") ? (params.sortierung as LibrarySort) : undefined,
};
const [items, facets] = await Promise.all([
getLibrary(user.id, filters),
getLibraryFacets(user.id),
]);
const hasFilters = Object.values(filters).some(Boolean);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Bibliothek</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{facets.counts.ALLE === 0
? "Noch nichts aufgenommen."
: `${facets.counts.ALLE} ${facets.counts.ALLE === 1 ? "Titel" : "Titel"} — davon ${facets.counts.WATCHING} gerade am Laufen.`}
</p>
</div>
<Link href="/suche" className="fl-btn fl-btn-primary">
Titel hinzufügen
</Link>
</header>
{facets.counts.ALLE > 0 && <LibraryFilters facets={facets} />}
<div className="mt-6">
{items.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
{facets.counts.ALLE === 0
? "Deine Bibliothek ist noch leer."
: hasFilters
? "Zu dieser Auswahl passt nichts."
: "Hier ist nichts."}
</p>
{facets.counts.ALLE === 0 && (
<Link href="/suche" className="fl-btn fl-btn-primary mt-4">
Ersten Titel suchen
</Link>
)}
</div>
) : (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Titel" : "Titel"}
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={item.entryId}>
<LibraryCard item={item} />
</li>
))}
</ul>
</>
)}
</div>
</AppShell>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { env } from "@/lib/env";
import { ApiKeys } from "@/components/settings/Forms";
export const metadata: Metadata = { title: "API-Schlüssel" };
export default async function ApiPage() {
const user = await requireUser("/einstellungen/api");
const keys = await prisma.apiKey.findMany({
where: { userId: user.id, revokedAt: null },
orderBy: { createdAt: "desc" },
select: { id: true, name: true, prefix: true, lastUsedAt: true, createdAt: true },
});
const example = [
'curl -H "Authorization: Bearer <dein-schlüssel>" \\',
` ${env.appUrl}/api/v1/bibliothek?status=WATCHING`,
].join("\n");
return (
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">API-Schlüssel</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Für Skripte und eigene Anwendungen. Ein Schlüssel hat dieselben Rechte wie dein Konto —
gib ihn nicht weiter und zieh ihn zurück, sobald du ihn nicht mehr brauchst.
</p>
<ApiKeys keys={keys} />
</section>
<section className="fl-card p-5">
<h2 className="mb-3 text-lg font-semibold">So wird er benutzt</h2>
<pre className="overflow-x-auto rounded-xl bg-[var(--fl-surface-2)] p-3 text-xs leading-relaxed">
{example}
</pre>
<p className="mt-3 text-sm text-[var(--fl-muted)]">
Die vollständige Beschreibung steht in{" "}
<a
href="/api/v1/openapi.json"
className="text-[var(--fl-accent)] underline-offset-4 hover:underline"
>
openapi.json
</a>{" "}
und in <code>docs/API.md</code> im Repository.
</p>
</section>
</div>
);
}
@@ -0,0 +1,63 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { env } from "@/lib/env";
import { ChannelList } from "@/components/settings/Forms";
export const metadata: Metadata = { title: "Benachrichtigungen" };
export default async function NotificationsPage() {
const user = await requireUser("/einstellungen/benachrichtigungen");
const [channels, recent] = await Promise.all([
prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { createdAt: "asc" },
}),
prisma.notificationLog.findMany({
where: { userId: user.id },
orderBy: { sentAt: "desc" },
take: 10,
}),
]);
return (
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">Kanäle</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Wohin Flimmer dir Bescheid gibt. Was gemeldet wird, stellst du im Profil ein.
</p>
<ChannelList
channels={channels.map((channel) => ({
id: channel.id,
kind: channel.kind,
name: channel.name,
config: (channel.config ?? {}) as Record<string, string>,
enabled: channel.enabled,
}))}
smtpReady={env.smtp.isConfigured}
/>
</section>
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">Zuletzt verschickt</h2>
{recent.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">Noch nichts verschickt.</p>
) : (
<ul className="divide-y divide-[var(--fl-border)] text-sm">
{recent.map((entry) => (
<li key={entry.id} className="py-2.5">
<p className="font-medium">{entry.subject}</p>
<p className="text-xs text-[var(--fl-faint)]">
{entry.sentAt.toLocaleString("de-DE")}
{entry.ok ? "" : ` · fehlgeschlagen: ${entry.error ?? "unbekannt"}`}
</p>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { ImportForm } from "@/components/settings/Forms";
export const metadata: Metadata = { title: "Import und Export" };
export default async function DataPage() {
const user = await requireUser("/einstellungen/daten");
const [entries, watches] = await Promise.all([
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
prisma.watch.count({ where: { userId: user.id } }),
]);
return (
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">Import</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Bringt Titel aus einem anderen Werkzeug mit.
</p>
<ImportForm />
</section>
<section className="fl-card p-5">
<h2 className="text-lg font-semibold">Export</h2>
<p className="mb-4 mt-1 text-sm text-[var(--fl-muted)]">
Deine Daten gehören dir: {entries} Einträge und {watches} Sichtungen zum Mitnehmen.
</p>
<div className="flex flex-wrap gap-2">
<a href="/api/v1/export?format=json" className="fl-btn fl-btn-primary" download>
Als JSON
</a>
<a href="/api/v1/export?format=csv" className="fl-btn fl-btn-ghost" download>
Als CSV
</a>
</div>
<p className="mt-3 text-xs text-[var(--fl-faint)]">
JSON enthält zusätzlich alle Sichtungen und Universen, CSV ist die schlanke Tabellenform
für Tabellenkalkulationen.
</p>
</section>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { SettingsNav } from "@/components/settings/SettingsNav";
/** Rahmen der Einstellungen: Seitennavigation links, Inhalt rechts. */
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser("/einstellungen");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Einstellungen</h1>
</header>
<div className="grid gap-6 lg:grid-cols-[14rem_minmax(0,1fr)]">
<SettingsNav isAdmin={user.role === "ADMIN"} />
<div className="min-w-0">{children}</div>
</div>
</AppShell>
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { PasswordForm, ProfileForm } from "@/components/settings/Forms";
import pkg from "../../../package.json";
export const metadata: Metadata = { title: "Einstellungen" };
export default async function SettingsPage() {
const session = await requireUser("/einstellungen");
const settings = await getSettings();
const user = await prisma.user.findUniqueOrThrow({
where: { id: session.id },
select: {
name: true,
email: true,
region: true,
publicProfile: true,
profileSlug: true,
notifyNewEpisode: true,
notifyRelease: true,
notifyAvailability: true,
passwordHash: true,
},
});
const { passwordHash, ...profile } = user;
return (
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">Profil</h2>
<ProfileForm user={profile} publicProfilesEnabled={settings.publicProfilesEnabled} />
</section>
{passwordHash && (
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">Passwort</h2>
<PasswordForm />
</section>
)}
<p className="text-xs text-[var(--fl-faint)]">
Flimmer {pkg.version} · Instanz {settings.instanceName}
</p>
</div>
);
}
+12
View File
@@ -332,6 +332,18 @@
animation: fl-glimmer 900ms var(--ease-glow); animation: fl-glimmer 900ms var(--ease-glow);
} }
/* Rückmeldungen fahren von unten herein — kurz und ohne Sprung. */
@keyframes fl-toast-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+127
View File
@@ -0,0 +1,127 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getUpcomingEpisodes } from "@/lib/stats";
import { AppShell } from "@/components/AppShell";
import { CalendarMonth } from "@/components/CalendarMonth";
import { episodeCode, formatDateLong } from "@/lib/labels";
export const metadata: Metadata = { title: "Kalender" };
/** Monatsangabe aus der URL („2026-09") oder der laufende Monat. */
function parseMonth(value: string | undefined): Date {
const match = value?.match(/^(\d{4})-(\d{2})$/);
if (match) {
const year = Number(match[1]);
const month = Number(match[2]) - 1;
if (year >= 1900 && year <= 2200 && month >= 0 && month <= 11) {
return new Date(year, month, 1);
}
}
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
}
function monthParam(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
export default async function CalendarPage({
searchParams,
}: {
searchParams: Promise<{ monat?: string }>;
}) {
const user = await requireUser("/kalender");
const settings = await getSettings();
const params = await searchParams;
const month = parseMonth(params.monat);
const from = new Date(month.getFullYear(), month.getMonth(), 1);
// Das Raster reicht in den Vor- und Folgemonat hinein — die Termine auch.
const gridFrom = new Date(from.getTime() - 7 * 86_400_000);
const gridTo = new Date(from.getFullYear(), from.getMonth() + 1, 7);
const episodes = await getUpcomingEpisodes(user.id, gridFrom, gridTo);
const inMonth = episodes.filter((episode) => episode.airDate.getMonth() === month.getMonth());
const previous = new Date(month.getFullYear(), month.getMonth() - 1, 1);
const next = new Date(month.getFullYear(), month.getMonth() + 1, 1);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Kalender</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ausstrahlungstermine der Serien in deiner Bibliothek.
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Monat wechseln">
<Link
href={`/kalender?monat=${monthParam(previous)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${previous.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
←
</Link>
<span className="min-w-40 text-center font-medium">
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</span>
<Link
href={`/kalender?monat=${monthParam(next)}`}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
aria-label={`Zum ${next.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
>
→
</Link>
<Link href="/kalender" className="ml-1 text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Heute
</Link>
</nav>
</header>
<div className="fl-card overflow-x-auto p-3 sm:p-4">
<div className="min-w-[44rem]">
<CalendarMonth month={month} episodes={episodes} />
</div>
</div>
<section className="mt-6" aria-labelledby="liste">
<h2 id="liste" className="mb-3 text-lg font-semibold">
Termine im {month.toLocaleDateString("de-DE", { month: "long" })}
</h2>
{inMonth.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
In diesem Monat läuft nichts aus deiner Bibliothek.
</div>
) : (
<ol className="fl-card divide-y divide-[var(--fl-border)]">
{inMonth.map((episode) => (
<li key={episode.episodeId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
<span className="w-52 shrink-0 text-sm text-[var(--fl-faint)]">
{formatDateLong(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-2 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
<span className="ml-2 text-[var(--fl-muted)]">{episode.name}</span>
</Link>
{episode.runtime && (
<span className="shrink-0 text-xs text-[var(--fl-faint)]">{episode.runtime} min</span>
)}
</li>
))}
</ol>
)}
</section>
</AppShell>
);
}
+4 -1
View File
@@ -1,6 +1,8 @@
import type { Metadata, Viewport } from "next"; import type { Metadata, Viewport } from "next";
import { Outfit } from "next/font/google"; import { Outfit } from "next/font/google";
import { BrandDefs } from "@/components/brand/FlimmerLogo"; import { BrandDefs } from "@/components/brand/FlimmerLogo";
import { ToastProvider } from "@/components/Toasts";
import { ServiceWorkerRegistration } from "@/components/ServiceWorkerRegistration";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import "./globals.css"; import "./globals.css";
@@ -73,7 +75,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
> >
Zum Inhalt springen Zum Inhalt springen
</a> </a>
{children} <ToastProvider>{children}</ToastProvider>
<ServiceWorkerRegistration />
</body> </body>
</html> </html>
); );
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
import type { Metadata } from "next";
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
export const metadata: Metadata = { title: "Keine Verbindung" };
/**
* Rückfallseite des Service Workers.
*
* Bewusst ohne Datenzugriff: sie muss auch dann funktionieren, wenn der
* Server nicht erreichbar ist, und wird deshalb statisch vorgerendert.
*/
export default function OfflinePage() {
return (
<main
id="inhalt"
className="flex min-h-dvh flex-col items-center justify-center px-6 text-center"
>
<FlimmerMark className="h-16 w-16" />
<h1 className="mt-6 text-2xl font-semibold tracking-tight">Gerade keine Verbindung</h1>
<p className="mt-2 max-w-sm text-[var(--fl-muted)]">
Flimmer erreicht deinen Server nicht. Bereits geladene Seiten und Poster bleiben verfügbar —
alles Weitere kommt zurück, sobald die Verbindung wieder steht.
</p>
<Link href="/" className="fl-btn fl-btn-primary mt-6">
Noch einmal versuchen
</Link>
</main>
);
}
+190 -44
View File
@@ -2,21 +2,196 @@ import Link from "next/link";
import { requireUser } from "@/lib/auth"; import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings"; import { getSettings } from "@/lib/settings";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import {
getContinueWatching,
getNewlyAvailable,
getUpcomingEpisodes,
} from "@/lib/stats";
import { prisma } from "@/lib/db";
import { episodeCode, formatDate, formatRelative, isUpcoming } from "@/lib/labels";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { FlimmerMark } from "@/components/brand/FlimmerLogo"; import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { QuickWatch } from "@/components/library/QuickWatch";
/** /**
* Übersichtsseite. * Übersicht: der Einstieg für „was schaue ich als Nächstes".
* *
* In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten * Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein. * Watchlist verfügbar geworden ist.
*/ */
export default async function DashboardPage() { export default async function DashboardPage() {
const user = await requireUser(); const user = await requireUser();
const settings = await getSettings(); const settings = await getSettings();
const now = new Date();
const inFourWeeks = new Date(now.getTime() + 28 * 86_400_000);
const [continueItems, upcoming, available, libraryCount] = await Promise.all([
getContinueWatching(user.id, 8),
getUpcomingEpisodes(user.id, now, inFourWeeks),
getNewlyAvailable(user.id, user.region),
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
]);
if (libraryCount === 0) {
return ( return (
<AppShell user={user} instanceName={settings.instanceName}> <AppShell user={user} instanceName={settings.instanceName}>
<EmptyStart tmdbReady={env.tmdb.isConfigured} name={user.name} />
</AppShell>
);
}
return (
<AppShell user={user} instanceName={settings.instanceName}>
<h1 className="sr-only">Übersicht</h1>
<section aria-labelledby="weiterschauen">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 id="weiterschauen" className="text-lg font-semibold">
Weiterschauen
</h2>
<Link href="/bibliothek?status=WATCHING" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Alle laufenden
</Link>
</div>
{continueItems.length === 0 ? (
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier.
</div>
) : (
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{continueItems.map((item) => (
<li key={item.entryId} className="fl-card flex gap-3 p-3">
<Link href={`/titel/${item.titleId}`} className="w-16 shrink-0 sm:w-20">
<Poster path={item.posterPath} alt={item.title} size="w185" />
</Link>
<div className="flex min-w-0 flex-1 flex-col">
<Link href={`/titel/${item.titleId}`} className="truncate font-medium hover:text-[var(--fl-accent)]">
{item.title}
</Link>
{item.next && (
<p className="mt-0.5 truncate text-sm">
<span className="fl-gradient-text font-mono font-semibold">
{episodeCode(item.next.seasonNumber, item.next.episodeNumber)}
</span>{" "}
<span className="text-[var(--fl-muted)]">{item.next.name}</span>
</p>
)}
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{item.watched} von {item.total} Episoden
{item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
</div>
<div className="mt-2.5">
{item.next && isUpcoming(item.next.airDate) ? (
<p className="text-xs text-[var(--fl-faint)]">
Nächste Episode erst am {formatDate(item.next.airDate)}
</p>
) : (
<QuickWatch entryId={item.entryId} />
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<div className="mt-8 grid gap-6 lg:grid-cols-2">
<section aria-labelledby="demnaechst">
<div className="mb-3 flex items-baseline justify-between gap-3">
<h2 id="demnaechst" className="text-lg font-semibold">
Demnächst
</h2>
<Link href="/kalender" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
Zum Kalender
</Link>
</div>
<div className="fl-card p-4">
{upcoming.length === 0 ? (
<p className="text-sm text-[var(--fl-muted)]">
In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
</p>
) : (
<ol className="space-y-2.5">
{upcoming.slice(0, 6).map((episode) => (
<li key={episode.episodeId} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate)}
</span>
<Link
href={`/titel/${episode.titleId}`}
className="min-w-0 flex-1 truncate text-sm hover:text-[var(--fl-accent)]"
>
<span className="font-medium">{episode.title}</span>
<span className="ml-1.5 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>
</Link>
</li>
))}
</ol>
)}
</div>
</section>
<section aria-labelledby="verfuegbar">
<h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
Neu verfügbar
</h2>
<div className="fl-card p-4">
{available.length === 0 ? (
<p className="text-sm text-[var(--fl-muted)]">
Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
einem Dienst in {user.region} auftaucht, steht er hier.
</p>
) : (
<ul className="space-y-3">
{available.slice(0, 5).map((item) => (
<li key={item.titleId} className="flex items-center gap-3">
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<Link
href={`/titel/${item.titleId}`}
className="block truncate text-sm font-medium hover:text-[var(--fl-accent)]"
>
{item.title}
</Link>
<p className="truncate text-xs text-[var(--fl-faint)]">
{item.providers.map((provider) => provider.name).join(", ")} ·{" "}
{formatRelative(item.since)}
</p>
</div>
</li>
))}
</ul>
)}
</div>
</section>
</div>
</AppShell>
);
}
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
return (
<section className="fl-card relative overflow-hidden p-6 sm:p-10"> <section className="fl-card relative overflow-hidden p-6 sm:p-10">
<div <div
aria-hidden aria-hidden
@@ -24,60 +199,31 @@ export default async function DashboardPage() {
style={{ backgroundImage: "var(--fl-gradient)" }} style={{ backgroundImage: "var(--fl-gradient)" }}
/> />
<div className="relative"> <div className="relative">
<FlimmerMark className="h-12 w-12" /> <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl"> Hallo {name.split(" ")[0]}.
Hallo {user.name.split(" ")[0]}.
</h1> </h1>
<p className="mt-2 max-w-xl text-[var(--fl-muted)]"> <p className="mt-2 max-w-xl text-[var(--fl-muted)]">
Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du
deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner
verfügbar geworden ist. Watchlist verfügbar geworden ist.
</p> </p>
<div className="mt-6 flex flex-wrap gap-3"> <div className="mt-6 flex flex-wrap gap-3">
<Link href="/suche" className="fl-btn fl-btn-primary"> <Link href="/suche" className="fl-btn fl-btn-primary">
Titel suchen und hinzufügen Titel suchen
</Link> </Link>
<Link href="/bibliothek" className="fl-btn fl-btn-ghost"> <Link href="/titel/neu" className="fl-btn fl-btn-ghost">
Zur Bibliothek Titel selbst anlegen
</Link> </Link>
</div> </div>
{!env.tmdb.isConfigured && ( {!tmdbReady && (
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]"> <p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Metadaten —
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte die Instanz neu.
die Instanz neu.
</p> </p>
)} )}
</div> </div>
</section> </section>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<PlaceholderCard
title="Weiterschauen"
text="Deine laufenden Serien mit der jeweils nächsten Episode."
/>
<PlaceholderCard
title="Demnächst"
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden."
/>
<PlaceholderCard
title="Neu verfügbar"
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind."
/>
</div>
</AppShell>
);
}
function PlaceholderCard({ title, text }: { title: string; text: string }) {
return (
<div className="fl-card p-5">
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase">
{title}
</h2>
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p>
</div>
); );
} }
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { getTrash } from "@/lib/queries";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { TrashActions } from "@/components/library/TrashActions";
import { formatRelative, TYPE_LABEL } from "@/lib/labels";
export const metadata: Metadata = { title: "Papierkorb" };
/** Nach dieser Frist räumt der Hintergrundlauf endgültig auf. */
const RETENTION_DAYS = 30;
export default async function TrashPage() {
const user = await requireUser("/papierkorb");
const settings = await getSettings();
const entries = await getTrash(user.id);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Papierkorb</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Entfernte Titel bleiben {RETENTION_DAYS} Tage liegen. Danach werden sie samt ihrer
Sichtungen endgültig gelöscht.
</p>
</header>
{entries.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">Der Papierkorb ist leer.</p>
<Link href="/bibliothek" className="fl-btn fl-btn-ghost mt-4">
Zur Bibliothek
</Link>
</div>
) : (
<ul className="space-y-3">
{entries.map((entry) => {
const deletedAt = entry.deletedAt!;
const purgeAt = new Date(deletedAt.getTime() + RETENTION_DAYS * 86_400_000);
return (
<li key={entry.id} className="fl-card flex flex-wrap items-center gap-4 p-3 sm:p-4">
<div className="w-16 shrink-0">
<Poster path={entry.title.posterPath} alt={entry.title.title} size="w154" />
</div>
<div className="min-w-0 flex-1">
<p className="font-medium">{entry.title.title}</p>
<p className="text-sm text-[var(--fl-faint)]">
{TYPE_LABEL[entry.title.type]}
{entry.title.releaseDate ? ` · ${entry.title.releaseDate.getFullYear()}` : ""}
{" · entfernt "}
{formatRelative(deletedAt)}
</p>
<p className="mt-0.5 text-xs text-[var(--fl-warning)]">
Wird {formatRelative(purgeAt)} endgültig gelöscht
</p>
</div>
<TrashActions entryId={entry.id} title={entry.title.title} />
</li>
);
})}
</ul>
)}
</AppShell>
);
}
+267
View File
@@ -0,0 +1,267 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import {
getActiveYears,
getGenreDistribution,
getMonthlyActivity,
getMostWatched,
getProviderDistribution,
getTopRated,
getTotals,
getYearReview,
} from "@/lib/stats";
import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
export const metadata: Metadata = { title: "Statistik" };
export default async function StatsPage({
searchParams,
}: {
searchParams: Promise<{ jahr?: string }>;
}) {
const user = await requireUser("/statistik");
const settings = await getSettings();
const params = await searchParams;
const years = await getActiveYears(user.id);
const requested = Number.parseInt(params.jahr ?? "", 10);
const year = years.includes(requested) ? requested : (years[0] ?? new Date().getFullYear());
const [totals, monthly, providers, genres, topRated, mostWatched, review] = await Promise.all([
getTotals(user.id),
getMonthlyActivity(user.id, 12),
getProviderDistribution(user.id),
getGenreDistribution(user.id),
getTopRated(user.id, 5),
getMostWatched(user.id, 5),
getYearReview(user.id, year),
]);
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
</header>
<div className="fl-card p-8 text-center">
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier
Schauzeit, Verteilungen und der Jahresrückblick.
</p>
<Link href="/bibliothek" className="fl-btn fl-btn-primary mt-4">
Zur Bibliothek
</Link>
</div>
</AppShell>
);
}
const days = totals.minutes / (60 * 24);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit.
</p>
</header>
<section className="fl-card mb-6 p-5 sm:p-7" aria-label="Gesamtschauzeit">
<p className="text-sm font-medium text-[var(--fl-muted)]">Gesamte Schauzeit</p>
<p className="fl-gradient-text mt-1 text-5xl font-semibold tabular-nums sm:text-6xl">
{Math.round(totals.minutes / 60).toLocaleString("de-DE")} h
</p>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück
</p>
<dl className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
<Stat
label="In der Bibliothek"
value={`${totals.seriesCount} Serien · ${totals.movieCount} Filme`}
/>
</dl>
</section>
<section className="fl-card mb-6 p-5" aria-labelledby="verlauf">
<h2 id="verlauf" className="mb-1 text-lg font-semibold">
Die letzten zwölf Monate
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Abgehakte Episoden und Filme je Monat.
</p>
<MonthlyChart points={monthly.map(({ label, episodes, movies, minutes }) => ({ label, episodes, movies, minutes }))} />
</section>
<div className="mb-6 grid gap-6 lg:grid-cols-2">
<section className="fl-card p-5" aria-labelledby="dienste">
<h2 id="dienste" className="mb-1 text-lg font-semibold">
Nach Streamingdienst
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das".
</p>
<DistributionBars
rows={providers.slice(0, 8)}
emptyText="Noch keine Sichtung einem Dienst zugeordnet."
useBrandColors
/>
</section>
<section className="fl-card p-5" aria-labelledby="genres">
<h2 id="genres" className="mb-1 text-lg font-semibold">
Nach Genre
</h2>
<p className="mb-4 text-sm text-[var(--fl-muted)]">
Titel mit mehreren Genres zählen in jedem mit.
</p>
<DistributionBars rows={genres.slice(0, 8)} emptyText="Noch keine Genres erfasst." />
</section>
</div>
<section className="fl-card mb-6 p-5" aria-labelledby="rueckblick">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 id="rueckblick" className="text-lg font-semibold">
{review.year} in Zahlen
</h2>
{years.length > 1 && (
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Jahr wählen">
{years.slice(0, 6).map((entry) => (
<Link
key={entry}
href={`/statistik?jahr=${entry}`}
aria-current={entry === review.year ? "true" : undefined}
className="rounded-full border px-3 py-1 text-sm font-medium transition-colors"
style={
entry === review.year
? {
backgroundImage: "var(--fl-gradient)",
color: "var(--fl-on-accent)",
borderColor: "transparent",
}
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
}
>
{entry}
</Link>
))}
</div>
)}
</div>
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
<Stat label="Schauzeit" value={formatMinutes(review.minutes)} />
<Stat label="Episoden" value={review.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={review.movies.toLocaleString("de-DE")} />
<Stat label="Neu angefangen" value={review.titlesStarted.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={review.titlesCompleted.toLocaleString("de-DE")} />
</dl>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
{review.busiestMonth && (
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
<span className="text-[var(--fl-faint)]">Stärkster Monat: </span>
<span className="font-medium">{review.busiestMonth.label}</span>
<span className="text-[var(--fl-muted)]">
{" "}
mit {formatMinutes(review.busiestMonth.minutes)}
</span>
</p>
)}
{review.topTitle && (
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
<span className="text-[var(--fl-faint)]">Die meiste Zeit ging an: </span>
<Link href={`/titel/${review.topTitle.titleId}`} className="font-medium hover:text-[var(--fl-accent)]">
{review.topTitle.title}
</Link>
<span className="text-[var(--fl-muted)]"> ({formatMinutes(review.topTitle.minutes)})</span>
</p>
)}
</div>
</section>
<div className="grid gap-6 lg:grid-cols-2">
<TitleList
id="meistzeit"
heading="Wo die meiste Zeit hinging"
items={mostWatched}
renderMeta={(item) => formatMinutes(item.minutes)}
emptyText="Noch nichts gesehen."
/>
<TitleList
id="bestbewertet"
heading="Deine besten Bewertungen"
items={topRated}
renderMeta={(item) => (item.rating ? `${item.rating}/10` : "—")}
emptyText="Noch nichts bewertet."
/>
</div>
</AppShell>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
</div>
);
}
function TitleList({
id,
heading,
items,
renderMeta,
emptyText,
}: {
id: string;
heading: string;
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: string }[];
renderMeta: (item: { rating: number | null; minutes: number }) => string;
emptyText: string;
}) {
return (
<section className="fl-card p-5" aria-labelledby={id}>
<h2 id={id} className="mb-4 text-lg font-semibold">
{heading}
</h2>
{items.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">{emptyText}</p>
) : (
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.titleId} className="flex items-center gap-3">
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
{index + 1}
</span>
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</Link>
<Link
href={`/titel/${item.titleId}`}
className="min-w-0 flex-1 truncate text-sm font-medium hover:text-[var(--fl-accent)]"
>
{item.title}
</Link>
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
{renderMeta(item)}
</span>
</li>
))}
</ol>
)}
</section>
);
}
+161
View File
@@ -0,0 +1,161 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { env } from "@/lib/env";
import { searchMovies, searchMulti, searchSeries, type TmdbSearchResult } from "@/lib/tmdb";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { SearchField } from "@/components/SearchField";
export const metadata: Metadata = { title: "Suchen" };
type Filter = "alle" | "serien" | "filme";
const FILTERS: { key: Filter; label: string }[] = [
{ key: "alle", label: "Alles" },
{ key: "serien", label: "Serien" },
{ key: "filme", label: "Filme" },
];
/** Normalisiert die drei TMDB-Antwortformen auf eine gemeinsame Darstellung. */
function normalize(item: TmdbSearchResult, fallbackType: "tv" | "movie") {
const type = item.media_type === "tv" || item.media_type === "movie" ? item.media_type : fallbackType;
const date = type === "tv" ? item.first_air_date : item.release_date;
return {
tmdbId: item.id,
type,
title: (type === "tv" ? item.name : item.title) ?? item.original_name ?? item.original_title ?? "Ohne Titel",
overview: item.overview ?? "",
posterPath: item.poster_path ?? null,
year: date ? date.slice(0, 4) : null,
rating: item.vote_average ?? null,
};
}
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; typ?: string }>;
}) {
const user = await requireUser("/suche");
const settings = await getSettings();
const params = await searchParams;
const query = (params.q ?? "").trim();
const filter: Filter = FILTERS.some((f) => f.key === params.typ) ? (params.typ as Filter) : "alle";
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Suchen</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Serien und Filme aus der TMDB-Datenbank — mit Postern, Staffeln und Episoden.
</p>
</header>
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
<p className="mt-3 text-sm text-[var(--fl-faint)]">
Nichts dabei?{" "}
<Link href="/titel/neu" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Titel selbst anlegen
</Link>
</p>
<div className="mt-8">
{!env.tmdb.isConfigured ? (
<Notice tone="warning">
Ohne TMDB-Schlüssel kann Flimmer nicht suchen. Trage <code>TMDB_API_KEY</code> in der{" "}
<code>.env</code> ein und starte die Instanz neu.
</Notice>
) : query.length < 2 ? (
<Notice>Gib mindestens zwei Zeichen ein, um zu suchen.</Notice>
) : (
<Results query={query} filter={filter} />
)}
</div>
</AppShell>
);
}
async function Results({ query, filter }: { query: string; filter: Filter }) {
let items: ReturnType<typeof normalize>[];
try {
if (filter === "serien") {
const data = await searchSeries(query);
items = data.results.map((item) => normalize(item, "tv"));
} else if (filter === "filme") {
const data = await searchMovies(query);
items = data.results.map((item) => normalize(item, "movie"));
} else {
const data = await searchMulti(query);
items = data.results.map((item) => normalize(item, "movie"));
}
} catch (error) {
return (
<Notice tone="danger">
{error instanceof Error ? error.message : "Die Suche bei TMDB ist fehlgeschlagen."}
</Notice>
);
}
if (items.length === 0) {
return <Notice>Zu „{query}" hat TMDB nichts gefunden.</Notice>;
}
return (
<>
<p className="mb-4 text-sm text-[var(--fl-faint)]">
{items.length} {items.length === 1 ? "Treffer" : "Treffer"} zu „{query}"
</p>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((item) => (
<li key={`${item.type}-${item.tmdbId}`}>
<Link
href={`/tmdb/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
className="fl-poster-card group block focus:outline-none"
>
<Poster path={item.posterPath} alt={item.title} size="w342" />
<div className="mt-2 space-y-0.5">
<p className="line-clamp-2 text-sm font-medium leading-snug group-hover:text-[var(--fl-text)]">
{item.title}
</p>
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "tv" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
</p>
</div>
</Link>
</li>
))}
</ul>
</>
);
}
function Notice({
children,
tone = "neutral",
}: {
children: React.ReactNode;
tone?: "neutral" | "warning" | "danger";
}) {
const color =
tone === "warning" ? "var(--fl-warning)" : tone === "danger" ? "var(--fl-danger)" : "var(--fl-muted)";
return (
<p
className="fl-card px-4 py-3 text-sm"
style={{
color,
borderColor:
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 45%, transparent)`,
backgroundColor:
tone === "neutral" ? undefined : `color-mix(in oklab, ${color} 10%, transparent)`,
}}
>
{children}
</p>
);
}
+309
View File
@@ -0,0 +1,309 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { ensureTitle } from "@/lib/catalog";
import { getNextEpisode, getProgress, getWatchedEpisodeIds } from "@/lib/library";
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { Backdrop, Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import {
ProviderList,
type ProviderBadgeData,
type ProviderKindKey,
} from "@/components/ProviderBadges";
import { SeasonList, type SeasonData } from "@/components/library/SeasonList";
import {
AddToLibrary,
EntryActions,
RatingPicker,
StatusPicker,
} from "@/components/library/EntryControls";
import { EntryNotes } from "@/components/library/EntryNotes";
import { NextEpisodeCard, WatchMovieButton } from "@/components/library/NextEpisode";
type Params = Promise<{ id: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params;
const title = await prisma.title.findUnique({ where: { id }, select: { title: true } });
return { title: title?.title ?? "Titel" };
}
export default async function TitlePage({ params }: { params: Params }) {
const user = await requireUser();
const settings = await getSettings();
const { id } = await params;
const title = await prisma.title.findUnique({
where: { id },
include: {
seasons: {
orderBy: { seasonNumber: "asc" },
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
},
providers: { where: { region: user.region }, include: { provider: true } },
entries: {
where: { userId: user.id },
include: { tags: { include: { tag: true } } },
},
},
});
if (!title) notFound();
// Im Hintergrund auffrischen, falls der Eintrag alt ist. Schlägt das fehl,
// bleibt der lokale Stand stehen — die Seite funktioniert trotzdem.
if (title.tmdbId) {
await ensureTitle(title.type, title.tmdbId, { region: user.region }).catch(() => undefined);
}
const entry = title.entries[0] ?? null;
const isSeries = title.type === "SERIES";
const [progress, next, watchedIds] = entry
? await Promise.all([
getProgress(user.id, entry.id),
isSeries ? getNextEpisode(user.id, entry.id) : Promise.resolve(null),
isSeries ? getWatchedEpisodeIds(user.id, entry.id) : Promise.resolve(new Set<string>()),
])
: [null, null, new Set<string>()];
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
for (const row of title.providers) {
(providerGroups[row.kind as ProviderKindKey] ??= []).push({
id: row.provider.id,
name: row.provider.name,
logoPath: row.provider.logoPath,
color: row.provider.color,
});
}
const suggestion = providerGroups.FLATRATE?.[0]?.name ?? providerGroups.FREE?.[0]?.name ?? null;
const seasons: SeasonData[] = title.seasons.map((season) => ({
id: season.id,
seasonNumber: season.seasonNumber,
name: season.name,
overview: season.overview,
airDate: season.airDate?.toISOString() ?? null,
episodes: season.episodes.map((episode) => ({
id: episode.id,
episodeNumber: episode.episodeNumber,
name: episode.name,
overview: episode.overview,
airDate: episode.airDate?.toISOString() ?? null,
runtime: episode.runtime,
})),
}));
const year = title.releaseDate?.getFullYear() ?? null;
const movieWatched = !isSeries && (progress?.watched ?? 0) > 0;
return (
<AppShell user={user} instanceName={settings.instanceName}>
<article>
<div className="fl-card relative overflow-hidden">
<Backdrop path={title.backdropPath} alt="" />
<div className="relative flex flex-col gap-6 p-5 sm:flex-row sm:p-7">
<div className="w-36 shrink-0 sm:w-48">
<Poster path={title.posterPath} alt={title.title} size="w500" priority />
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{isSeries ? "Serie" : "Film"}
{year ? ` · ${year}` : ""}
{title.status ? ` · ${title.status}` : ""}
</p>
{entry && (
<span
className="rounded-full px-2.5 py-0.5 text-xs font-medium"
style={{
color: STATUS_COLOR[entry.status as StatusKey],
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[entry.status as StatusKey]} 16%, transparent)`,
}}
>
{STATUS_LABEL[entry.status as StatusKey]}
</span>
)}
</div>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">
{title.title}
</h1>
{title.originalTitle && title.originalTitle !== title.title && (
<p className="mt-0.5 text-sm text-[var(--fl-faint)]">{title.originalTitle}</p>
)}
{title.tagline && (
<p className="mt-2 text-sm italic text-[var(--fl-muted)]">„{title.tagline}"</p>
)}
<dl className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
{isSeries && (
<Fact label="Umfang">
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
{title.episodeCount} Episoden
</Fact>
)}
{title.runtime ? (
<Fact label={isSeries ? "Pro Episode" : "Laufzeit"}>{title.runtime} min</Fact>
) : null}
{title.voteAverage ? <Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</Fact> : null}
{entry && progress ? (
<Fact label="Deine Zeit">{formatMinutes(progress.minutesWatched)}</Fact>
) : null}
</dl>
{title.genres.length > 0 && (
<ul className="mt-4 flex flex-wrap gap-1.5">
{title.genres.map((genre) => (
<li
key={genre}
className="rounded-full border border-[var(--fl-border)] px-2.5 py-0.5 text-xs text-[var(--fl-muted)]"
>
{genre}
</li>
))}
</ul>
)}
{title.overview && (
<p className="mt-4 max-w-2xl text-sm leading-relaxed text-[var(--fl-muted)]">
{title.overview}
</p>
)}
<div className="mt-6">
{entry ? (
<div className="space-y-4">
{progress && (
<div className="max-w-md">
<ProgressBar
percent={progress.percent}
label={
isSeries
? `${progress.watched} von ${progress.total} Episoden gesehen — noch ${formatMinutes(progress.minutesLeft)}`
: movieWatched
? "Gesehen"
: `Noch ${formatMinutes(progress.minutesLeft)}`
}
showValue
/>
</div>
)}
{isSeries && next && <NextEpisodeCard entryId={entry.id} next={{ ...next, airDate: next.airDate?.toISOString() ?? null }} />}
{isSeries && !next && progress?.total ? (
<p className="rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
Durchgeschaut — alle {progress.total} Episoden abgehakt.
</p>
) : null}
{!isSeries && <WatchMovieButton entryId={entry.id} watched={movieWatched} />}
<StatusPicker entryId={entry.id} status={entry.status as StatusKey} />
</div>
) : (
<AddToLibrary titleId={title.id} isSeries={isSeries} />
)}
</div>
</div>
</div>
</div>
{entry && (
<div className="mt-6 grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<section className="order-2 lg:order-1">
<h2 className="mb-3 text-lg font-semibold">
{isSeries ? "Staffeln" : "Wo läuft das?"}
</h2>
{isSeries ? (
<SeasonList seasons={seasons} entryId={entry.id} watchedIds={[...watchedIds]} />
) : (
<div className="fl-card p-4">
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
</section>
<aside className="order-1 space-y-6 lg:order-2">
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Deine Bewertung
</h2>
<RatingPicker entryId={entry.id} rating={entry.rating} />
</div>
<div className="fl-card p-4">
<EntryNotes
entryId={entry.id}
notes={entry.notes}
whereToWatch={entry.whereToWatch}
tags={entry.tags.map((link) => link.tag.name)}
suggestion={suggestion}
/>
</div>
{isSeries && (
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Wo läuft das?
</h2>
<ProviderList groups={providerGroups} region={user.region} />
</div>
)}
<div className="fl-card p-4">
<EntryActions
entryId={entry.id}
currentRun={entry.currentRun}
canRewatch={entry.status === "COMPLETED"}
/>
</div>
</aside>
</div>
)}
{!entry && (
<>
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Wo läuft das?</h2>
<div className="fl-card p-4">
<ProviderList groups={providerGroups} region={user.region} />
<p className="mt-3 text-xs text-[var(--fl-faint)]">
Verfügbarkeitsdaten von JustWatch über TMDB, Region {user.region}.
</p>
</div>
</section>
{isSeries && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
<SeasonList seasons={seasons} />
</section>
)}
</>
)}
<p className="mt-8 text-sm">
<Link href="/bibliothek" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Zur Bibliothek
</Link>
</p>
</article>
</AppShell>
);
}
function Fact({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 font-medium">{children}</dd>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { ManualTitleForm } from "@/components/library/ManualTitleForm";
export const metadata: Metadata = { title: "Titel anlegen" };
export default async function NewTitlePage() {
const user = await requireUser("/titel/neu");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<div className="mx-auto max-w-2xl">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Titel selbst anlegen</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Für alles, was TMDB nicht kennt. Solche Titel werden vom Abgleich nie angefasst — die
Angaben bleiben so, wie du sie einträgst.
</p>
</header>
<div className="fl-card p-5 sm:p-6">
<ManualTitleForm />
</div>
<p className="mt-6 text-sm text-[var(--fl-faint)]">
Lieber doch bei TMDB nachsehen?{" "}
<Link href="/suche" className="text-[var(--fl-accent)] underline-offset-4 hover:underline">
Zur Suche
</Link>
</p>
</div>
</AppShell>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { notFound, redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { ensureTitle } from "@/lib/catalog";
import { TmdbError } from "@/lib/tmdb";
import type { TitleType } from "@/generated/prisma/client";
/**
* Einsprung von der Suche: holt den Titel bei TMDB und leitet auf die
* kanonische Detailseite weiter.
*
* Warum der Umweg: Die Detailseite hängt an der internen Titel-ID, damit auch
* manuell angelegte Titel ohne TMDB-Treffer dieselbe Seite bekommen. Vor dem
* ersten Import gibt es diese ID aber noch nicht.
*/
const ART_TO_TYPE: Record<string, TitleType> = { serie: "SERIES", film: "MOVIE" };
export default async function TmdbRedirectPage({
params,
}: {
params: Promise<{ art: string; tmdbId: string }>;
}) {
const user = await requireUser();
const { art, tmdbId: raw } = await params;
const type = ART_TO_TYPE[art];
const tmdbId = Number.parseInt(raw, 10);
if (!type || !Number.isFinite(tmdbId)) notFound();
let title;
try {
title = await ensureTitle(type, tmdbId, { region: user.region });
} catch (error) {
if (error instanceof TmdbError && error.status === 404) notFound();
throw error;
}
redirect(`/titel/${title.id}`);
}
+171
View File
@@ -0,0 +1,171 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { prisma } from "@/lib/db";
import { getSettings } from "@/lib/settings";
import { getMostWatched, getTopRated, getTotals } from "@/lib/stats";
import { formatMinutes, formatRelative } from "@/lib/labels";
import { FlimmerLogo } from "@/components/brand/FlimmerLogo";
import { Poster } from "@/components/Poster";
import { ThemeToggle } from "@/components/ThemeToggle";
type Params = Promise<{ slug: string }>;
/**
* Öffentliche Profilseite.
*
* Zeigt Zahlen und zuletzt Gesehenes — **keine** Notizen, keine Watchlist,
* keine E-Mail-Adresse. Was privat gemeint ist, bleibt privat. Die Seite ist
* nur erreichbar, wenn der Nutzer sie freigeschaltet hat *und* der Betreiber
* öffentliche Profile instanzweit erlaubt.
*/
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { slug } = await params;
const user = await prisma.user.findFirst({
where: { profileSlug: slug, publicProfile: true, isActive: true },
select: { name: true },
});
return user ? { title: `${user.name} · Profil` } : { title: "Profil" };
}
export default async function PublicProfilePage({ params }: { params: Params }) {
const { slug } = await params;
const settings = await getSettings();
if (!settings.publicProfilesEnabled) notFound();
const user = await prisma.user.findFirst({
where: { profileSlug: slug, publicProfile: true, isActive: true },
select: { id: true, name: true, createdAt: true },
});
if (!user) notFound();
const [totals, mostWatched, topRated, recent] = await Promise.all([
getTotals(user.id),
getMostWatched(user.id, 6),
getTopRated(user.id, 6),
prisma.watch.findMany({
where: { userId: user.id },
orderBy: { watchedAt: "desc" },
take: 8,
distinct: ["titleId"],
include: {
title: { select: { id: true, title: true, posterPath: true, type: true } },
episode: { select: { seasonNumber: true, episodeNumber: true } },
},
}),
]);
return (
<div className="flex min-h-dvh flex-col">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-4 py-4 sm:px-6">
<Link href="/" aria-label="Flimmer">
<FlimmerLogo />
</Link>
<ThemeToggle />
</div>
<main id="inhalt" className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 sm:px-6">
<header className="fl-card relative overflow-hidden p-6 sm:p-8">
<div
aria-hidden
className="pointer-events-none absolute -right-20 -top-28 h-64 w-64 rounded-full opacity-25 blur-3xl"
style={{ backgroundImage: "var(--fl-gradient)" }}
/>
<div className="relative">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Öffentliches Profil
</p>
<h1 className="mt-1 text-3xl font-semibold tracking-tight">{user.name}</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Dabei seit {user.createdAt.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</p>
<dl className="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
<Stat label="Schauzeit" value={formatMinutes(totals.minutes)} />
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
</dl>
</div>
</header>
{recent.length > 0 && (
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold">Zuletzt gesehen</h2>
<ul className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
{recent.map((watch) => (
<li key={watch.id}>
<Poster path={watch.title.posterPath} alt={watch.title.title} size="w185" />
<p className="mt-1.5 line-clamp-2 text-xs font-medium leading-snug">
{watch.title.title}
</p>
<p className="text-xs text-[var(--fl-faint)]">
{formatRelative(watch.watchedAt)}
</p>
</li>
))}
</ul>
</section>
)}
<div className="mt-6 grid gap-6 sm:grid-cols-2">
<TitleList heading="Die meiste Zeit" items={mostWatched} showMinutes />
<TitleList heading="Bestbewertet" items={topRated} />
</div>
{totals.minutes === 0 && (
<p className="fl-card mt-6 p-6 text-center text-sm text-[var(--fl-muted)]">
Hier ist noch nichts zu sehen.
</p>
)}
</main>
<footer className="mx-auto w-full max-w-5xl px-4 pb-10 pt-6 text-center text-xs text-[var(--fl-faint)] sm:px-6">
Erstellt mit Flimmer · Metadaten von TMDB
</footer>
</div>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
</div>
);
}
function TitleList({
heading,
items,
showMinutes = false,
}: {
heading: string;
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number }[];
showMinutes?: boolean;
}) {
if (items.length === 0) return null;
return (
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">{heading}</h2>
<ol className="space-y-3">
{items.map((item, index) => (
<li key={item.titleId} className="flex items-center gap-3">
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
{index + 1}
</span>
<span className="w-10 shrink-0">
<Poster path={item.posterPath} alt={item.title} size="w154" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{item.title}</span>
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
{showMinutes ? formatMinutes(item.minutes) : item.rating ? `${item.rating}/10` : "—"}
</span>
</li>
))}
</ol>
</section>
);
}
+193
View File
@@ -0,0 +1,193 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { getUniverse, suggestCollections } from "@/lib/universes";
import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { ProgressBar } from "@/components/ProgressBar";
import { OrderedList } from "@/components/universes/OrderedList";
import {
AddTitles,
DeleteUniverse,
OrderTabs,
SharePanel,
} from "@/components/universes/UniverseTools";
type Params = Promise<{ id: string }>;
type Search = Promise<{ reihenfolge?: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params;
const universe = await prisma.universe.findUnique({ where: { id }, select: { name: true } });
return { title: universe?.name ?? "Universum" };
}
export default async function UniversePage({
params,
searchParams,
}: {
params: Params;
searchParams: Search;
}) {
const user = await requireUser();
const settings = await getSettings();
const { id } = await params;
const { reihenfolge } = await searchParams;
const universe = await getUniverse(user.id, id, reihenfolge);
if (!universe) notFound();
const activeOrder = universe.orders.find((order) => order.id === universe.activeOrderId);
const inUniverse = new Set(universe.entries.map((entry) => entry.titleId));
// Vorschläge zum Aufnehmen: alles aus der eigenen Bibliothek, was noch fehlt.
const [libraryTitles, collections] = universe.canWrite
? await Promise.all([
prisma.libraryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { title: { title: "asc" } },
select: {
title: { select: { id: true, title: true, type: true, releaseDate: true } },
},
}),
suggestCollections(user.id),
])
: [[], []];
const candidates = libraryTitles
.map((entry) => entry.title)
.filter((title) => !inUniverse.has(title.id))
.map((title) => ({
id: title.id,
title: title.title,
type: title.type,
year: title.releaseDate?.getFullYear() ?? null,
}));
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Universum
{!universe.isOwner && ` · geteilt von ${universe.ownerName}`}
{!universe.canWrite && " · nur ansehen"}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{universe.name}</h1>
{universe.description && (
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">{universe.description}</p>
)}
</header>
<section className="fl-card mb-5 p-5" aria-label="Gesamtfortschritt">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-2xl font-semibold">
{universe.progress.completed}{" "}
<span className="text-base font-normal text-[var(--fl-muted)]">
von {universe.progress.total} abgeschlossen
</span>
</p>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{formatMinutes(universe.progress.watchedMinutes)} gesehen
{universe.progress.remainingMinutes > 0 && (
<>
{" · noch "}
<span className="text-[var(--fl-text)]">
{formatMinutes(universe.progress.remainingMinutes)}
</span>
{" bis zum Ende des Universums"}
</>
)}
</p>
</div>
{universe.nextUp && (
<div className="text-right">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<Link
href={`/titel/${universe.nextUp.titleId}`}
className="fl-gradient-text text-lg font-semibold"
>
{universe.nextUp.title}
</Link>
</div>
)}
</div>
<div className="mt-4">
<ProgressBar
percent={universe.progress.percent}
label={`${universe.progress.percent} Prozent des Universums abgeschlossen`}
/>
</div>
</section>
<div className="mb-4">
<OrderTabs universe={universe} canWrite={universe.canWrite} />
{activeOrder?.kind === "RELEASE" ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell.
</p>
) : universe.canWrite ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort.
</p>
) : null}
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="order-2 lg:order-1">
<OrderedList
universeId={universe.id}
orderId={universe.activeOrderId}
entries={universe.entries}
sortable={activeOrder?.kind === "CUSTOM"}
canWrite={universe.canWrite}
nextUpItemId={universe.nextUp?.itemId ?? null}
/>
</div>
<aside className="order-1 space-y-5 lg:order-2">
{universe.canWrite && (
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Titel aufnehmen
</h2>
<AddTitles
universeId={universe.id}
candidates={candidates}
collections={collections}
/>
</div>
)}
{universe.isOwner && (
<>
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Teilen
</h2>
<SharePanel universe={universe} />
</div>
<div className="fl-card p-4">
<DeleteUniverse universeId={universe.id} name={universe.name} />
</div>
</>
)}
</aside>
</div>
<p className="mt-8 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Alle Universen
</Link>
</p>
</AppShell>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { NewUniverseForm } from "@/components/universes/UniverseTools";
export const metadata: Metadata = { title: "Neues Universum" };
export default async function NewUniversePage() {
const user = await requireUser("/universen/neu");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<div className="mx-auto max-w-xl">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Neues Universum</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ein Universum bündelt Filme und Serien, die zusammengehören. Die
Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du
danach an.
</p>
</header>
<div className="fl-card p-5 sm:p-6">
<NewUniverseForm />
</div>
<p className="mt-6 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Zurück zur Übersicht
</Link>
</p>
</div>
</AppShell>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { listUniverses } from "@/lib/universes";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
export const metadata: Metadata = { title: "Universen" };
export default async function UniversesPage() {
const user = await requireUser("/universen");
const settings = await getSettings();
const universes = await listUniverses(user.id);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Universen</h1>
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">
Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem
Gesamtfortschritt.
</p>
</div>
<Link href="/universen/neu" className="fl-btn fl-btn-primary">
Neues Universum
</Link>
</header>
{universes.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
Noch kein Universum angelegt. Ein Universum bündelt zusammengehörige Titel — etwa alle
MCU-Filme samt Serien — und zeigt dir, was als Nächstes dran ist.
</p>
<Link href="/universen/neu" className="fl-btn fl-btn-primary mt-4">
Erstes Universum anlegen
</Link>
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{universes.map((universe) => (
<li key={universe.id}>
<Link
href={`/universen/${universe.id}`}
className="fl-card fl-poster-card block overflow-hidden p-4"
>
<div className="mb-3 flex gap-1.5">
{universe.posters.length > 0 ? (
universe.posters.map((path, index) => (
<div key={`${path}-${index}`} className="w-1/5">
<Poster path={path} alt="" size="w154" />
</div>
))
) : (
<div className="w-1/5">
<Poster path={null} alt={universe.name} size="w154" />
</div>
)}
</div>
<h2 className="font-semibold">{universe.name}</h2>
{universe.description && (
<p className="mt-1 line-clamp-2 text-sm text-[var(--fl-muted)]">
{universe.description}
</p>
)}
<p className="mt-2 text-xs text-[var(--fl-faint)]">
{universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"}
{!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={universe.percent}
label={`${universe.completed} von ${universe.itemCount} abgeschlossen`}
showValue
/>
</div>
</Link>
</li>
))}
</ul>
)}
</AppShell>
);
}
+129
View File
@@ -0,0 +1,129 @@
import type { Metadata } from "next";
import { requireAdmin } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { env } from "@/lib/env";
import { AppShell } from "@/components/AppShell";
import { AdminSettings, UserRow } from "@/components/settings/Forms";
import { formatRelative } from "@/lib/labels";
export const metadata: Metadata = { title: "Verwaltung" };
export default async function AdminPage() {
const admin = await requireAdmin();
const settings = await getSettings();
const [users, lastRun, catalog] = await Promise.all([
prisma.user.findMany({
orderBy: { createdAt: "asc" },
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
_count: { select: { entries: true } },
},
}),
prisma.syncRun.findFirst({ orderBy: { startedAt: "desc" } }),
Promise.all([
prisma.title.count(),
prisma.episode.count(),
prisma.watch.count(),
]),
]);
const [titleCount, episodeCount, watchCount] = catalog;
return (
<AppShell user={admin} instanceName={settings.instanceName}>
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Verwaltung</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Einstellungen der Instanz, Konten und Hintergrundlauf.
</p>
</header>
<div className="grid gap-6 lg:grid-cols-2">
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">Instanz</h2>
<AdminSettings
registrationEnabled={settings.registrationEnabled}
publicProfilesEnabled={settings.publicProfilesEnabled}
instanceName={settings.instanceName}
/>
</section>
<div className="space-y-6">
<section className="fl-card p-5">
<h2 className="mb-4 text-lg font-semibold">Zustand</h2>
<dl className="grid grid-cols-2 gap-4 text-sm">
<Fact label="Konten" value={String(users.length)} />
<Fact label="Titel im Katalog" value={titleCount.toLocaleString("de-DE")} />
<Fact label="Episoden" value={episodeCount.toLocaleString("de-DE")} />
<Fact label="Sichtungen" value={watchCount.toLocaleString("de-DE")} />
<Fact
label="TMDB"
value={env.tmdb.isConfigured ? "eingerichtet" : "kein Schlüssel"}
/>
<Fact label="SMTP" value={env.smtp.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
<Fact
label="Zeitplan"
value={
env.scheduler.enabled
? `täglich ${String(env.scheduler.hour).padStart(2, "0")}:05 Uhr`
: "abgeschaltet"
}
/>
<Fact label="OIDC" value={env.oidc.isConfigured ? "eingerichtet" : "nicht gesetzt"} />
</dl>
</section>
<section className="fl-card p-5">
<h2 className="mb-3 text-lg font-semibold">Letzter Abgleich</h2>
{lastRun ? (
<p className="text-sm text-[var(--fl-muted)]">
{formatRelative(lastRun.startedAt)} ·{" "}
<span style={{ color: lastRun.ok ? "var(--fl-success)" : "var(--fl-danger)" }}>
{lastRun.ok ? "erfolgreich" : "mit Fehlern"}
</span>
{lastRun.message ? ` · ${lastRun.message}` : ""}
</p>
) : (
<p className="text-sm text-[var(--fl-faint)]">Noch kein Abgleich gelaufen.</p>
)}
</section>
</div>
</div>
<section className="fl-card mt-6 p-5">
<h2 className="mb-2 text-lg font-semibold">Konten</h2>
<ul className="divide-y divide-[var(--fl-border)]">
{users.map((user) => (
<UserRow
key={user.id}
user={{
id: user.id,
name: user.name,
email: user.email,
role: user.role,
isActive: user.isActive,
entries: user._count.entries,
}}
isSelf={user.id === admin.id}
/>
))}
</ul>
</section>
</AppShell>
);
}
function Fact({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
<dd className="mt-0.5 font-medium">{value}</dd>
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
import Link from "next/link";
import { clsx } from "clsx";
import { episodeCode } from "@/lib/labels";
import type { UpcomingEpisode } from "@/lib/stats";
/**
* Monatsraster mit den Ausstrahlungsterminen.
*
* Bewusst als Tabelle ausgezeichnet: ein Kalender *ist* eine Tabelle, und mit
* Screenreader wird daraus dann „Woche 3, Donnerstag, 14." statt einer
* ununterscheidbaren Kachelwüste.
*/
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
function startOfGrid(month: Date): Date {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
// Montag als Wochenanfang: Sonntag (0) wird zu 6.
const weekday = (first.getDay() + 6) % 7;
return new Date(first.getFullYear(), first.getMonth(), 1 - weekday);
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function CalendarMonth({
month,
episodes,
}: {
month: Date;
episodes: UpcomingEpisode[];
}) {
const today = new Date();
const start = startOfGrid(month);
// Sechs Wochen zeigen immer den ganzen Monat, egal auf welchen Tag er fällt.
const days = Array.from({ length: 42 }, (_, index) => {
const date = new Date(start);
date.setDate(start.getDate() + index);
return date;
});
const byDay = new Map<string, UpcomingEpisode[]>();
for (const episode of episodes) {
const key = episode.airDate.toDateString();
const list = byDay.get(key);
if (list) list.push(episode);
else byDay.set(key, [episode]);
}
const weeks = Array.from({ length: 6 }, (_, index) => days.slice(index * 7, index * 7 + 7));
return (
<table className="w-full table-fixed border-collapse">
<caption className="sr-only">
Ausstrahlungstermine im {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
</caption>
<thead>
<tr>
{WEEKDAYS.map((day) => (
<th
key={day}
scope="col"
className="pb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]"
>
{day}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, weekIndex) => (
<tr key={weekIndex}>
{week.map((date) => {
const inMonth = date.getMonth() === month.getMonth();
const isToday = isSameDay(date, today);
const list = byDay.get(date.toDateString()) ?? [];
return (
<td
key={date.toISOString()}
className={clsx(
"h-24 border border-[var(--fl-border)] p-1 align-top sm:h-28",
!inMonth && "opacity-40",
)}
style={
list.length > 0
? { backgroundColor: "color-mix(in oklab, var(--fl-magenta) 7%, transparent)" }
: undefined
}
>
<span
className={clsx(
"mb-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs tabular-nums",
isToday
? "font-semibold text-[var(--fl-on-accent)]"
: "text-[var(--fl-faint)]",
)}
style={isToday ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{date.getDate()}
</span>
<ul className="space-y-0.5">
{list.slice(0, 3).map((episode) => (
<li key={episode.episodeId}>
<Link
href={`/titel/${episode.titleId}`}
title={`${episode.title} — ${episodeCode(episode.seasonNumber, episode.episodeNumber)} ${episode.name}`}
className="block truncate rounded px-1 py-0.5 text-[0.7rem] leading-tight text-[var(--fl-text)] transition-colors hover:bg-[var(--fl-surface-2)]"
>
<span className="font-mono text-[var(--fl-accent)]">
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
</span>{" "}
{episode.title}
</Link>
</li>
))}
{list.length > 3 && (
<li className="px-1 text-[0.7rem] text-[var(--fl-faint)]">
+ {list.length - 3} weitere
</li>
)}
</ul>
</td>
);
})}
</tr>
))}
</tbody>
</table>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { clsx } from "clsx";
import { posterUrl, type ImageSize } from "@/lib/image-url";
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
/**
* Posterbild mit Rückfallebene.
*
* Nicht jeder Titel hat bei TMDB ein Poster, manuell angelegte schon gar
* nicht, und einzelne Pfade verschwinden mit der Zeit.
*
* Die Rückfallkachel liegt deshalb **dauerhaft darunter**, das Bild darüber:
* lädt es, verdeckt es die Kachel; lädt es nicht, bleibt die Kachel stehen.
* Bewusst ohne onError-Handler — ein Bild kann schon fehlschlagen, bevor React
* den Handler überhaupt angehängt hat. Das leere alt-Attribut sorgt dafür,
* dass ein kaputtes Bild in sich zusammenfällt statt ein Symbol zu zeigen;
* den Titel trägt der Container als aria-label.
*/
export function Poster({
path,
alt,
size = "w342",
className,
sizes = "(min-width: 1024px) 12rem, 40vw",
priority = false,
}: {
path: string | null | undefined;
alt: string;
size?: ImageSize;
className?: string;
sizes?: string;
priority?: boolean;
}) {
const url = posterUrl(path, size);
return (
<div
role="img"
aria-label={alt}
className={clsx(
"relative aspect-[2/3] w-full overflow-hidden rounded-[var(--radius-poster,0.875rem)] bg-[var(--fl-surface-2)]",
className,
)}
>
<div
aria-hidden
className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-3 text-center"
style={{ backgroundImage: "var(--fl-gradient-soft)" }}
>
<FlimmerMark className="h-8 w-8 opacity-70" flat />
<span className="line-clamp-3 text-xs text-[var(--fl-muted)]">{alt}</span>
</div>
{url && (
// Bewusst <img> statt next/image: die Bilder laufen bereits über den
// eigenen Cache-Endpunkt, eine zweite Optimierungsschicht brächte dem
// selbst gehosteten Server nur zusätzliche Last.
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt=""
sizes={sizes}
loading={priority ? "eager" : "lazy"}
decoding="async"
className="absolute inset-0 h-full w-full object-cover"
/>
)}
</div>
);
}
/** Breitbild-Variante für Kopfbereiche (Backdrop). */
export function Backdrop({
path,
alt,
className,
}: {
path: string | null | undefined;
alt: string;
className?: string;
}) {
const url = posterUrl(path, "w1280");
if (!url) return null;
return (
<div className={clsx("absolute inset-0 overflow-hidden", className)} aria-hidden>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt="" className="h-full w-full object-cover opacity-25" />
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(to top, var(--fl-bg) 8%, color-mix(in oklab, var(--fl-bg) 70%, transparent) 55%, color-mix(in oklab, var(--fl-bg) 30%, transparent) 100%)",
}}
/>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { clsx } from "clsx";
/**
* Fortschrittsbalken im Signaturverlauf.
*
* Der Wert steht zusätzlich als Text daneben — ein Balken allein ist für
* Screenreader und bei starker Vergrößerung wertlos.
*/
export function ProgressBar({
percent,
label,
className,
showValue = false,
}: {
percent: number;
label: string;
className?: string;
showValue?: boolean;
}) {
const value = Math.max(0, Math.min(100, Math.round(percent)));
return (
<div className={clsx("space-y-1", className)}>
<div
className="fl-progress"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<span style={{ width: `${value}%` }} />
</div>
{showValue && <p className="text-xs text-[var(--fl-faint)]">{label}</p>}
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { posterUrl } from "@/lib/image-url";
/**
* Streamingdienste als Badges.
*
* Farbe und Logo kommen aus dem Katalog; Dienste ohne hinterlegte Akzentfarbe
* bekommen einen neutralen Ton, damit die Reihe trotzdem ruhig aussieht.
*/
export type ProviderBadgeData = {
id: string;
name: string;
logoPath: string | null;
color: string | null;
};
const KIND_LABEL = {
FLATRATE: "Im Abo",
FREE: "Kostenlos",
ADS: "Mit Werbung",
RENT: "Leihen",
BUY: "Kaufen",
} as const;
export type ProviderKindKey = keyof typeof KIND_LABEL;
export function ProviderBadge({ provider }: { provider: ProviderBadgeData }) {
const logo = posterUrl(provider.logoPath, "w92");
const accent = provider.color ?? "var(--fl-border-strong)";
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border py-1 pl-1 pr-3 text-xs font-medium"
style={{
borderColor: `color-mix(in oklab, ${accent} 55%, transparent)`,
backgroundColor: `color-mix(in oklab, ${accent} 14%, transparent)`,
}}
>
{/* Farbpunkt liegt darunter; ein Logo, das nicht lädt, fällt in sich
zusammen und gibt ihn wieder frei. */}
<span className="relative h-5 w-5 shrink-0">
<span
aria-hidden
className="absolute inset-0 rounded-full"
style={{ backgroundColor: accent }}
/>
{logo && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={logo}
alt=""
loading="lazy"
decoding="async"
className="absolute inset-0 h-5 w-5 rounded-full object-cover"
/>
)}
</span>
<span className="text-[var(--fl-text)]">{provider.name}</span>
</span>
);
}
/** Alle Verfügbarkeiten eines Titels, nach Art gruppiert. */
export function ProviderList({
groups,
region,
}: {
groups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>>;
region: string;
}) {
const order: ProviderKindKey[] = ["FLATRATE", "FREE", "ADS", "RENT", "BUY"];
const present = order.filter((kind) => (groups[kind]?.length ?? 0) > 0);
if (present.length === 0) {
return (
<p className="text-sm text-[var(--fl-faint)]">
Für die Region {region} meldet TMDB derzeit keinen Streamingdienst.
</p>
);
}
return (
<div className="space-y-3">
{present.map((kind) => (
<div key={kind} className="flex flex-wrap items-center gap-2">
<span className="w-24 shrink-0 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
{KIND_LABEL[kind]}
</span>
{groups[kind]?.map((provider) => (
<ProviderBadge key={`${kind}-${provider.id}`} provider={provider} />
))}
</div>
))}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter } from "next/navigation";
import { clsx } from "clsx";
/**
* Suchfeld mit Typfilter.
*
* Die Anfrage steht in der URL — damit sind Treffer teilbar, der Zurück-Knopf
* funktioniert und die Suche selbst bleibt eine Server-Komponente. Getippte
* Eingaben werden kurz gesammelt, bevor die Adresse fortgeschrieben wird.
*/
export function SearchField({
initialQuery,
filter,
filters,
}: {
initialQuery: string;
filter: string;
filters: { key: string; label: string }[];
}) {
const router = useRouter();
const pathname = usePathname();
const [value, setValue] = useState(initialQuery);
const [pending, startTransition] = useTransition();
const first = useRef(true);
useEffect(() => {
// Beim ersten Rendern nichts tun — sonst ersetzt die Komponente die
// gerade geladene URL durch eine identische.
if (first.current) {
first.current = false;
return;
}
const timer = setTimeout(() => {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
if (filter !== "alle") params.set("typ", filter);
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}, 350);
return () => clearTimeout(timer);
}, [value, filter, pathname, router]);
function selectFilter(key: string) {
const params = new URLSearchParams();
if (value.trim()) params.set("q", value.trim());
if (key !== "alle") params.set("typ", key);
startTransition(() => {
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
});
}
return (
<div className="space-y-3">
<div className="relative">
<SearchIcon />
<input
type="search"
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Serie oder Film suchen …"
aria-label="Suchbegriff"
autoFocus
className="fl-input pl-10 text-base"
/>
{pending && (
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-[var(--fl-faint)]"
role="status"
>
sucht …
</span>
)}
</div>
<div className="flex flex-wrap gap-2" role="group" aria-label="Nach Typ filtern">
{filters.map((item) => (
<button
key={item.key}
type="button"
onClick={() => selectFilter(item.key)}
aria-pressed={filter === item.key}
className={clsx(
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
filter === item.key
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={filter === item.key ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{item.label}
</button>
))}
</div>
</div>
);
}
function SearchIcon() {
return (
<svg
viewBox="0 0 24 24"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--fl-faint)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<circle cx="11" cy="11" r="6.5" />
<path d="m16 16 4.5 4.5" strokeLinecap="round" />
</svg>
);
}
@@ -0,0 +1,27 @@
"use client";
import { useEffect } from "react";
/**
* Meldet den Service Worker an.
*
* Nur in der Produktion: im Entwicklungsmodus würde ein Zwischenspeicher
* dauernd veraltete Bündel ausliefern und jede Änderung verschleiern.
*/
export function ServiceWorkerRegistration() {
useEffect(() => {
if (process.env.NODE_ENV !== "production") return;
if (!("serviceWorker" in navigator)) return;
const register = () => {
navigator.serviceWorker.register("/sw.js").catch((error) => {
console.warn("Service Worker konnte nicht angemeldet werden:", error);
});
};
if (document.readyState === "complete") register();
else window.addEventListener("load", register, { once: true });
}, []);
return null;
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { clsx } from "clsx";
import type { ActionResult } from "@/app/actions/library";
/**
* Kurze Rückmeldungen am unteren Rand — mit optionalem „Rückgängig".
*
* Warum überhaupt: Beim Abhaken einer Episode passiert die eigentliche Wirkung
* weit weg vom Klick (Fortschrittsbalken, Status, „nächste Episode"). Eine
* kurze Bestätigung sagt, was gerade geschehen ist — und lässt es zurücknehmen,
* ohne die Stelle wiederfinden zu müssen.
*/
type Toast = {
id: number;
message: string;
tone: "info" | "error";
undo?: () => Promise<void> | void;
};
type ToastContext = {
show(message: string, options?: { tone?: Toast["tone"]; undo?: Toast["undo"] }): void;
/** Bequemer Weg für Aktionsergebnisse samt Rückgängig-Möglichkeit. */
report(result: ActionResult, undo?: (watchIds: string[]) => Promise<unknown>): void;
};
const Context = createContext<ToastContext | null>(null);
const VISIBLE_MS = 6000;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const show = useCallback<ToastContext["show"]>((message, options) => {
const id = nextId.current++;
setToasts((current) => [
...current.slice(-2),
{ id, message, tone: options?.tone ?? "info", undo: options?.undo },
]);
}, []);
const report = useCallback<ToastContext["report"]>(
(result, undo) => {
if (!result.ok) {
show(result.message ?? "Das hat nicht geklappt.", { tone: "error" });
return;
}
if (!result.message) return;
const ids = result.watchIds ?? [];
show(result.message, {
undo: undo && ids.length > 0 ? () => void undo(ids) : undefined,
});
},
[show],
);
return (
<Context.Provider value={{ show, report }}>
{children}
<div
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={() => dismiss(toast.id)} />
))}
</div>
</Context.Provider>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
useEffect(() => {
const timer = setTimeout(onDismiss, VISIBLE_MS);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div
role={toast.tone === "error" ? "alert" : "status"}
className={clsx(
"pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border px-4 py-2.5 text-sm shadow-[var(--fl-shadow)] backdrop-blur-xl",
"motion-safe:animate-[fl-toast-in_240ms_var(--ease-glow)]",
)}
style={{
backgroundColor: "var(--fl-surface-solid)",
borderColor:
toast.tone === "error"
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
: "var(--fl-border)",
color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)",
}}
>
<span className="flex-1">{toast.message}</span>
{toast.undo && (
<button
type="button"
onClick={() => {
void toast.undo?.();
onDismiss();
}}
className="shrink-0 rounded-lg px-2 py-1 text-sm font-semibold text-[var(--fl-accent)] underline-offset-2 hover:underline"
>
Rückgängig
</button>
)}
<button
type="button"
onClick={onDismiss}
aria-label="Meldung schließen"
className="shrink-0 text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
export function useToasts(): ToastContext {
const context = useContext(Context);
if (!context) {
throw new Error("useToasts braucht einen ToastProvider im Elternbaum.");
}
return context;
}
+232
View File
@@ -0,0 +1,232 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import {
addTitleAction,
setStatusAction,
startRewatchAction,
trashEntryAction,
updateEntryAction,
} from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { STATUS_COLOR, STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
/** Aufnahmeknopf für Titel, die noch nicht in der Bibliothek liegen. */
export function AddToLibrary({ titleId, isSeries }: { titleId: string; isSeries: boolean }) {
const { report } = useToasts();
const router = useRouter();
const [pending, startTransition] = useTransition();
function add(status: StatusKey) {
startTransition(async () => {
report(await addTitleAction(titleId, status));
router.refresh();
});
}
return (
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={pending}
onClick={() => add("WATCHING")}
className="fl-btn fl-btn-primary"
>
{isSeries ? "Ich schaue das gerade" : "In die Bibliothek"}
</button>
<button
type="button"
disabled={pending}
onClick={() => add("WATCHLIST")}
className="fl-btn fl-btn-ghost"
>
Auf die Watchlist
</button>
</div>
);
}
/** Statusauswahl als Chip-Reihe — schneller zu treffen als ein Auswahlfeld. */
export function StatusPicker({ entryId, status }: { entryId: string; status: StatusKey }) {
const { report } = useToasts();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [optimistic, setOptimistic] = useState(status);
function choose(next: StatusKey) {
if (next === optimistic) return;
setOptimistic(next);
startTransition(async () => {
const result = await setStatusAction(entryId, next);
if (!result.ok) setOptimistic(status);
report(result);
router.refresh();
});
}
return (
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Status">
{STATUS_ORDER.map((key) => {
const active = optimistic === key;
return (
<button
key={key}
type="button"
disabled={pending}
onClick={() => choose(key)}
aria-pressed={active}
className={clsx(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "text-[var(--fl-text)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={
active
? {
borderColor: `color-mix(in oklab, ${STATUS_COLOR[key]} 60%, transparent)`,
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[key]} 16%, transparent)`,
}
: undefined
}
>
{STATUS_LABEL[key]}
</button>
);
})}
</div>
);
}
/** Bewertung von 1 bis 10. */
export function RatingPicker({ entryId, rating }: { entryId: string; rating: number | null }) {
const { report } = useToasts();
const router = useRouter();
const [value, setValue] = useState(rating);
const [hover, setHover] = useState<number | null>(null);
const [, startTransition] = useTransition();
function choose(next: number | null) {
setValue(next);
startTransition(async () => {
report(await updateEntryAction(entryId, { rating: next }));
router.refresh();
});
}
const shown = hover ?? value ?? 0;
return (
<div className="flex items-center gap-2">
<div
className="flex gap-0.5"
role="group"
aria-label="Bewertung von 1 bis 10"
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: 10 }, (_, index) => index + 1).map((step) => (
<button
key={step}
type="button"
aria-label={`${step} von 10`}
aria-pressed={value === step}
onMouseEnter={() => setHover(step)}
onFocus={() => setHover(step)}
onBlur={() => setHover(null)}
onClick={() => choose(value === step ? null : step)}
className="group p-0.5"
>
<span
className={clsx(
"block h-5 w-2.5 rounded-full transition-colors",
step <= shown ? "" : "bg-[var(--fl-surface-2)]",
)}
style={step <= shown ? { backgroundImage: "var(--fl-gradient)" } : undefined}
/>
</button>
))}
</div>
<span className="w-14 text-sm tabular-nums text-[var(--fl-muted)]">
{value ? `${value}/10` : "—"}
</span>
</div>
);
}
/** Papierkorb und neuer Durchlauf. */
export function EntryActions({
entryId,
currentRun,
canRewatch,
}: {
entryId: string;
currentRun: number;
canRewatch: boolean;
}) {
const { report } = useToasts();
const router = useRouter();
const [pending, startTransition] = useTransition();
const [confirming, setConfirming] = useState(false);
return (
<div className="flex flex-wrap items-center gap-2">
{canRewatch && (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await startRewatchAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost text-sm"
>
Noch einmal schauen
</button>
)}
{currentRun > 0 && (
<span className="text-xs text-[var(--fl-faint)]">Durchlauf {currentRun + 1}</span>
)}
<span className="flex-1" />
{confirming ? (
<span className="flex items-center gap-2 text-sm">
<span className="text-[var(--fl-muted)]">In den Papierkorb?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await trashEntryAction(entryId));
router.push("/bibliothek");
})
}
className="fl-btn fl-btn-ghost px-2.5 py-1 text-sm text-[var(--fl-danger)]"
>
Ja, weg damit
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="fl-btn fl-btn-ghost px-2.5 py-1 text-sm"
>
Abbrechen
</button>
</span>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Aus der Bibliothek entfernen
</button>
)}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* Notizfeld, „wo schaue ich das" und eigene Tags.
*
* Gespeichert wird beim Verlassen des Feldes, nicht bei jedem Tastendruck —
* das spart Anfragen und erspart das Suchen nach einem Speichern-Knopf.
*/
export function EntryNotes({
entryId,
notes,
whereToWatch,
tags,
suggestion,
}: {
entryId: string;
notes: string | null;
whereToWatch: string | null;
tags: string[];
/** Vorschlag aus der Streaming-Verfügbarkeit. */
suggestion: string | null;
}) {
return (
<div className="space-y-4">
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} />
</div>
);
}
function useAutosave(entryId: string) {
const { report } = useToasts();
const router = useRouter();
const [, startTransition] = useTransition();
return (data: Record<string, unknown>) =>
startTransition(async () => {
const result = await updateEntryAction(entryId, data);
if (!result.ok) report(result);
router.refresh();
});
}
function WhereToWatch({
entryId,
value,
suggestion,
}: {
entryId: string;
value: string | null;
suggestion: string | null;
}) {
const save = useAutosave(entryId);
const [text, setText] = useState(value ?? "");
const initial = useRef(value ?? "");
return (
<div className="space-y-1.5">
<label htmlFor="whereToWatch" className="block text-sm font-medium text-[var(--fl-muted)]">
Wo schaue ich das?
</label>
<input
id="whereToWatch"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ whereToWatch: text });
}}
placeholder={suggestion ? `Vorschlag: ${suggestion}` : "z. B. Netflix, Blu-ray, Mediathek"}
className="fl-input"
/>
{suggestion && text !== suggestion && (
<button
type="button"
onClick={() => {
setText(suggestion);
initial.current = suggestion;
save({ whereToWatch: suggestion });
}}
className="text-xs text-[var(--fl-accent)] underline-offset-2 hover:underline"
>
{suggestion} übernehmen
</button>
)}
</div>
);
}
function Notes({ entryId, value }: { entryId: string; value: string | null }) {
const save = useAutosave(entryId);
const [text, setText] = useState(value ?? "");
const initial = useRef(value ?? "");
return (
<div className="space-y-1.5">
<label htmlFor="notes" className="block text-sm font-medium text-[var(--fl-muted)]">
Notiz
</label>
<textarea
id="notes"
value={text}
rows={4}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ notes: text });
}}
placeholder="Was du dir merken willst — Lieblingsfolge, mit wem du das schaust, wo du aufgehört hast."
className="fl-input resize-y"
/>
</div>
);
}
function TagEditor({ entryId, tags }: { entryId: string; tags: string[] }) {
const { report } = useToasts();
const router = useRouter();
const [current, setCurrent] = useState(tags);
const [draft, setDraft] = useState("");
const [, startTransition] = useTransition();
function commit(next: string[]) {
setCurrent(next);
startTransition(async () => {
const result = await setTagsAction(entryId, next);
if (!result.ok) {
report(result);
setCurrent(tags);
}
router.refresh();
});
}
function add() {
const name = draft.trim().toLocaleLowerCase("de-DE");
setDraft("");
if (!name || current.includes(name)) return;
commit([...current, name]);
}
return (
<div className="space-y-1.5">
<label htmlFor="tag-input" className="block text-sm font-medium text-[var(--fl-muted)]">
Tags
</label>
{current.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{current.map((tag) => (
<li key={tag}>
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--fl-border)] py-1 pl-3 pr-1.5 text-xs">
{tag}
<button
type="button"
onClick={() => commit(current.filter((entry) => entry !== tag))}
aria-label={`Tag ${tag} entfernen`}
className="rounded-full p-0.5 text-[var(--fl-faint)] hover:text-[var(--fl-danger)]"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
</span>
</li>
))}
</ul>
)}
<input
id="tag-input"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
add();
}
}}
onBlur={add}
placeholder="Tag eingeben und Enter drücken"
className="fl-input"
/>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import type { LibraryItem } from "@/lib/queries";
/**
* Posterkachel der Bibliothek.
*
* Alles Wesentliche steht ohne Klick da: Status, Fortschritt, Bewertung.
* Die Karte ist ein Link — der ganze Bereich ist anklickbar, nicht nur der Titel.
*/
export function LibraryCard({ item }: { item: LibraryItem }) {
const status = item.status as StatusKey;
const showProgress = item.type === "SERIES" && item.total > 0 && item.status !== "WATCHLIST";
return (
<Link href={`/titel/${item.titleId}`} className="fl-poster-card group block focus:outline-none">
<div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" />
<span
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
style={{
color: STATUS_COLOR[status],
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
}}
>
{STATUS_LABEL[status]}
</span>
{item.rating != null && (
<span
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
{item.rating}
</span>
)}
{item.currentRun > 0 && (
<span className="absolute bottom-2 right-2 rounded-full bg-[color-mix(in_oklab,var(--fl-bg)_78%,transparent)] px-2 py-0.5 text-[0.65rem] font-medium text-[var(--fl-muted)] backdrop-blur-md">
{item.currentRun + 1}. Durchlauf
</span>
)}
</div>
<div className="mt-2 space-y-1">
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
</p>
{showProgress && (
<div className="pt-0.5">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
<p className="mt-1 text-xs text-[var(--fl-faint)]">
{item.watched}/{item.total} Episoden
</p>
</div>
)}
</div>
</Link>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
import type { LibraryFacets } from "@/lib/queries";
/**
* Filterleiste der Bibliothek.
*
* Der gesamte Zustand steht in der URL: Ansichten sind teilbar, der
* Zurück-Knopf tut das Erwartbare, und die Liste selbst bleibt eine
* Server-Komponente ohne eigenen Zustand.
*/
const SORTS = [
{ key: "aktualisiert", label: "Zuletzt aktualisiert" },
{ key: "hinzugefuegt", label: "Zuletzt hinzugefügt" },
{ key: "titel", label: "Titel" },
{ key: "bewertung", label: "Bewertung" },
{ key: "jahr", label: "Erscheinungsjahr" },
] as const;
export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [, startTransition] = useTransition();
const [query, setQuery] = useState(params.get("q") ?? "");
const first = useRef(true);
function apply(changes: Record<string, string | null>) {
const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key);
else next.set(key, value);
}
startTransition(() => {
router.replace(next.size ? `${pathname}?${next}` : pathname, { scroll: false });
});
}
// Volltextsuche gesammelt anwenden, nicht bei jedem Tastendruck.
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const status = params.get("status");
const type = params.get("typ");
const sort = params.get("sortierung") ?? "aktualisiert";
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length;
return (
<div className="space-y-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="In der Bibliothek suchen …"
aria-label="Bibliothek durchsuchen"
className="fl-input"
/>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
<Chip active={!status} onClick={() => apply({ status: null })}>
Alle <Count value={facets.counts.ALLE} />
</Chip>
{STATUS_ORDER.map((key) => (
<Chip key={key} active={status === key} onClick={() => apply({ status: key })}>
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
</Chip>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
label="Typ"
value={type ?? ""}
onChange={(value) => apply({ typ: value || null })}
options={[
{ value: "", label: "Serien und Filme" },
{ value: "SERIES", label: "Nur Serien" },
{ value: "MOVIE", label: "Nur Filme" },
]}
/>
{facets.genres.length > 0 && (
<Select
label="Genre"
value={params.get("genre") ?? ""}
onChange={(value) => apply({ genre: value || null })}
options={[
{ value: "", label: "Alle Genres" },
...facets.genres.map((genre) => ({ value: genre, label: genre })),
]}
/>
)}
{facets.providers.length > 0 && (
<Select
label="Dienst"
value={params.get("dienst") ?? ""}
onChange={(value) => apply({ dienst: value || null })}
options={[
{ value: "", label: "Alle Dienste" },
...facets.providers.map((name) => ({ value: name, label: name })),
]}
/>
)}
{facets.tags.length > 0 && (
<Select
label="Tag"
value={params.get("tag") ?? ""}
onChange={(value) => apply({ tag: value || null })}
options={[
{ value: "", label: "Alle Tags" },
...facets.tags.map((tag) => ({ value: tag, label: tag })),
]}
/>
)}
<Select
label="Sortierung"
value={sort}
onChange={(value) => apply({ sortierung: value === "aktualisiert" ? null : value })}
options={SORTS.map((entry) => ({ value: entry.key, label: entry.label }))}
/>
{(activeCount > 0 || status || query) && (
<button
type="button"
onClick={() => {
setQuery("");
startTransition(() => router.replace(pathname, { scroll: false }));
}}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
Filter zurücksetzen
</button>
)}
</div>
</div>
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={clsx(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={active ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{children}
</button>
);
}
function Count({ value }: { value: number }) {
return <span className="ml-1 text-xs tabular-nums opacity-70">{value}</span>;
}
function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<label className="inline-flex items-center gap-1.5 text-sm">
<span className="sr-only">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={label}
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm text-[var(--fl-text)] transition-colors hover:border-[var(--fl-border-strong)]"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createManualTitleAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { STATUS_LABEL, STATUS_ORDER } from "@/lib/labels";
/**
* Titel von Hand anlegen — für alles, was TMDB nicht kennt.
*
* Pflicht ist nur der Titel. Alles Weitere ist optional, weil man bei einem
* Heimvideo oder einem Vortragsmitschnitt selten die Laufzeit zur Hand hat.
*/
export function ManualTitleForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [type, setType] = useState<"SERIES" | "MOVIE">("MOVIE");
const [error, setError] = useState<string | null>(null);
function submit(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (!title) {
setError("Ohne Titel geht es nicht.");
return;
}
setError(null);
const year = Number.parseInt(String(formData.get("year") ?? ""), 10);
const runtime = Number.parseInt(String(formData.get("runtime") ?? ""), 10);
const genres = String(formData.get("genres") ?? "")
.split(",")
.map((genre) => genre.trim())
.filter(Boolean);
startTransition(async () => {
const result = await createManualTitleAction({
type,
title,
year: Number.isFinite(year) ? year : null,
runtime: Number.isFinite(runtime) ? runtime : null,
overview: String(formData.get("overview") ?? ""),
genres,
status: String(formData.get("status") ?? "WATCHLIST"),
});
report(result);
if (result.ok && result.titleId) router.push(`/titel/${result.titleId}`);
});
}
return (
<form action={submit} className="space-y-4">
<fieldset>
<legend className="mb-2 block text-sm font-medium text-[var(--fl-muted)]">Was ist es?</legend>
<div className="flex gap-2">
{(["MOVIE", "SERIES"] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setType(option)}
aria-pressed={type === option}
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
style={
type === option
? { backgroundImage: "var(--fl-gradient)", color: "var(--fl-on-accent)", borderColor: "transparent" }
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
}
>
{option === "MOVIE" ? "Film" : "Serie"}
</button>
))}
</div>
</fieldset>
<Field id="title" label="Titel" required autoFocus />
<div className="grid gap-4 sm:grid-cols-2">
<Field id="year" label="Erscheinungsjahr" type="number" placeholder="2026" />
<Field
id="runtime"
label={type === "SERIES" ? "Minuten je Episode" : "Laufzeit in Minuten"}
type="number"
placeholder="95"
/>
</div>
<Field id="genres" label="Genres" placeholder="Drama, Komödie — durch Komma getrennt" />
<div className="space-y-1.5">
<label htmlFor="overview" className="block text-sm font-medium text-[var(--fl-muted)]">
Beschreibung
</label>
<textarea id="overview" name="overview" rows={3} className="fl-input resize-y" />
</div>
<div className="space-y-1.5">
<label htmlFor="status" className="block text-sm font-medium text-[var(--fl-muted)]">
Status
</label>
<select id="status" name="status" defaultValue="WATCHLIST" className="fl-input">
{STATUS_ORDER.map((key) => (
<option key={key} value={key}>
{STATUS_LABEL[key]}
</option>
))}
</select>
</div>
{error && (
<p role="alert" className="text-sm text-[var(--fl-danger)]">
{error}
</p>
)}
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Anlegen und aufnehmen"}
</button>
</form>
);
}
function Field({
id,
label,
type = "text",
placeholder,
required = false,
autoFocus = false,
}: {
id: string;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
autoFocus?: boolean;
}) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
{label}
{!required && <span className="ml-1 text-xs text-[var(--fl-faint)]">(optional)</span>}
</label>
<input
id={id}
name={id}
type={type}
placeholder={placeholder}
required={required}
autoFocus={autoFocus}
className="fl-input"
/>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
/**
* „Als Nächstes dran" mit dem Schnellknopf.
*
* Das ist der meistbenutzte Knopf der ganzen Anwendung — deshalb steht er
* oben, ist groß, und sagt vorher, was er abhaken wird.
*/
export function NextEpisodeCard({
entryId,
next,
}: {
entryId: string;
next: {
id: string;
name: string;
seasonNumber: number;
episodeNumber: number;
airDate: string | null;
runtime: number | null;
};
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const upcoming = isUpcoming(next.airDate);
const code = episodeCode(next.seasonNumber, next.episodeNumber);
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
return (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<p className="mt-1 truncate font-medium">
<span className="fl-gradient-text font-mono font-semibold">{code}</span>{" "}
<span className="text-[var(--fl-text)]">{next.name}</span>
</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{upcoming
? `Erscheint erst am ${formatDate(next.airDate)}`
: [formatDate(next.airDate), next.runtime ? `${next.runtime} min` : null]
.filter(Boolean)
.join(" · ") || "Kein Ausstrahlungsdatum hinterlegt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await watchNextAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
title={`${code} als gesehen markieren`}
>
+1 Episode
</button>
</div>
);
}
/** Sichtungsknopf für Filme. */
export function WatchMovieButton({
entryId,
watched,
}: {
entryId: string;
watched: boolean;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
if (watched) {
return (
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Gesehen
</p>
);
}
return (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await markMovieAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
>
Als gesehen markieren
</button>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { undoAction, watchNextAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* „+1 Episode" für die Übersicht — derselbe Griff wie auf der Detailseite,
* nur ohne Kontext drumherum.
*/
export function QuickWatch({ entryId }: { entryId: string }) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
return (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await watchNextAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
+1 Episode
</button>
);
}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import {
markUpToAction,
toggleEpisodeAction,
toggleSeasonAction,
undoAction,
} from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
/**
* Staffel- und Episodenliste mit Fortschritt.
*
* Liegt der Titel in der Bibliothek (`entryId` gesetzt), lässt sich jede
* Episode abhaken, eine ganze Staffel auf einmal, oder „bis hierhin" alles
* Vorherige. Ohne Eintrag ist die Liste eine reine Übersicht.
*
* Die Häkchen reagieren sofort (optimistisch) und werden zurückgedreht, wenn
* der Server widerspricht — Abhaken soll sich anfühlen wie ein Schalter, nicht
* wie ein Formular.
*/
export type EpisodeData = {
id: string;
episodeNumber: number;
name: string;
overview: string | null;
airDate: string | null;
runtime: number | null;
};
export type SeasonData = {
id: string;
seasonNumber: number;
name: string;
overview: string | null;
airDate: string | null;
episodes: EpisodeData[];
};
export function SeasonList({
seasons,
entryId,
watchedIds,
}: {
seasons: SeasonData[];
entryId?: string;
watchedIds?: string[];
}) {
const router = useRouter();
const { report } = useToasts();
const [watched, setWatched] = useState(() => new Set(watchedIds ?? []));
const [, startTransition] = useTransition();
// Nach einem Serverabgleich (router.refresh) den Stand übernehmen.
useEffect(() => {
setWatched(new Set(watchedIds ?? []));
}, [watchedIds]);
const firstOpen = seasons.find((season) => season.seasonNumber > 0) ?? seasons[0];
const [open, setOpen] = useState<string[]>(firstOpen ? [firstOpen.id] : []);
async function undo(watchIds: string[]) {
if (!entryId) return;
await undoAction(entryId, watchIds);
router.refresh();
}
function toggleEpisode(episodeId: string, next: boolean) {
if (!entryId) return;
setWatched((current) => {
const copy = new Set(current);
if (next) copy.add(episodeId);
else copy.delete(episodeId);
return copy;
});
startTransition(async () => {
const result = await toggleEpisodeAction(entryId, episodeId, next);
if (!result.ok) {
setWatched((current) => {
const copy = new Set(current);
if (next) copy.delete(episodeId);
else copy.add(episodeId);
return copy;
});
}
report(result, next ? (ids) => undo(ids) : undefined);
router.refresh();
});
}
function toggleSeason(season: SeasonData, next: boolean) {
if (!entryId) return;
const ids = season.episodes.map((episode) => episode.id);
setWatched((current) => {
const copy = new Set(current);
for (const id of ids) {
if (next) copy.add(id);
else copy.delete(id);
}
return copy;
});
startTransition(async () => {
const result = await toggleSeasonAction(entryId, season.id, next);
report(result, next ? (watchIds) => undo(watchIds) : undefined);
router.refresh();
});
}
function markUpTo(episodeId: string) {
if (!entryId) return;
startTransition(async () => {
const result = await markUpToAction(entryId, episodeId);
report(result, (watchIds) => undo(watchIds));
router.refresh();
});
}
if (seasons.length === 0) {
return (
<p className="text-sm text-[var(--fl-faint)]">Zu dieser Serie liegen keine Staffeln vor.</p>
);
}
return (
<div className="space-y-2">
{seasons.map((season) => {
const isOpen = open.includes(season.id);
const seen = season.episodes.filter((episode) => watched.has(episode.id)).length;
const complete = season.episodes.length > 0 && seen === season.episodes.length;
return (
<section key={season.id} className="fl-card overflow-hidden">
<div className="flex items-center gap-2 px-2 py-1.5 sm:px-3">
<button
type="button"
onClick={() =>
setOpen((current) =>
current.includes(season.id)
? current.filter((id) => id !== season.id)
: [...current, season.id],
)
}
aria-expanded={isOpen}
className="flex flex-1 items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-[var(--fl-surface-2)]"
>
<Chevron open={isOpen} />
<span className="min-w-0 flex-1">
<span className="font-medium">{season.name}</span>
<span className="ml-2 text-sm text-[var(--fl-faint)]">
{entryId
? `${seen} von ${season.episodes.length} gesehen`
: `${season.episodes.length} ${season.episodes.length === 1 ? "Episode" : "Episoden"}`}
{season.airDate ? ` · ab ${formatDate(season.airDate)}` : ""}
</span>
</span>
</button>
{entryId && season.episodes.length > 0 && (
<button
type="button"
onClick={() => toggleSeason(season, !complete)}
className="shrink-0 rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
>
{complete ? "Staffel zurücksetzen" : "Staffel abhaken"}
</button>
)}
</div>
{isOpen && (
<ol className="border-t border-[var(--fl-border)]">
{season.episodes.map((episode) => (
<EpisodeRow
key={episode.id}
episode={episode}
seasonNumber={season.seasonNumber}
watched={watched.has(episode.id)}
interactive={Boolean(entryId)}
onToggle={(next) => toggleEpisode(episode.id, next)}
onMarkUpTo={() => markUpTo(episode.id)}
/>
))}
{season.episodes.length === 0 && (
<li className="px-4 py-3 text-sm text-[var(--fl-faint)]">
Für diese Staffel liegen noch keine Episoden vor.
</li>
)}
</ol>
)}
</section>
);
})}
</div>
);
}
function EpisodeRow({
episode,
seasonNumber,
watched,
interactive,
onToggle,
onMarkUpTo,
}: {
episode: EpisodeData;
seasonNumber: number;
watched: boolean;
interactive: boolean;
onToggle: (next: boolean) => void;
onMarkUpTo: () => void;
}) {
const [glimmer, setGlimmer] = useState(false);
const previous = useRef(watched);
// Beim Abhaken glimmt die Zeile einmal warm auf.
useEffect(() => {
if (watched && !previous.current) {
setGlimmer(true);
const timer = setTimeout(() => setGlimmer(false), 900);
previous.current = watched;
return () => clearTimeout(timer);
}
previous.current = watched;
}, [watched]);
const upcoming = isUpcoming(episode.airDate);
return (
<li
className={clsx(
"group flex items-start gap-3 border-b border-[var(--fl-border)] px-3 py-2.5 last:border-b-0 sm:px-4",
glimmer && "fl-glimmer",
)}
>
{interactive ? (
<label className="flex cursor-pointer items-center gap-3 pt-0.5">
<input
type="checkbox"
checked={watched}
onChange={(event) => onToggle(event.target.checked)}
className="sr-only"
/>
<Checkbox checked={watched} />
<span className="sr-only">
{episodeCode(seasonNumber, episode.episodeNumber)} als gesehen markieren
</span>
</label>
) : (
<span className="w-5" />
)}
<span className="w-14 shrink-0 pt-1 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(seasonNumber, episode.episodeNumber)}
</span>
<span className="min-w-0 flex-1">
<span
className={clsx(
"block text-sm font-medium",
watched && "text-[var(--fl-muted)]",
)}
>
{episode.name}
{upcoming && (
<span className="ml-2 rounded-full border border-[var(--fl-border)] px-2 py-0.5 align-middle text-[0.65rem] font-normal text-[var(--fl-faint)]">
noch nicht ausgestrahlt
</span>
)}
</span>
{episode.overview && (
<span className="mt-0.5 line-clamp-2 block text-xs text-[var(--fl-faint)]">
{episode.overview}
</span>
)}
{interactive && !watched && (
<button
type="button"
onClick={onMarkUpTo}
className="mt-1 text-xs text-[var(--fl-accent)] opacity-0 transition-opacity underline-offset-2 hover:underline focus:opacity-100 group-hover:opacity-100"
>
Bis hierhin gesehen
</button>
)}
</span>
<span className="shrink-0 pt-1 text-right text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate) ?? "—"}
{episode.runtime ? <span className="block">{episode.runtime} min</span> : null}
</span>
</li>
);
}
function Checkbox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden
className={clsx(
"grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
checked ? "border-transparent" : "border-[var(--fl-border-strong)]",
)}
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{checked && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className={clsx(
"h-4 w-4 shrink-0 text-[var(--fl-muted)] transition-transform duration-200",
open && "rotate-90",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="m9 6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { purgeEntryAction, restoreEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* Wiederherstellen oder endgültig löschen.
* Das endgültige Löschen fragt nach — es lässt sich nicht zurücknehmen.
*/
export function TrashActions({ entryId, title }: { entryId: string; title: string }) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [confirming, setConfirming] = useState(false);
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await restoreEntryAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
Wiederherstellen
</button>
{confirming ? (
<>
<span className="text-sm text-[var(--fl-muted)]">Endgültig löschen?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await purgeEntryAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
>
Ja, {title} löschen
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Endgültig löschen
</button>
)}
</div>
);
}
+757
View File
@@ -0,0 +1,757 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import {
changePasswordAction,
createApiKeyAction,
deleteChannelAction,
importDataAction,
revokeApiKeyAction,
runSyncAction,
saveChannelAction,
setInstanceNameAction,
setPublicProfilesAction,
setRegistrationAction,
setUserActiveAction,
setUserRoleAction,
testChannelAction,
toggleChannelAction,
updateProfileAction,
} from "@/app/actions/settings";
import { useToasts } from "@/components/Toasts";
import { formatDate } from "@/lib/labels";
/** Wiederverwendete Bausteine der Einstellungsseiten. */
function Field({
id,
label,
hint,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & { id: string; label: string; hint?: string }) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
{label}
</label>
<input id={id} name={id} className="fl-input" {...props} />
{hint && <p className="text-xs text-[var(--fl-faint)]">{hint}</p>}
</div>
);
}
function Toggle({
checked,
onChange,
label,
hint,
disabled,
}: {
checked: boolean;
onChange: (value: boolean) => void;
label: string;
hint?: string;
disabled?: boolean;
}) {
return (
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="sr-only"
/>
<span
aria-hidden
className={clsx(
"mt-0.5 h-5 w-9 shrink-0 rounded-full p-0.5 transition-colors",
checked ? "" : "bg-[var(--fl-surface-2)] ring-1 ring-[var(--fl-border-strong)]",
)}
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
<span
className={clsx(
"block h-4 w-4 rounded-full bg-[var(--fl-text)] transition-transform",
checked && "translate-x-4",
)}
/>
</span>
<span className="min-w-0">
<span className="block text-sm font-medium">{label}</span>
{hint && <span className="block text-xs text-[var(--fl-faint)]">{hint}</span>}
</span>
</label>
);
}
// ---------------------------------------------------------------------------
// Profil
// ---------------------------------------------------------------------------
export function ProfileForm({
user,
publicProfilesEnabled,
}: {
user: {
name: string;
email: string;
region: string;
publicProfile: boolean;
profileSlug: string | null;
notifyNewEpisode: boolean;
notifyRelease: boolean;
notifyAvailability: boolean;
};
publicProfilesEnabled: boolean;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [state, setState] = useState(user);
return (
<form
action={() =>
startTransition(async () => {
report(
await updateProfileAction({
name: state.name,
region: state.region,
publicProfile: state.publicProfile,
profileSlug: state.profileSlug || null,
notifyNewEpisode: state.notifyNewEpisode,
notifyRelease: state.notifyRelease,
notifyAvailability: state.notifyAvailability,
}),
);
router.refresh();
})
}
className="space-y-5"
>
<Field
id="name"
label="Anzeigename"
value={state.name}
onChange={(event) => setState({ ...state, name: event.target.value })}
required
/>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-[var(--fl-muted)]">E-Mail-Adresse</label>
<p className="fl-input text-[var(--fl-faint)]">{user.email}</p>
</div>
<Field
id="region"
label="Region"
value={state.region}
maxLength={2}
onChange={(event) => setState({ ...state, region: event.target.value.toUpperCase() })}
hint="Zwei Buchstaben nach ISO 3166 — bestimmt, welche Streamingdienste angezeigt werden (DE, AT, CH …)."
/>
<fieldset className="space-y-3">
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
Wovon soll Flimmer dir berichten?
</legend>
<Toggle
checked={state.notifyNewEpisode}
onChange={(value) => setState({ ...state, notifyNewEpisode: value })}
label="Neue Episoden"
hint="Wenn bei einer Serie in deiner Bibliothek eine Folge dazukommt."
/>
<Toggle
checked={state.notifyRelease}
onChange={(value) => setState({ ...state, notifyRelease: value })}
label="Neue Releases"
hint="Kinostart oder Heimkinostart eines Films auf deiner Watchlist."
/>
<Toggle
checked={state.notifyAvailability}
onChange={(value) => setState({ ...state, notifyAvailability: value })}
label="Neu verfügbar"
hint="Wenn ein Watchlist-Titel auf einem Dienst in deiner Region auftaucht."
/>
</fieldset>
{publicProfilesEnabled && (
<fieldset className="space-y-3">
<legend className="mb-1 text-sm font-medium text-[var(--fl-muted)]">
Öffentliches Profil
</legend>
<Toggle
checked={state.publicProfile}
onChange={(value) => setState({ ...state, publicProfile: value })}
label="Profil öffentlich zeigen"
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
/>
{state.publicProfile && (
<Field
id="profileSlug"
label="Adresse"
value={state.profileSlug ?? ""}
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
placeholder="dein-name"
hint="Erreichbar unter /u/<adresse>"
/>
)}
</fieldset>
)}
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Speichern"}
</button>
</form>
);
}
export function PasswordForm() {
const { report } = useToasts();
const [pending, startTransition] = useTransition();
return (
<form
action={(formData) =>
startTransition(async () => {
const result = await changePasswordAction({
current: String(formData.get("current") ?? ""),
next: String(formData.get("next") ?? ""),
repeat: String(formData.get("repeat") ?? ""),
});
report(result);
if (result.ok) window.location.href = "/anmelden";
})
}
className="space-y-4"
>
<Field id="current" label="Bisheriges Passwort" type="password" autoComplete="current-password" required />
<Field
id="next"
label="Neues Passwort"
type="password"
autoComplete="new-password"
required
hint="Mindestens 10 Zeichen."
/>
<Field id="repeat" label="Neues Passwort wiederholen" type="password" autoComplete="new-password" required />
<p className="text-xs text-[var(--fl-faint)]">
Nach dem Wechsel wirst du auf allen Geräten abgemeldet.
</p>
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
Passwort ändern
</button>
</form>
);
}
// ---------------------------------------------------------------------------
// API-Schlüssel
// ---------------------------------------------------------------------------
export function ApiKeys({
keys,
}: {
keys: { id: string; name: string; prefix: string; lastUsedAt: Date | null; createdAt: Date }[];
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [secret, setSecret] = useState<string | null>(null);
return (
<div className="space-y-5">
<form
action={(formData) => {
const name = String(formData.get("keyName") ?? "").trim();
if (!name) return;
startTransition(async () => {
const result = await createApiKeyAction(name);
report(result);
if (result.secret) setSecret(result.secret);
router.refresh();
});
}}
className="flex flex-wrap items-end gap-2"
>
<div className="min-w-48 flex-1 space-y-1.5">
<label htmlFor="keyName" className="block text-sm font-medium text-[var(--fl-muted)]">
Neuer Schlüssel
</label>
<input id="keyName" name="keyName" placeholder="z. B. Handy-Skript" className="fl-input" />
</div>
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
Anlegen
</button>
</form>
{secret && (
<div
role="alert"
className="rounded-xl border p-3"
style={{
borderColor: "color-mix(in oklab, var(--fl-warning) 45%, transparent)",
backgroundColor: "color-mix(in oklab, var(--fl-warning) 10%, transparent)",
}}
>
<p className="text-sm font-medium text-[var(--fl-warning)]">
Einmalig sichtbar — jetzt kopieren:
</p>
<code className="mt-2 block break-all rounded-lg bg-[var(--fl-surface-2)] p-2 font-mono text-xs">
{secret}
</code>
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(secret);
report({ ok: true, message: "In die Zwischenablage kopiert" });
}}
className="fl-btn fl-btn-ghost mt-2 px-3 py-1.5 text-sm"
>
Kopieren
</button>
</div>
)}
{keys.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">Noch kein Schlüssel angelegt.</p>
) : (
<ul className="divide-y divide-[var(--fl-border)]">
{keys.map((key) => (
<li key={key.id} className="flex flex-wrap items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{key.name}</p>
<p className="font-mono text-xs text-[var(--fl-faint)]">
{key.prefix}… · angelegt {formatDate(key.createdAt)}
{key.lastUsedAt ? ` · zuletzt benutzt ${formatDate(key.lastUsedAt)}` : " · nie benutzt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await revokeApiKeyAction(key.id));
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Zurückziehen
</button>
</li>
))}
</ul>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Benachrichtigungskanäle
// ---------------------------------------------------------------------------
const CHANNEL_FIELDS: Record<string, { key: string; label: string; placeholder: string }[]> = {
EMAIL: [{ key: "email", label: "Abweichende Adresse", placeholder: "leer = Konto-Adresse" }],
NTFY: [
{ key: "server", label: "Server", placeholder: "https://ntfy.sh" },
{ key: "topic", label: "Topic", placeholder: "flimmer-abc123" },
{ key: "token", label: "Zugriffstoken", placeholder: "optional" },
],
GOTIFY: [
{ key: "url", label: "Server", placeholder: "https://gotify.example.com" },
{ key: "token", label: "Anwendungs-Token", placeholder: "AxB..." },
],
WEBHOOK: [
{ key: "webhookUrl", label: "Adresse", placeholder: "https://discord.com/api/webhooks/…" },
],
};
const CHANNEL_LABEL: Record<string, string> = {
EMAIL: "E-Mail",
NTFY: "ntfy",
GOTIFY: "Gotify",
WEBHOOK: "Webhook (Discord, Slack, …)",
};
export function ChannelList({
channels,
smtpReady,
}: {
channels: { id: string; kind: string; name: string; config: Record<string, string>; enabled: boolean }[];
smtpReady: boolean;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [kind, setKind] = useState<string>("NTFY");
const [config, setConfig] = useState<Record<string, string>>({});
return (
<div className="space-y-6">
{channels.length > 0 && (
<ul className="divide-y divide-[var(--fl-border)]">
{channels.map((channel) => (
<li key={channel.id} className="flex flex-wrap items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{channel.name}
<span className="ml-2 text-xs font-normal text-[var(--fl-faint)]">
{CHANNEL_LABEL[channel.kind] ?? channel.kind}
</span>
</p>
<p className="truncate text-xs text-[var(--fl-faint)]">
{Object.entries(channel.config)
.filter(([key]) => key !== "token")
.map(([key, value]) => `${key}: ${value}`)
.join(" · ") || "ohne weitere Angaben"}
</p>
</div>
<Toggle
checked={channel.enabled}
onChange={(value) =>
startTransition(async () => {
report(await toggleChannelAction(channel.id, value));
router.refresh();
})
}
label=""
/>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await testChannelAction(channel.id));
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
Testen
</button>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await deleteChannelAction(channel.id));
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Entfernen
</button>
</li>
))}
</ul>
)}
<form
action={(formData) => {
const name = String(formData.get("channelName") ?? "").trim();
if (!name) return;
startTransition(async () => {
report(await saveChannelAction({ kind, name, config }));
setConfig({});
router.refresh();
});
}}
className="space-y-4 border-t border-[var(--fl-border)] pt-5"
>
<h3 className="text-sm font-semibold">Kanal hinzufügen</h3>
<div className="space-y-1.5">
<label htmlFor="kind" className="block text-sm font-medium text-[var(--fl-muted)]">
Art
</label>
<select
id="kind"
value={kind}
onChange={(event) => {
setKind(event.target.value);
setConfig({});
}}
className="fl-input"
>
{Object.entries(CHANNEL_LABEL).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
{kind === "EMAIL" && !smtpReady && (
<p className="text-xs text-[var(--fl-warning)]">
Für diese Instanz ist kein SMTP-Zugang eingerichtet — E-Mails können nicht verschickt
werden.
</p>
)}
</div>
<Field id="channelName" label="Name" placeholder="z. B. Handy" required />
{(CHANNEL_FIELDS[kind] ?? []).map((field) => (
<div key={field.key} className="space-y-1.5">
<label
htmlFor={`cfg-${field.key}`}
className="block text-sm font-medium text-[var(--fl-muted)]"
>
{field.label}
</label>
<input
id={`cfg-${field.key}`}
value={config[field.key] ?? ""}
onChange={(event) => setConfig({ ...config, [field.key]: event.target.value })}
placeholder={field.placeholder}
className="fl-input"
/>
</div>
))}
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
Kanal anlegen
</button>
</form>
</div>
);
}
// ---------------------------------------------------------------------------
// Import
// ---------------------------------------------------------------------------
export function ImportForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [format, setFormat] = useState<"csv" | "trakt">("csv");
const [summary, setSummary] = useState<string[] | null>(null);
return (
<div className="space-y-4">
<div className="flex gap-2">
{(["csv", "trakt"] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setFormat(option)}
aria-pressed={format === option}
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
style={
format === option
? {
backgroundImage: "var(--fl-gradient)",
color: "var(--fl-on-accent)",
borderColor: "transparent",
}
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
}
>
{option === "csv" ? "CSV" : "Trakt.tv (JSON)"}
</button>
))}
</div>
<p className="text-sm text-[var(--fl-muted)]">
{format === "csv"
? "Die Kopfzeile braucht mindestens eine Spalte mit dem Titel. Erkannt werden außerdem Typ, Status, Bewertung, TMDB, Jahr und Notiz."
: "Ein Export aus Trakt.tv — Watchlist, History, Ratings oder Collection. Flimmer sucht selbst die Einträge heraus."}
</p>
<input
type="file"
accept={format === "csv" ? ".csv,text/csv" : ".json,application/json"}
onChange={(event) => {
const file = event.target.files?.[0];
if (!file) return;
setSummary(null);
startTransition(async () => {
const content = await file.text();
const result = await importDataAction(format, content);
report({ ok: result.ok, message: result.message });
if (result.summary) {
setSummary([
`${result.summary.processed} Zeilen gelesen`,
`${result.summary.added} neu aufgenommen`,
`${result.summary.skipped} übersprungen (schon vorhanden oder unvollständig)`,
...result.summary.errors.slice(0, 5),
]);
}
router.refresh();
});
}}
disabled={pending}
className="fl-input file:mr-3 file:rounded-lg file:border-0 file:bg-[var(--fl-surface-2)] file:px-3 file:py-1.5 file:text-sm file:text-[var(--fl-text)]"
/>
<p className="text-xs text-[var(--fl-faint)]">
Der Import überschreibt nichts: Titel, die schon in deiner Bibliothek liegen, bleiben
unangetastet.
</p>
{summary && (
<ul className="rounded-xl border border-[var(--fl-border)] p-3 text-sm text-[var(--fl-muted)]">
{summary.map((line, index) => (
<li key={index}>{line}</li>
))}
</ul>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Verwaltung
// ---------------------------------------------------------------------------
export function AdminSettings({
registrationEnabled,
publicProfilesEnabled,
instanceName,
}: {
registrationEnabled: boolean;
publicProfilesEnabled: boolean;
instanceName: string;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
return (
<div className="space-y-5">
<form
action={(formData) =>
startTransition(async () => {
report(await setInstanceNameAction(String(formData.get("instanceName") ?? "")));
router.refresh();
})
}
className="flex flex-wrap items-end gap-2"
>
<div className="min-w-48 flex-1">
<Field id="instanceName" label="Name der Instanz" defaultValue={instanceName} />
</div>
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost">
Speichern
</button>
</form>
<Toggle
checked={registrationEnabled}
onChange={(value) =>
startTransition(async () => {
report(await setRegistrationAction(value));
router.refresh();
})
}
label="Registrierung offen"
hint="Solange es kein einziges Konto gibt, bleibt die Registrierung unabhängig davon offen."
/>
<Toggle
checked={publicProfilesEnabled}
onChange={(value) =>
startTransition(async () => {
report(await setPublicProfilesAction(value));
router.refresh();
})
}
label="Öffentliche Profile erlauben"
hint="Schaltet die Profilseiten instanzweit ab, unabhängig von der Einstellung der Nutzer."
/>
<div className="border-t border-[var(--fl-border)] pt-5">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await runSyncAction());
router.refresh();
})
}
className="fl-btn fl-btn-primary"
>
{pending ? "Läuft …" : "Abgleich jetzt starten"}
</button>
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Frischt laufende Serien bei TMDB auf, verschickt fällige Benachrichtigungen und räumt den
Papierkorb. Kann eine Weile dauern.
</p>
</div>
</div>
);
}
export function UserRow({
user,
isSelf,
}: {
user: { id: string; name: string; email: string; role: string; isActive: boolean; entries: number };
isSelf: boolean;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
return (
<li className="flex flex-wrap items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{user.name}
{user.role === "ADMIN" && (
<span
className="ml-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
Verwaltung
</span>
)}
{!user.isActive && (
<span className="ml-2 text-[0.65rem] font-semibold text-[var(--fl-danger)]">gesperrt</span>
)}
</p>
<p className="truncate text-xs text-[var(--fl-faint)]">
{user.email} · {user.entries} {user.entries === 1 ? "Titel" : "Titel"}
</p>
</div>
{!isSelf && (
<>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await setUserRoleAction(user.id, user.role === "ADMIN" ? "USER" : "ADMIN"));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
{user.role === "ADMIN" ? "Rechte entziehen" : "Zur Verwaltung"}
</button>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await setUserActiveAction(user.id, !user.isActive));
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
{user.isActive ? "Sperren" : "Freigeben"}
</button>
</>
)}
</li>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { clsx } from "clsx";
/** Seitennavigation der Einstellungen. */
const ITEMS = [
{ href: "/einstellungen", label: "Profil" },
{ href: "/einstellungen/benachrichtigungen", label: "Benachrichtigungen" },
{ href: "/einstellungen/api", label: "API-Schlüssel" },
{ href: "/einstellungen/daten", label: "Import und Export" },
];
export function SettingsNav({ isAdmin }: { isAdmin: boolean }) {
const pathname = usePathname();
return (
<nav
aria-label="Einstellungen"
className="flex gap-1 overflow-x-auto lg:flex-col lg:overflow-visible"
>
{ITEMS.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className={clsx(
"shrink-0 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
active
? "bg-[var(--fl-surface-2)] text-[var(--fl-text)]"
: "text-[var(--fl-muted)] hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]",
)}
>
{item.label}
</Link>
);
})}
{isAdmin && (
<Link
href="/verwaltung"
className="shrink-0 rounded-lg px-3 py-2 text-sm font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
>
Verwaltung
</Link>
)}
</nav>
);
}
+373
View File
@@ -0,0 +1,373 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { formatMinutes } from "@/lib/labels";
/**
* Diagramme der Statistikseite.
*
* Handgeschriebenes SVG statt einer Chart-Bibliothek: die Diagramme sind
* schlicht, sollen sich an die Design-Tokens halten und in beiden Farbschemata
* funktionieren — dafür lohnt keine Abhängigkeit mit eigenem Versionsrhythmus.
*
* Farbwahl: Die beiden Serienfarben sind abgedunkelte Varianten der
* Signaturfarben. Sie erfüllen in **beiden** Modi die Prüfungen auf
* Helligkeitsband, Farbsättigung, Farbfehlsichtigkeit (schlechtestes Paar
* ΔE 12,1 unter Deuteranopie) und Kontrast zum Untergrund. Die vollen
* Signaturtöne wären auf dunklem Grund zu hell und fielen durch.
*/
export const SERIES_COLORS = {
episodes: "#c47a18",
movies: "#d92d78",
} as const;
/** Ein Ton für Balken, die nur eine Größe zeigen (keine Identität). */
const SINGLE_HUE = "#c47a18";
// ---------------------------------------------------------------------------
// Säulen je Monat
// ---------------------------------------------------------------------------
export type MonthlyPointData = {
label: string;
episodes: number;
movies: number;
minutes: number;
};
/** Rundet die Achsenspitze auf einen glatten Wert auf. */
function niceMax(value: number): number {
if (value <= 0) return 4;
const magnitude = 10 ** Math.floor(Math.log10(value));
for (const step of [1, 2, 2.5, 5, 10]) {
const candidate = step * magnitude;
if (candidate >= value) return candidate;
}
return 10 * magnitude;
}
export function MonthlyChart({ points }: { points: MonthlyPointData[] }) {
const [active, setActive] = useState<number | null>(null);
const max = niceMax(Math.max(1, ...points.map((point) => point.episodes + point.movies)));
const ticks = [0, max / 2, max];
// Breites Seitenverhältnis: das SVG füllt die Karte und bleibt trotzdem flach.
const width = 1100;
const height = 260;
const padding = { top: 12, right: 8, bottom: 28, left: 38 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const band = plotWidth / Math.max(1, points.length);
const barWidth = Math.min(24, band - 10);
const scale = (value: number) => (value / max) * plotHeight;
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
if (total === 0) {
return (
<p className="py-8 text-center text-sm text-[var(--fl-faint)]">
In den letzten zwölf Monaten wurde noch nichts abgehakt.
</p>
);
}
return (
<figure className="m-0">
<Legend
items={[
{ label: "Episoden", color: SERIES_COLORS.episodes },
{ label: "Filme", color: SERIES_COLORS.movies },
]}
/>
<div className="relative mt-2 overflow-x-auto">
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full min-w-[32rem]"
role="img"
// Bewusst aria-label statt eines <title>-Kindes: React behandelt
// <title> als Dokument-Metadatum und hebt es aus dem SVG heraus —
// das brach die Hydration.
aria-label={`Gesehene Episoden und Filme je Monat in den letzten ${points.length} Monaten`}
onMouseLeave={() => setActive(null)}
>
{ticks.map((tick) => {
const y = padding.top + plotHeight - scale(tick);
return (
<g key={tick}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="var(--fl-border)"
strokeWidth="1"
/>
<text
x={padding.left - 8}
y={y + 4}
textAnchor="end"
className="fill-[var(--fl-faint)] text-[11px] tabular-nums"
>
{Math.round(tick)}
</text>
</g>
);
})}
{points.map((point, index) => {
const x = padding.left + index * band + (band - barWidth) / 2;
const episodeHeight = scale(point.episodes);
const movieHeight = scale(point.movies);
const baseline = padding.top + plotHeight;
const isActive = active === index;
return (
<g
key={point.label + index}
onMouseEnter={() => setActive(index)}
onFocus={() => setActive(index)}
tabIndex={0}
className="outline-none"
>
{/* Großzügige Trefferfläche — der Balken selbst ist schmal. */}
<rect
x={padding.left + index * band}
y={padding.top}
width={band}
height={plotHeight}
fill={isActive ? "var(--fl-surface-2)" : "transparent"}
opacity={isActive ? 0.6 : 0}
/>
{point.episodes > 0 && (
<rect
x={x}
y={baseline - episodeHeight}
width={barWidth}
height={episodeHeight}
rx={point.movies > 0 ? 0 : 4}
fill={SERIES_COLORS.episodes}
/>
)}
{point.movies > 0 && (
<rect
x={x}
// 2 px Fuge in Untergrundfarbe zwischen den Segmenten
y={baseline - episodeHeight - movieHeight - (point.episodes > 0 ? 2 : 0)}
width={barWidth}
height={movieHeight}
rx={4}
fill={SERIES_COLORS.movies}
/>
)}
<text
x={padding.left + index * band + band / 2}
y={height - 8}
textAnchor="middle"
className={clsx(
"text-[11px]",
isActive ? "fill-[var(--fl-text)]" : "fill-[var(--fl-faint)]",
)}
>
{point.label}
</text>
</g>
);
})}
</svg>
{active !== null && points[active] && (
<Tooltip
title={points[active].label}
rows={[
{ label: "Episoden", value: String(points[active].episodes), color: SERIES_COLORS.episodes },
{ label: "Filme", value: String(points[active].movies), color: SERIES_COLORS.movies },
{ label: "Schauzeit", value: formatMinutes(points[active].minutes) },
]}
position={active / Math.max(1, points.length - 1)}
/>
)}
</div>
<figcaption className="sr-only">
<table>
<caption>Gesehene Episoden und Filme je Monat</caption>
<thead>
<tr>
<th scope="col">Monat</th>
<th scope="col">Episoden</th>
<th scope="col">Filme</th>
<th scope="col">Schauzeit</th>
</tr>
</thead>
<tbody>
{points.map((point, index) => (
<tr key={point.label + index}>
<th scope="row">{point.label}</th>
<td>{point.episodes}</td>
<td>{point.movies}</td>
<td>{formatMinutes(point.minutes)}</td>
</tr>
))}
</tbody>
</table>
</figcaption>
</figure>
);
}
function Tooltip({
title,
rows,
position,
}: {
title: string;
rows: { label: string; value: string; color?: string }[];
position: number;
}) {
return (
<div
role="status"
className="pointer-events-none absolute top-0 z-10 min-w-[9rem] -translate-x-1/2 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-solid)] px-3 py-2 text-xs shadow-[var(--fl-shadow)]"
style={{ left: `${12 + position * 76}%` }}
>
<p className="mb-1 font-semibold">{title}</p>
{rows.map((row) => (
<p key={row.label} className="flex items-center gap-1.5 text-[var(--fl-muted)]">
{row.color && (
<span
aria-hidden
className="h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
)}
<span className="flex-1">{row.label}</span>
<span className="font-medium tabular-nums text-[var(--fl-text)]">{row.value}</span>
</p>
))}
</div>
);
}
function Legend({ items }: { items: { label: string; color: string }[] }) {
return (
<ul className="flex flex-wrap gap-4 text-xs text-[var(--fl-muted)]">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-1.5">
<span
aria-hidden
className="h-2.5 w-2.5 rounded-sm"
style={{ backgroundColor: item.color }}
/>
{item.label}
</li>
))}
</ul>
);
}
// ---------------------------------------------------------------------------
// Liegende Balken für Verteilungen
// ---------------------------------------------------------------------------
export type DistributionRow = {
label: string;
minutes: number;
count: number;
/** Markenfarbe eines Dienstes; wird nur genutzt, wenn sie kontrastreich genug ist. */
color?: string | null;
};
/** Relative Leuchtdichte nach WCAG — für die Kontrastprüfung der Markenfarben. */
function luminance(hex: string): number | null {
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
if (!match?.[1]) return null;
const value = Number.parseInt(match[1], 16);
const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((raw) => {
const c = raw / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
}
function contrast(hex: string, surfaceLuminance: number): number | null {
const value = luminance(hex);
if (value === null) return null;
const lighter = Math.max(value, surfaceLuminance);
const darker = Math.min(value, surfaceLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
/** Leuchtdichte der beiden Untergründe (--fl-bg in dunkel und hell). */
const SURFACE_DARK = 0.0055;
const SURFACE_LIGHT = 0.9;
/**
* Eine Markenfarbe wird nur benutzt, wenn sie auf **beiden** Untergründen
* mindestens 3:1 erreicht. So bleibt die Wahl unabhängig vom eingestellten
* Farbschema — sonst müsste die Komponente den Theme-Wechsel mitverfolgen und
* liefe Gefahr, beim ersten Rendern etwas anderes zu zeigen als der Server.
*/
function safeBrandColor(hex: string | null | undefined): string | null {
if (!hex) return null;
const dark = contrast(hex, SURFACE_DARK);
const light = contrast(hex, SURFACE_LIGHT);
if (dark === null || light === null) return null;
return dark >= 3 && light >= 3 ? hex : null;
}
export function DistributionBars({
rows,
emptyText,
useBrandColors = false,
}: {
rows: DistributionRow[];
emptyText: string;
/**
* Streamingdienste tragen ihre Markenfarbe — das ist ihre Identität und
* zieht sich durchs ganze UI. Zu dunkle oder zu blasse Marken fallen
* automatisch auf den geprüften Standardton zurück, sonst verschwände der
* Balken je nach Farbschema.
*/
useBrandColors?: boolean;
}) {
if (rows.length === 0) {
return <p className="py-6 text-center text-sm text-[var(--fl-faint)]">{emptyText}</p>;
}
const max = Math.max(...rows.map((row) => row.minutes), 1);
return (
<ul className="space-y-2.5">
{rows.map((row) => {
const brand = useBrandColors ? safeBrandColor(row.color) : null;
const color = brand ?? SINGLE_HUE;
return (
<li key={row.label}>
<div className="mb-1 flex items-baseline justify-between gap-3 text-sm">
<span className="min-w-0 truncate text-[var(--fl-text)]">{row.label}</span>
<span className="shrink-0 tabular-nums text-[var(--fl-muted)]">
{formatMinutes(row.minutes)}
</span>
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-[var(--fl-surface-2)]">
<div
className="h-full rounded-full transition-[width] duration-500"
style={{
width: `${Math.max(2, (row.minutes / max) * 100)}%`,
backgroundColor: color,
}}
/>
</div>
</li>
);
})}
</ul>
);
}
+272
View File
@@ -0,0 +1,272 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { clsx } from "clsx";
import { reorderAction, removeFromUniverseAction } from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import type { UniverseEntry } from "@/lib/universes";
/**
* Nummerierte Liste eines Universums in der gewählten Reihenfolge.
*
* Umsortieren geht per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop
* wäre mit Tastatur nicht bedienbar und auf dem Handy fummelig; die Knöpfe
* sind der verlässliche Weg, das Ziehen der bequeme.
*
* Die Erscheinungsreihenfolge (`sortable = false`) ergibt sich aus den Daten
* und lässt sich deshalb gar nicht erst anfassen.
*/
export function OrderedList({
universeId,
orderId,
entries,
sortable,
canWrite,
nextUpItemId,
}: {
universeId: string;
orderId: string;
entries: UniverseEntry[];
sortable: boolean;
canWrite: boolean;
nextUpItemId: string | null;
}) {
const router = useRouter();
const { report } = useToasts();
const [items, setItems] = useState(entries);
const [dragging, setDragging] = useState<string | null>(null);
const [, startTransition] = useTransition();
useEffect(() => setItems(entries), [entries]);
const interactive = sortable && canWrite;
function persist(next: UniverseEntry[]) {
setItems(next);
startTransition(async () => {
const result = await reorderAction(
orderId,
next.map((entry) => entry.itemId),
);
if (!result.ok) {
report(result);
setItems(entries);
}
router.refresh();
});
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= items.length) return;
const next = [...items];
const [moved] = next.splice(index, 1);
if (moved) next.splice(target, 0, moved);
persist(next);
}
function dropOn(targetItemId: string) {
if (!dragging || dragging === targetItemId) return;
const from = items.findIndex((entry) => entry.itemId === dragging);
const to = items.findIndex((entry) => entry.itemId === targetItemId);
if (from < 0 || to < 0) return;
const next = [...items];
const [moved] = next.splice(from, 1);
if (moved) next.splice(to, 0, moved);
setDragging(null);
persist(next);
}
if (items.length === 0) {
return (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
Dieses Universum ist noch leer. Nimm Filme und Serien aus deiner Bibliothek auf.
</p>
</div>
);
}
return (
<ol className="space-y-2">
{items.map((entry, index) => (
<li
key={entry.itemId}
draggable={interactive}
onDragStart={() => setDragging(entry.itemId)}
onDragEnd={() => setDragging(null)}
onDragOver={(event) => {
if (interactive) event.preventDefault();
}}
onDrop={() => dropOn(entry.itemId)}
className={clsx(
"fl-card flex items-center gap-3 p-2.5 transition-opacity sm:gap-4 sm:p-3",
dragging === entry.itemId && "opacity-40",
entry.itemId === nextUpItemId && "ring-1",
)}
style={
entry.itemId === nextUpItemId
? {
boxShadow: "var(--fl-glow)",
borderColor: "color-mix(in oklab, var(--fl-magenta) 45%, transparent)",
}
: undefined
}
>
<span
aria-hidden
className={clsx(
"w-7 shrink-0 text-center font-mono text-sm",
entry.completed ? "text-[var(--fl-success)]" : "text-[var(--fl-faint)]",
)}
>
{entry.completed ? "✓" : index + 1}
</span>
<Link href={`/titel/${entry.titleId}`} className="w-12 shrink-0 sm:w-14">
<Poster path={entry.posterPath} alt={entry.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/titel/${entry.titleId}`}
className="font-medium hover:text-[var(--fl-accent)]"
>
{entry.title}
</Link>
{entry.itemId === nextUpItemId && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
Als Nächstes dran
</span>
)}
{entry.status && (
<span
className="text-[0.65rem] font-medium"
style={{ color: STATUS_COLOR[entry.status as StatusKey] }}
>
{STATUS_LABEL[entry.status as StatusKey]}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{entry.type === "SERIES" ? "Serie" : "Film"}
{entry.year ? ` · ${entry.year}` : ""}
{entry.type === "SERIES"
? ` · ${entry.watchedEpisodes}/${entry.totalEpisodes} Episoden`
: ""}
{entry.remainingMinutes > 0 ? ` · noch ${formatMinutes(entry.remainingMinutes)}` : ""}
</p>
{entry.type === "SERIES" && entry.totalEpisodes > 0 && (
<div className="mt-1.5 max-w-xs">
<ProgressBar
percent={(entry.watchedEpisodes / entry.totalEpisodes) * 100}
label={`${entry.watchedEpisodes} von ${entry.totalEpisodes} Episoden gesehen`}
/>
</div>
)}
</div>
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
{interactive && (
<>
<IconButton
label={`${entry.title} nach oben`}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<path d="m6 15 6-6 6 6" />
</IconButton>
<IconButton
label={`${entry.title} nach unten`}
disabled={index === items.length - 1}
onClick={() => move(index, 1)}
>
<path d="m6 9 6 6 6-6" />
</IconButton>
</>
)}
<RemoveButton universeId={universeId} itemId={entry.itemId} title={entry.title} />
</div>
)}
</li>
))}
</ol>
);
}
function IconButton({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="rounded-lg p-1.5 text-[var(--fl-faint)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)] disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{children}
</svg>
</button>
);
}
function RemoveButton({
universeId,
itemId,
title,
}: {
universeId: string;
itemId: string;
title: string;
}) {
const router = useRouter();
const { report } = useToasts();
const [, startTransition] = useTransition();
return (
<IconButton
label={`${title} aus dem Universum entfernen`}
onClick={() =>
startTransition(async () => {
report(await removeFromUniverseAction(universeId, itemId));
router.refresh();
})
}
>
<path d="m6 6 12 12M18 6 6 18" />
</IconButton>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import {
addCollectionAction,
addTitleToUniverseAction,
createOrderAction,
createUniverseAction,
deleteOrderAction,
shareUniverseAction,
trashUniverseAction,
unshareUniverseAction,
} from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import type { UniverseView } from "@/lib/universes";
/** Formular für ein neues Universum. */
export function NewUniverseForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
return (
<form
action={(formData) => {
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
startTransition(async () => {
const result = await createUniverseAction(
name,
String(formData.get("description") ?? ""),
);
report(result);
if (result.ok && result.id) router.push(`/universen/${result.id}`);
});
}}
className="space-y-4"
>
<div className="space-y-1.5">
<label htmlFor="name" className="block text-sm font-medium text-[var(--fl-muted)]">
Name
</label>
<input
id="name"
name="name"
required
autoFocus
placeholder="MCU, Star Wars, Wizarding World …"
className="fl-input"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="description" className="block text-sm font-medium text-[var(--fl-muted)]">
Beschreibung <span className="text-xs text-[var(--fl-faint)]">(optional)</span>
</label>
<textarea id="description" name="description" rows={3} className="fl-input resize-y" />
</div>
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Universum anlegen"}
</button>
</form>
);
}
/** Umschalter zwischen den Reihenfolgen eines Universums. */
export function OrderTabs({
universe,
canWrite,
}: {
universe: UniverseView;
canWrite: boolean;
}) {
const router = useRouter();
const params = useSearchParams();
const { report } = useToasts();
const [adding, setAdding] = useState(false);
const [, startTransition] = useTransition();
function select(orderId: string) {
const next = new URLSearchParams(params.toString());
next.set("reihenfolge", orderId);
router.replace(`/universen/${universe.id}?${next}`, { scroll: false });
}
const active = universe.orders.find((order) => order.id === universe.activeOrderId);
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Reihenfolge">
{universe.orders.map((order) => {
const isActive = order.id === universe.activeOrderId;
return (
<button
key={order.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => select(order.id)}
className={clsx(
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
isActive
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{order.name}
</button>
);
})}
</div>
{canWrite && (
<>
{adding ? (
<form
action={(formData) => {
const name = String(formData.get("orderName") ?? "").trim();
if (!name) return;
setAdding(false);
startTransition(async () => {
const result = await createOrderAction(universe.id, name);
report(result);
if (result.ok && result.id) select(result.id);
router.refresh();
});
}}
className="flex items-center gap-1.5"
>
<input
name="orderName"
autoFocus
placeholder="z. B. Chronologisch"
className="fl-input w-48 py-1.5 text-sm"
/>
<button type="submit" className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Anlegen
</button>
<button
type="button"
onClick={() => setAdding(false)}
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</form>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="rounded-full border border-dashed border-[var(--fl-border-strong)] px-3.5 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:text-[var(--fl-text)]"
>
+ Reihenfolge
</button>
)}
{active && active.kind === "CUSTOM" && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await deleteOrderAction(active.id));
router.replace(`/universen/${universe.id}`);
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
„{active.name}" löschen
</button>
)}
</>
)}
</div>
);
}
/** Titel aus der eigenen Bibliothek aufnehmen — mit Tippfilter. */
export function AddTitles({
universeId,
candidates,
collections,
}: {
universeId: string;
candidates: { id: string; title: string; type: string; year: number | null }[];
collections: { id: number; name: string }[];
}) {
const router = useRouter();
const { report } = useToasts();
const [query, setQuery] = useState("");
const [pending, startTransition] = useTransition();
const filtered = query.trim()
? candidates
.filter((item) => item.title.toLocaleLowerCase("de-DE").includes(query.toLocaleLowerCase("de-DE")))
.slice(0, 8)
: candidates.slice(0, 8);
return (
<div className="space-y-4">
{collections.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus einer TMDB-Reihe übernehmen
</h3>
<div className="flex flex-wrap gap-1.5">
{collections.map((collection) => (
<button
key={collection.id}
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addCollectionAction(universeId, collection.id));
router.refresh();
})
}
className="rounded-full border border-[var(--fl-border)] px-3 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]"
>
{collection.name}
</button>
))}
</div>
</div>
)}
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus der Bibliothek aufnehmen
</h3>
{candidates.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">
Alles aus deiner Bibliothek ist schon in diesem Universum.
</p>
) : (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Titel filtern …"
aria-label="Titel filtern"
className="fl-input mb-2"
/>
<ul className="space-y-1">
{filtered.map((item) => (
<li key={item.id}>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addTitleToUniverseAction(universeId, item.id));
router.refresh();
})
}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--fl-surface-2)]"
>
<span className="min-w-0 truncate">{item.title}</span>
<span className="ml-3 shrink-0 text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
</span>
</button>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-[var(--fl-faint)]">Kein Treffer.</li>
)}
</ul>
</>
)}
</div>
</div>
);
}
/** Freigabe an andere Konten der Instanz. */
export function SharePanel({ universe }: { universe: UniverseView }) {
const router = useRouter();
const { report } = useToasts();
const [permission, setPermission] = useState<"READ" | "WRITE">("READ");
const [pending, startTransition] = useTransition();
return (
<div className="space-y-3">
<form
action={(formData) => {
const email = String(formData.get("email") ?? "").trim();
if (!email) return;
startTransition(async () => {
report(await shareUniverseAction(universe.id, email, permission));
router.refresh();
});
}}
className="space-y-2"
>
<label htmlFor="share-email" className="block text-sm font-medium text-[var(--fl-muted)]">
Mit jemandem auf dieser Instanz teilen
</label>
<input
id="share-email"
name="email"
type="email"
placeholder="person@beispiel.de"
className="fl-input"
/>
<div className="flex flex-wrap items-center gap-2">
<select
value={permission}
onChange={(event) => setPermission(event.target.value as "READ" | "WRITE")}
aria-label="Recht"
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm"
>
<option value="READ">Nur ansehen</option>
<option value="WRITE">Gemeinsam pflegen</option>
</select>
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Teilen
</button>
</div>
</form>
{universe.members.length > 0 && (
<ul className="space-y-1 border-t border-[var(--fl-border)] pt-3">
{universe.members.map((member) => (
<li key={member.id} className="flex items-center gap-2 text-sm">
<span className="min-w-0 flex-1 truncate">
{member.name}
<span className="ml-1.5 text-xs text-[var(--fl-faint)]">
{member.permission === "WRITE" ? "pflegt mit" : "liest mit"}
</span>
</span>
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await unshareUniverseAction(universe.id, member.id));
router.refresh();
})
}
className="text-xs text-[var(--fl-faint)] underline-offset-2 hover:text-[var(--fl-danger)] hover:underline"
>
entfernen
</button>
</li>
))}
</ul>
)}
</div>
);
}
/** Universum löschen — mit Rückfrage. */
export function DeleteUniverse({ universeId, name }: { universeId: string; name: string }) {
const router = useRouter();
const { report } = useToasts();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
if (!confirming) {
return (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Universum löschen
</button>
);
}
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-[var(--fl-muted)]">„{name}" wirklich löschen?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await trashUniverseAction(universeId));
router.push("/universen");
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
>
Ja, löschen
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Wird von Next beim Start des Servers einmal ausgeführt.
*
* Hier hängt der eingebaute Zeitplan für den täglichen Abgleich. Er läuft nur
* in der Node-Laufzeit (nicht im Edge-Runtime) und nur, wenn ENABLE_SCHEDULER
* gesetzt ist — bei mehreren App-Instanzen soll ihn nur eine übernehmen.
*/
export async function register() {
if (process.env.NEXT_RUNTIME !== "nodejs") return;
const { startScheduler } = await import("./lib/sync");
startScheduler();
}

Some files were not shown because too many files have changed in this diff Show More