Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ac598ea81 | ||
|
|
075b42584a | ||
|
|
8c872b240a | ||
|
|
0e51df7bad | ||
|
|
b53ee828ba | ||
|
|
ed6011384a | ||
|
|
5cf4fc48a6 | ||
|
|
5ee6946fd5 | ||
|
|
90497ddc3b | ||
|
|
190d7b3796 | ||
|
|
e2565b600f | ||
|
|
a3c74c157b | ||
|
|
6f5bdcc20c | ||
|
|
f9ce2f7021 | ||
|
|
f613136e8a | ||
|
|
4c3c878980 | ||
|
|
bb6ce4ab35 | ||
|
|
9ecaaf03a9 | ||
|
|
75128d1bf2 | ||
|
|
e11cb6abef | ||
|
|
cc0e498fe6 | ||
|
|
4feb84c060 |
+173
-1
@@ -7,6 +7,174 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [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 +214,9 @@ 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.5.0...HEAD
|
||||||
|
[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
|
||||||
|
|||||||
@@ -80,9 +80,28 @@ 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, released) — Gerüst, Branding, Docker, Schema, Auth. 38 min.
|
||||||
|
- ⏳ **Etappe 2** — TMDB-Anbindung
|
||||||
|
|
||||||
<!-- BEGIN:nextjs-agent-rules -->
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
|
||||||
|
|||||||
@@ -21,10 +21,68 @@ 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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 |
|
||||||
|
|
||||||
**Bisher gesamt: 38 min**
|
**Bisher gesamt: 105 min**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+3
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flimmer",
|
"name": "flimmer",
|
||||||
"version": "0.1.0",
|
"version": "0.5.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,8 @@
|
|||||||
"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": "tsx scripts/screenshots.ts",
|
||||||
|
"test": "node --env-file-if-exists=.env --conditions=react-server --test-concurrency=1 --import tsx --test tests/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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>`.
|
||||||
@@ -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();
|
||||||
@@ -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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
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 { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
>
|
>
|
||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
{children}
|
<ToastProvider>{children}</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+190
-44
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import {
|
||||||
|
clearTmdbCache,
|
||||||
|
getCollection,
|
||||||
|
getMovie,
|
||||||
|
getSeason,
|
||||||
|
getSeries,
|
||||||
|
parseDate,
|
||||||
|
type TmdbProvider,
|
||||||
|
type TmdbWatchProviders,
|
||||||
|
} from "./tmdb";
|
||||||
|
import type { ProviderKind, Title, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernahme von TMDB-Daten in den lokalen Katalog.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Staffel 0 sind Specials — sie zählen nicht zum regulären Fortschritt. */
|
||||||
|
export const SPECIALS_SEASON = 0;
|
||||||
|
|
||||||
|
/** Verwirft den TMDB-Antwortspeicher (Tests, Verwaltung „jetzt neu laden"). */
|
||||||
|
export function clearCatalogCaches() {
|
||||||
|
clearTmdbCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Streamingdienste -------------------------------------------------------
|
||||||
|
|
||||||
|
const PROVIDER_KIND_MAP = {
|
||||||
|
flatrate: "FLATRATE",
|
||||||
|
rent: "RENT",
|
||||||
|
buy: "BUY",
|
||||||
|
ads: "ADS",
|
||||||
|
free: "FREE",
|
||||||
|
} as const satisfies Record<string, ProviderKind>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiedererkennbare Akzentfarben der großen Dienste. Alles Unbekannte bekommt
|
||||||
|
* im UI einen neutralen Ton — deshalb ist die Liste nur eine Abkürzung, keine
|
||||||
|
* Voraussetzung.
|
||||||
|
*/
|
||||||
|
const PROVIDER_COLORS: Record<string, string> = {
|
||||||
|
netflix: "#e50914",
|
||||||
|
"amazon prime video": "#00a8e1",
|
||||||
|
"prime video": "#00a8e1",
|
||||||
|
"disney plus": "#0e47a1",
|
||||||
|
"disney+": "#0e47a1",
|
||||||
|
"apple tv+": "#8f8f8f",
|
||||||
|
"apple tv plus": "#8f8f8f",
|
||||||
|
"apple tv": "#8f8f8f",
|
||||||
|
wow: "#00a4e4",
|
||||||
|
"sky go": "#00a4e4",
|
||||||
|
paramount: "#0064ff",
|
||||||
|
"paramount plus": "#0064ff",
|
||||||
|
"rtl+": "#e30613",
|
||||||
|
joyn: "#f5008c",
|
||||||
|
"joyn plus": "#f5008c",
|
||||||
|
crunchyroll: "#f47521",
|
||||||
|
mubi: "#000000",
|
||||||
|
ard: "#0a3b78",
|
||||||
|
"ard mediathek": "#0a3b78",
|
||||||
|
zdf: "#fa7d19",
|
||||||
|
"zdf mediathek": "#fa7d19",
|
||||||
|
arte: "#ff6600",
|
||||||
|
magentatv: "#e20074",
|
||||||
|
"wow presents plus": "#00a4e4",
|
||||||
|
"google play movies": "#34a853",
|
||||||
|
"youtube premium": "#ff0000",
|
||||||
|
youtube: "#ff0000",
|
||||||
|
"maxdome store": "#ff6d00",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function upsertProvider(provider: TmdbProvider) {
|
||||||
|
const color = PROVIDER_COLORS[provider.provider_name.trim().toLowerCase()] ?? null;
|
||||||
|
return prisma.watchProvider.upsert({
|
||||||
|
where: { tmdbId: provider.provider_id },
|
||||||
|
create: {
|
||||||
|
tmdbId: provider.provider_id,
|
||||||
|
name: provider.provider_name,
|
||||||
|
logoPath: provider.logo_path ?? null,
|
||||||
|
color,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
name: provider.provider_name,
|
||||||
|
logoPath: provider.logo_path ?? null,
|
||||||
|
...(color ? { color } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt die Verfügbarkeit eines Titels für eine Region fort.
|
||||||
|
*
|
||||||
|
* Einträge, die es nicht mehr gibt, werden gelöscht; vorhandene bleiben
|
||||||
|
* unangetastet, damit `firstSeenAt` erhalten bleibt — daran hängt die
|
||||||
|
* Benachrichtigung „ist jetzt verfügbar".
|
||||||
|
*/
|
||||||
|
async function syncProviders(titleId: string, data: TmdbWatchProviders | undefined, region: string) {
|
||||||
|
const regional = data?.results?.[region];
|
||||||
|
const seen: { providerId: string; kind: ProviderKind }[] = [];
|
||||||
|
|
||||||
|
if (regional) {
|
||||||
|
for (const [key, kind] of Object.entries(PROVIDER_KIND_MAP)) {
|
||||||
|
const list = regional[key as keyof typeof regional];
|
||||||
|
if (!Array.isArray(list)) continue;
|
||||||
|
for (const provider of list as TmdbProvider[]) {
|
||||||
|
const row = await upsertProvider(provider);
|
||||||
|
seen.push({ providerId: row.id, kind });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.titleProvider.deleteMany({
|
||||||
|
where: {
|
||||||
|
titleId,
|
||||||
|
region,
|
||||||
|
NOT: seen.map(({ providerId, kind }) => ({ providerId, kind })),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...seen.map(({ providerId, kind }) =>
|
||||||
|
prisma.titleProvider.upsert({
|
||||||
|
where: { titleId_providerId_region_kind: { titleId, providerId, region, kind } },
|
||||||
|
create: { titleId, providerId, region, kind },
|
||||||
|
update: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Filme ------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function importMovie(tmdbId: number, region = env.tmdb.region): Promise<Title> {
|
||||||
|
const data = await getMovie(tmdbId);
|
||||||
|
|
||||||
|
const title = await prisma.title.upsert({
|
||||||
|
where: { type_tmdbId: { type: "MOVIE", tmdbId } },
|
||||||
|
create: {
|
||||||
|
type: "MOVIE",
|
||||||
|
tmdbId,
|
||||||
|
imdbId: data.imdb_id ?? null,
|
||||||
|
title: data.title,
|
||||||
|
originalTitle: data.original_title ?? null,
|
||||||
|
overview: data.overview ?? null,
|
||||||
|
tagline: data.tagline ?? null,
|
||||||
|
posterPath: data.poster_path ?? null,
|
||||||
|
backdropPath: data.backdrop_path ?? null,
|
||||||
|
releaseDate: parseDate(data.release_date),
|
||||||
|
runtime: data.runtime ?? null,
|
||||||
|
status: data.status ?? null,
|
||||||
|
genres: data.genres?.map((g) => g.name) ?? [],
|
||||||
|
originalLang: data.original_language ?? null,
|
||||||
|
homepage: data.homepage ?? null,
|
||||||
|
voteAverage: data.vote_average ?? null,
|
||||||
|
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
|
||||||
|
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
imdbId: data.imdb_id ?? null,
|
||||||
|
title: data.title,
|
||||||
|
originalTitle: data.original_title ?? null,
|
||||||
|
overview: data.overview ?? null,
|
||||||
|
tagline: data.tagline ?? null,
|
||||||
|
posterPath: data.poster_path ?? null,
|
||||||
|
backdropPath: data.backdrop_path ?? null,
|
||||||
|
releaseDate: parseDate(data.release_date),
|
||||||
|
runtime: data.runtime ?? null,
|
||||||
|
status: data.status ?? null,
|
||||||
|
genres: data.genres?.map((g) => g.name) ?? [],
|
||||||
|
originalLang: data.original_language ?? null,
|
||||||
|
homepage: data.homepage ?? null,
|
||||||
|
voteAverage: data.vote_average ?? null,
|
||||||
|
tmdbCollectionId: data.belongs_to_collection?.id ?? null,
|
||||||
|
tmdbCollectionName: data.belongs_to_collection?.name ?? null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncProviders(title.id, data["watch/providers"], region);
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Serien -----------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ImportSeriesOptions = {
|
||||||
|
region?: string;
|
||||||
|
/** Nur Kopfdaten holen — spart bei der Suche die Episodenabrufe. */
|
||||||
|
skipEpisodes?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function importSeries(
|
||||||
|
tmdbId: number,
|
||||||
|
{ region = env.tmdb.region, skipEpisodes = false }: ImportSeriesOptions = {},
|
||||||
|
): Promise<Title> {
|
||||||
|
const data = await getSeries(tmdbId);
|
||||||
|
const averageRuntime = data.episode_run_time?.[0] ?? null;
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
imdbId: data.external_ids?.imdb_id ?? null,
|
||||||
|
title: data.name,
|
||||||
|
originalTitle: data.original_name ?? null,
|
||||||
|
overview: data.overview ?? null,
|
||||||
|
tagline: data.tagline ?? null,
|
||||||
|
posterPath: data.poster_path ?? null,
|
||||||
|
backdropPath: data.backdrop_path ?? null,
|
||||||
|
releaseDate: parseDate(data.first_air_date),
|
||||||
|
lastAirDate: parseDate(data.last_air_date),
|
||||||
|
runtime: averageRuntime,
|
||||||
|
status: data.status ?? null,
|
||||||
|
inProduction: data.in_production ?? false,
|
||||||
|
genres: data.genres?.map((g) => g.name) ?? [],
|
||||||
|
originalLang: data.original_language ?? null,
|
||||||
|
homepage: data.homepage ?? null,
|
||||||
|
voteAverage: data.vote_average ?? null,
|
||||||
|
syncedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = await prisma.title.upsert({
|
||||||
|
where: { type_tmdbId: { type: "SERIES", tmdbId } },
|
||||||
|
create: { type: "SERIES", tmdbId, ...common },
|
||||||
|
update: common,
|
||||||
|
});
|
||||||
|
|
||||||
|
await syncProviders(title.id, data["watch/providers"], region);
|
||||||
|
|
||||||
|
if (skipEpisodes) return title;
|
||||||
|
|
||||||
|
await importSeasons(title.id, tmdbId, data.seasons ?? [], averageRuntime);
|
||||||
|
|
||||||
|
// Erneut lesen: importSeasons schreibt Staffel- und Episodenzähler fort,
|
||||||
|
// das oben zurückgegebene Objekt wäre an dieser Stelle veraltet.
|
||||||
|
return prisma.title.findUniqueOrThrow({ where: { id: title.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSeasons(
|
||||||
|
titleId: string,
|
||||||
|
tmdbId: number,
|
||||||
|
seasons: NonNullable<Awaited<ReturnType<typeof getSeries>>["seasons"]>,
|
||||||
|
fallbackRuntime: number | null,
|
||||||
|
) {
|
||||||
|
for (const summary of seasons) {
|
||||||
|
const season = await prisma.season.upsert({
|
||||||
|
where: { titleId_seasonNumber: { titleId, seasonNumber: summary.season_number } },
|
||||||
|
create: {
|
||||||
|
titleId,
|
||||||
|
tmdbId: summary.id,
|
||||||
|
seasonNumber: summary.season_number,
|
||||||
|
name: summary.name,
|
||||||
|
overview: summary.overview ?? null,
|
||||||
|
posterPath: summary.poster_path ?? null,
|
||||||
|
airDate: parseDate(summary.air_date),
|
||||||
|
episodeCount: summary.episode_count ?? 0,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
tmdbId: summary.id,
|
||||||
|
name: summary.name,
|
||||||
|
overview: summary.overview ?? null,
|
||||||
|
posterPath: summary.poster_path ?? null,
|
||||||
|
airDate: parseDate(summary.air_date),
|
||||||
|
episodeCount: summary.episode_count ?? 0,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const details = await getSeason(tmdbId, summary.season_number);
|
||||||
|
for (const episode of details.episodes ?? []) {
|
||||||
|
const payload = {
|
||||||
|
tmdbId: episode.id,
|
||||||
|
seasonId: season.id,
|
||||||
|
name: episode.name,
|
||||||
|
overview: episode.overview ?? null,
|
||||||
|
stillPath: episode.still_path ?? null,
|
||||||
|
airDate: parseDate(episode.air_date),
|
||||||
|
runtime: episode.runtime ?? fallbackRuntime,
|
||||||
|
};
|
||||||
|
await prisma.episode.upsert({
|
||||||
|
where: {
|
||||||
|
titleId_seasonNumber_episodeNumber: {
|
||||||
|
titleId,
|
||||||
|
seasonNumber: episode.season_number,
|
||||||
|
episodeNumber: episode.episode_number,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
titleId,
|
||||||
|
seasonNumber: episode.season_number,
|
||||||
|
episodeNumber: episode.episode_number,
|
||||||
|
...payload,
|
||||||
|
},
|
||||||
|
update: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshSeriesCounts(titleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt Staffeln und Episoden neu — Specials (Staffel 0) bleiben außen vor,
|
||||||
|
* damit der Fortschritt nicht an Weihnachtsfolgen hängen bleibt.
|
||||||
|
*/
|
||||||
|
export async function refreshSeriesCounts(titleId: string) {
|
||||||
|
const [seasonCount, episodeCount] = await Promise.all([
|
||||||
|
prisma.season.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
|
||||||
|
prisma.episode.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
|
||||||
|
]);
|
||||||
|
await prisma.title.update({
|
||||||
|
where: { id: titleId },
|
||||||
|
data: { seasonCount, episodeCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gemeinsame Einstiege ---------------------------------------------------
|
||||||
|
|
||||||
|
export function importTitle(type: TitleType, tmdbId: number, region = env.tmdb.region) {
|
||||||
|
return type === "SERIES" ? importSeries(tmdbId, { region }) : importMovie(tmdbId, region);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den Titel aus dem lokalen Katalog und holt ihn bei Bedarf von TMDB.
|
||||||
|
* `maxAgeHours` bestimmt, ab wann ein vorhandener Eintrag aufgefrischt wird.
|
||||||
|
*/
|
||||||
|
export async function ensureTitle(
|
||||||
|
type: TitleType,
|
||||||
|
tmdbId: number,
|
||||||
|
{ maxAgeHours = 24 * 7, region = env.tmdb.region } = {},
|
||||||
|
): Promise<Title> {
|
||||||
|
const existing = await prisma.title.findUnique({ where: { type_tmdbId: { type, tmdbId } } });
|
||||||
|
|
||||||
|
if (existing?.syncedAt) {
|
||||||
|
const ageHours = (Date.now() - existing.syncedAt.getTime()) / 3_600_000;
|
||||||
|
if (ageHours < maxAgeHours) return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await importTitle(type, tmdbId, region);
|
||||||
|
} catch (error) {
|
||||||
|
// Steht der Titel bereits im Katalog, ist eine gescheiterte Auffrischung
|
||||||
|
// kein Grund, die Seite platzen zu lassen.
|
||||||
|
if (existing) return existing;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Übernimmt eine TMDB-Collection als Liste von Filmen (Grundlage für Universen). */
|
||||||
|
export async function importCollection(collectionTmdbId: number, region = env.tmdb.region) {
|
||||||
|
const collection = await getCollection(collectionTmdbId);
|
||||||
|
const titles: Title[] = [];
|
||||||
|
|
||||||
|
for (const part of collection.parts ?? []) {
|
||||||
|
titles.push(await importMovie(part.id, region));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nach Erscheinungsdatum, damit die Release-Reihenfolge direkt stimmt.
|
||||||
|
titles.sort((a, b) => (a.releaseDate?.getTime() ?? 0) - (b.releaseDate?.getTime() ?? 0));
|
||||||
|
|
||||||
|
return { collection, titles };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Bild-URLs und ihre Prüfung.
|
||||||
|
*
|
||||||
|
* Bewusst **ohne** `server-only`: Posterkacheln werden auch aus
|
||||||
|
* Client-Komponenten heraus gerendert (etwa in der sortierbaren Liste eines
|
||||||
|
* Universums). Alles, was tatsächlich den Server braucht — Abruf bei TMDB und
|
||||||
|
* der Dateicache — liegt in `images.ts`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Zugelassene Größen — verhindert, dass über den Parameter beliebige URLs entstehen. */
|
||||||
|
export const IMAGE_SIZES = [
|
||||||
|
"w92",
|
||||||
|
"w154",
|
||||||
|
"w185",
|
||||||
|
"w300",
|
||||||
|
"w342",
|
||||||
|
"w500",
|
||||||
|
"w780",
|
||||||
|
"w1280",
|
||||||
|
"original",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ImageSize = (typeof IMAGE_SIZES)[number];
|
||||||
|
|
||||||
|
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
|
||||||
|
|
||||||
|
export function isValidImagePath(path: string): boolean {
|
||||||
|
return PATH_PATTERN.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidSize(size: string): size is ImageSize {
|
||||||
|
return (IMAGE_SIZES as readonly string[]).includes(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
|
||||||
|
export function posterUrl(
|
||||||
|
path: string | null | undefined,
|
||||||
|
size: ImageSize = "w342",
|
||||||
|
): string | null {
|
||||||
|
if (!path || !isValidImagePath(path)) return null;
|
||||||
|
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { IMAGE_BASE } from "./tmdb";
|
||||||
|
import type { ImageSize } from "./image-url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lokaler Bild-Zwischenspeicher.
|
||||||
|
*
|
||||||
|
* Poster und Hintergrundbilder werden einmal von TMDB geholt und danach vom
|
||||||
|
* eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch
|
||||||
|
* ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf
|
||||||
|
* einen Abruf bei einem Dritten auslöst.
|
||||||
|
*
|
||||||
|
* Die URL-Bildung selbst steht in `image-url.ts` — die brauchen auch
|
||||||
|
* Client-Komponenten, und sie darf deshalb nicht hinter `server-only` liegen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { IMAGE_SIZES, isValidImagePath, isValidSize, posterUrl } from "./image-url";
|
||||||
|
export type { ImageSize } from "./image-url";
|
||||||
|
|
||||||
|
function cacheDir() {
|
||||||
|
return join(env.dataDir, "images");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheFile(size: string, path: string) {
|
||||||
|
// Der Dateiname wird gehasht, damit keine Pfadanteile aus der Anfrage in
|
||||||
|
// den Dateinamen wandern können.
|
||||||
|
const key = createHash("sha256").update(`${size}${path}`).digest("hex");
|
||||||
|
const extension = path.slice(path.lastIndexOf("."));
|
||||||
|
return join(cacheDir(), `${key}${extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CachedImage = {
|
||||||
|
data: Buffer;
|
||||||
|
contentType: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".png": "image/png",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".webp": "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCachedImage(size: ImageSize, path: string): Promise<CachedImage> {
|
||||||
|
const file = cacheFile(size, path);
|
||||||
|
const extension = path.slice(path.lastIndexOf(".")).toLowerCase();
|
||||||
|
const contentType = CONTENT_TYPES[extension] ?? "application/octet-stream";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await stat(file);
|
||||||
|
if (info.size > 0) {
|
||||||
|
return { data: await readFile(file), contentType, fromCache: true };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Noch nicht im Zwischenspeicher — dann eben holen.
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${IMAGE_BASE}/${size}${path}`, { cache: "no-store" });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Bild konnte nicht geladen werden (${response.status}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = Buffer.from(await response.arrayBuffer());
|
||||||
|
|
||||||
|
// Erst in eine temporäre Datei schreiben und dann umbenennen: so sieht ein
|
||||||
|
// paralleler Abruf nie eine halb geschriebene Datei.
|
||||||
|
await mkdir(cacheDir(), { recursive: true });
|
||||||
|
const temporary = `${file}.${process.pid}.tmp`;
|
||||||
|
await writeFile(temporary, data);
|
||||||
|
await rename(temporary, file);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
contentType: response.headers.get("content-type") ?? contentType,
|
||||||
|
fromCache: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* Beschriftungen und Formatierungen, die im Server- wie im Browsercode
|
||||||
|
* gebraucht werden. Bewusst frei von Server-Abhängigkeiten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STATUS_LABEL = {
|
||||||
|
WATCHING: "Schaue gerade",
|
||||||
|
PAUSED: "Pausiert",
|
||||||
|
COMPLETED: "Abgeschlossen",
|
||||||
|
DROPPED: "Abgebrochen",
|
||||||
|
WATCHLIST: "Watchlist",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StatusKey = keyof typeof STATUS_LABEL;
|
||||||
|
|
||||||
|
export const STATUS_ORDER: StatusKey[] = [
|
||||||
|
"WATCHING",
|
||||||
|
"WATCHLIST",
|
||||||
|
"PAUSED",
|
||||||
|
"COMPLETED",
|
||||||
|
"DROPPED",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Farbtoken je Status — als CSS-Variable, damit Dark und Light beide stimmen. */
|
||||||
|
export const STATUS_COLOR: Record<StatusKey, string> = {
|
||||||
|
WATCHING: "var(--fl-accent-alt)",
|
||||||
|
WATCHLIST: "var(--fl-info)",
|
||||||
|
PAUSED: "var(--fl-warning)",
|
||||||
|
COMPLETED: "var(--fl-success)",
|
||||||
|
DROPPED: "var(--fl-faint)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TYPE_LABEL = { SERIES: "Serie", MOVIE: "Film" } as const;
|
||||||
|
|
||||||
|
/** „S03E05" */
|
||||||
|
export function episodeCode(seasonNumber: number, episodeNumber: number): string {
|
||||||
|
return `S${String(seasonNumber).padStart(2, "0")}E${String(episodeNumber).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minuten menschenlesbar: „48 min", „3 h 20 min", „5 Tage 4 h".
|
||||||
|
* Ab einem Tag wird gerundet — auf die Minute genau interessiert dann niemanden.
|
||||||
|
*/
|
||||||
|
export function formatMinutes(minutes: number): string {
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) return "0 min";
|
||||||
|
|
||||||
|
const days = Math.floor(minutes / (60 * 24));
|
||||||
|
const hours = Math.floor((minutes % (60 * 24)) / 60);
|
||||||
|
const rest = Math.round(minutes % 60);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return hours > 0
|
||||||
|
? `${days} ${days === 1 ? "Tag" : "Tage"} ${hours} h`
|
||||||
|
: `${days} ${days === 1 ? "Tag" : "Tage"}`;
|
||||||
|
}
|
||||||
|
if (hours > 0) return rest > 0 ? `${hours} h ${rest} min` : `${hours} h`;
|
||||||
|
return `${rest} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value: Date | string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateLong(value: Date | string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return date.toLocaleDateString("de-DE", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „vor 3 Tagen", „heute", „in 2 Wochen" */
|
||||||
|
export function formatRelative(value: Date | string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
|
||||||
|
const diffDays = Math.round((date.getTime() - Date.now()) / 86_400_000);
|
||||||
|
if (diffDays === 0) return "heute";
|
||||||
|
if (diffDays === 1) return "morgen";
|
||||||
|
if (diffDays === -1) return "gestern";
|
||||||
|
|
||||||
|
const formatter = new Intl.RelativeTimeFormat("de-DE", { numeric: "auto" });
|
||||||
|
if (Math.abs(diffDays) < 30) return formatter.format(diffDays, "day");
|
||||||
|
if (Math.abs(diffDays) < 365) return formatter.format(Math.round(diffDays / 30), "month");
|
||||||
|
return formatter.format(Math.round(diffDays / 365), "year");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ist das Datum in der Zukunft? (Für „noch nicht ausgestrahlt".) */
|
||||||
|
export function isUpcoming(value: Date | string | null | undefined): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
const date = typeof value === "string" ? new Date(value) : value;
|
||||||
|
return !Number.isNaN(date.getTime()) && date.getTime() > Date.now();
|
||||||
|
}
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { SPECIALS_SEASON } from "./catalog";
|
||||||
|
import type { EntryStatus, LibraryEntry, Prisma, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persönliches Tracking: Bibliothek, Status, Sichtungen, Fortschritt.
|
||||||
|
*
|
||||||
|
* Grundgedanke: Eine **Sichtung** (`Watch`) ist die einzige Wahrheit darüber,
|
||||||
|
* was jemand gesehen hat. Fortschritt, Statistik und „nächste Episode" werden
|
||||||
|
* daraus berechnet, nichts davon wird doppelt gespeichert. Rewatches zählen
|
||||||
|
* über `run` — Durchlauf 0 ist die Erstsichtung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class LibraryError extends Error {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Aufnehmen und entfernen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Nimmt einen Titel in die Bibliothek auf oder gibt den vorhandenen Eintrag zurück. */
|
||||||
|
export async function addToLibrary(
|
||||||
|
userId: string,
|
||||||
|
titleId: string,
|
||||||
|
status: EntryStatus = "WATCHLIST",
|
||||||
|
): Promise<LibraryEntry> {
|
||||||
|
const existing = await prisma.libraryEntry.findUnique({
|
||||||
|
where: { userId_titleId: { userId, titleId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ein Titel im Papierkorb wird beim erneuten Aufnehmen wiederbelebt,
|
||||||
|
// statt einen zweiten Eintrag anzulegen — der Fortschritt bleibt erhalten.
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.deletedAt) return existing;
|
||||||
|
return prisma.libraryEntry.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { deletedAt: null, status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestion = await suggestWhereToWatch(userId, titleId);
|
||||||
|
|
||||||
|
return prisma.libraryEntry.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
titleId,
|
||||||
|
status,
|
||||||
|
whereToWatch: suggestion,
|
||||||
|
startedAt: status === "WATCHING" ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vorschlag für „wo schaue ich das": der erste Abo-Dienst in der Region des
|
||||||
|
* Nutzers. Rein als Startwert — überschreiben lässt sich das Feld jederzeit.
|
||||||
|
*/
|
||||||
|
async function suggestWhereToWatch(userId: string, titleId: string): Promise<string | null> {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { region: true } });
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const provider = await prisma.titleProvider.findFirst({
|
||||||
|
where: { titleId, region: user.region, kind: { in: ["FLATRATE", "FREE", "ADS"] } },
|
||||||
|
include: { provider: { select: { name: true } } },
|
||||||
|
orderBy: { kind: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return provider?.provider.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In den Papierkorb legen — 30 Tage lang wiederherstellbar. */
|
||||||
|
export async function moveToTrash(userId: string, entryId: string) {
|
||||||
|
return prisma.libraryEntry.update({
|
||||||
|
where: { id: entryId, userId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreFromTrash(userId: string, entryId: string) {
|
||||||
|
return prisma.libraryEntry.update({
|
||||||
|
where: { id: entryId, userId },
|
||||||
|
data: { deletedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Endgültig löschen. Die Sichtungen gehen dabei mit verloren. */
|
||||||
|
export async function purgeEntry(userId: string, entryId: string) {
|
||||||
|
await prisma.watch.deleteMany({ where: { userId, entryId } });
|
||||||
|
await prisma.libraryEntry.delete({ where: { id: entryId, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Status und Angaben
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function setStatus(userId: string, entryId: string, status: EntryStatus) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
|
||||||
|
const data: Prisma.LibraryEntryUpdateInput = { status };
|
||||||
|
if (status === "WATCHING" && !entry.startedAt) data.startedAt = new Date();
|
||||||
|
if (status === "COMPLETED") data.finishedAt = new Date();
|
||||||
|
if (status === "WATCHLIST") {
|
||||||
|
data.startedAt = null;
|
||||||
|
data.finishedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.libraryEntry.update({ where: { id: entryId }, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEntryDetails(
|
||||||
|
userId: string,
|
||||||
|
entryId: string,
|
||||||
|
data: { rating?: number | null; notes?: string | null; whereToWatch?: string | null; favorite?: boolean },
|
||||||
|
) {
|
||||||
|
await requireEntry(userId, entryId);
|
||||||
|
|
||||||
|
if (data.rating != null && (data.rating < 1 || data.rating > 10)) {
|
||||||
|
throw new LibraryError("Die Bewertung muss zwischen 1 und 10 liegen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.libraryEntry.update({
|
||||||
|
where: { id: entryId },
|
||||||
|
data: {
|
||||||
|
...(data.rating !== undefined ? { rating: data.rating } : {}),
|
||||||
|
...(data.notes !== undefined ? { notes: data.notes?.trim() || null } : {}),
|
||||||
|
...(data.whereToWatch !== undefined
|
||||||
|
? { whereToWatch: data.whereToWatch?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(data.favorite !== undefined ? { favorite: data.favorite } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireEntry(userId: string, entryId: string) {
|
||||||
|
const entry = await prisma.libraryEntry.findFirst({
|
||||||
|
where: { id: entryId, userId },
|
||||||
|
include: { title: { select: { id: true, type: true, runtime: true, episodeCount: true } } },
|
||||||
|
});
|
||||||
|
if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek.");
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sichtungen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ergebnis einer Sichtungsaktion.
|
||||||
|
*
|
||||||
|
* `watchIds` trägt die neu angelegten Sichtungen — damit lässt sich die
|
||||||
|
* Aktion mit einem Klick zurücknehmen, ohne raten zu müssen, was dazugehörte.
|
||||||
|
*/
|
||||||
|
export type WatchResult = {
|
||||||
|
watchIds: string[];
|
||||||
|
status: EntryStatus;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Markiert einzelne Episoden als gesehen (im aktuellen Durchlauf). */
|
||||||
|
export async function markEpisodesWatched(
|
||||||
|
userId: string,
|
||||||
|
entryId: string,
|
||||||
|
episodeIds: string[],
|
||||||
|
watchedAt = new Date(),
|
||||||
|
): Promise<WatchResult> {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
if (entry.title.type !== "SERIES") {
|
||||||
|
throw new LibraryError("Episoden gibt es nur bei Serien.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: { id: { in: episodeIds }, titleId: entry.titleId },
|
||||||
|
select: { id: true, runtime: true, seasonNumber: true, episodeNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadySeen = new Set(
|
||||||
|
(
|
||||||
|
await prisma.watch.findMany({
|
||||||
|
where: { userId, episodeId: { in: episodes.map((e) => e.id) }, run: entry.currentRun },
|
||||||
|
select: { episodeId: true },
|
||||||
|
})
|
||||||
|
).map((watch) => watch.episodeId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreate = episodes.filter((episode) => !alreadySeen.has(episode.id));
|
||||||
|
|
||||||
|
const created = await prisma.$transaction(
|
||||||
|
toCreate.map((episode) =>
|
||||||
|
prisma.watch.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
entryId,
|
||||||
|
titleId: entry.titleId,
|
||||||
|
episodeId: episode.id,
|
||||||
|
run: entry.currentRun,
|
||||||
|
watchedAt,
|
||||||
|
// Laufzeit wird eingefroren: spätere TMDB-Korrekturen sollen die
|
||||||
|
// bereits gezählte Schauzeit nicht rückwirkend verbiegen.
|
||||||
|
minutes: episode.runtime ?? entry.title.runtime ?? 0,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = await reconcileStatus(userId, entryId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
watchIds: created.map((watch) => watch.id),
|
||||||
|
status,
|
||||||
|
label:
|
||||||
|
toCreate.length === 1
|
||||||
|
? "Episode als gesehen markiert"
|
||||||
|
: `${toCreate.length} Episoden als gesehen markiert`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt die Markierung einzelner Episoden im aktuellen Durchlauf zurück. */
|
||||||
|
export async function unmarkEpisodes(userId: string, entryId: string, episodeIds: string[]) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
await prisma.watch.deleteMany({
|
||||||
|
where: { userId, entryId, episodeId: { in: episodeIds }, run: entry.currentRun },
|
||||||
|
});
|
||||||
|
return reconcileStatus(userId, entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ganze Staffel abhaken. */
|
||||||
|
export async function markSeasonWatched(userId: string, entryId: string, seasonId: string) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: { seasonId, titleId: entry.titleId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return markEpisodesWatched(
|
||||||
|
userId,
|
||||||
|
entryId,
|
||||||
|
episodes.map((episode) => episode.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unmarkSeason(userId: string, entryId: string, seasonId: string) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: { seasonId, titleId: entry.titleId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return unmarkEpisodes(
|
||||||
|
userId,
|
||||||
|
entryId,
|
||||||
|
episodes.map((episode) => episode.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Bis hierhin gesehen": markiert die Episode und alles davor.
|
||||||
|
* Praktisch, wenn jemand mitten in einer Staffel einsteigt.
|
||||||
|
*/
|
||||||
|
export async function markUpTo(userId: string, entryId: string, episodeId: string) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
const target = await prisma.episode.findFirst({
|
||||||
|
where: { id: episodeId, titleId: entry.titleId },
|
||||||
|
select: { seasonNumber: true, episodeNumber: true },
|
||||||
|
});
|
||||||
|
if (!target) throw new LibraryError("Diese Episode gehört nicht zu dem Titel.");
|
||||||
|
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: {
|
||||||
|
titleId: entry.titleId,
|
||||||
|
seasonNumber: { gt: SPECIALS_SEASON },
|
||||||
|
OR: [
|
||||||
|
{ seasonNumber: { lt: target.seasonNumber } },
|
||||||
|
{ seasonNumber: target.seasonNumber, episodeNumber: { lte: target.episodeNumber } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return markEpisodesWatched(
|
||||||
|
userId,
|
||||||
|
entryId,
|
||||||
|
episodes.map((episode) => episode.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „+1 Episode" — hakt genau die nächste offene Episode ab. */
|
||||||
|
export async function watchNextEpisode(userId: string, entryId: string): Promise<WatchResult> {
|
||||||
|
const next = await getNextEpisode(userId, entryId);
|
||||||
|
if (!next) throw new LibraryError("Es sind keine offenen Episoden mehr da.");
|
||||||
|
|
||||||
|
const result = await markEpisodesWatched(userId, entryId, [next.id]);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
label: `S${String(next.seasonNumber).padStart(2, "0")}E${String(next.episodeNumber).padStart(2, "0")} abgehakt`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Film als gesehen markieren. */
|
||||||
|
export async function markMovieWatched(
|
||||||
|
userId: string,
|
||||||
|
entryId: string,
|
||||||
|
watchedAt = new Date(),
|
||||||
|
): Promise<WatchResult> {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
if (entry.title.type !== "MOVIE") throw new LibraryError("Das ist keine Filmsichtung.");
|
||||||
|
|
||||||
|
const existing = await prisma.watch.findFirst({
|
||||||
|
where: { userId, entryId, episodeId: null, run: entry.currentRun },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return { watchIds: [], status: entry.status, label: "War schon als gesehen markiert" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const watch = await prisma.watch.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
entryId,
|
||||||
|
titleId: entry.titleId,
|
||||||
|
run: entry.currentRun,
|
||||||
|
watchedAt,
|
||||||
|
minutes: entry.title.runtime ?? 0,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await reconcileStatus(userId, entryId);
|
||||||
|
return { watchIds: [watch.id], status, label: "Film als gesehen markiert" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Macht eine Sichtungsaktion rückgängig. */
|
||||||
|
export async function undoWatches(userId: string, entryId: string, watchIds: string[]) {
|
||||||
|
await requireEntry(userId, entryId);
|
||||||
|
await prisma.watch.deleteMany({ where: { id: { in: watchIds }, userId, entryId } });
|
||||||
|
return reconcileStatus(userId, entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Neuen Durchlauf beginnen.
|
||||||
|
*
|
||||||
|
* Die bisherigen Sichtungen bleiben stehen — sie gehören zum vorherigen
|
||||||
|
* Durchlauf und zählen weiter in die Statistik. Der Fortschritt startet neu.
|
||||||
|
*/
|
||||||
|
export async function startRewatch(userId: string, entryId: string) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
return prisma.libraryEntry.update({
|
||||||
|
where: { id: entryId },
|
||||||
|
data: {
|
||||||
|
currentRun: entry.currentRun + 1,
|
||||||
|
status: "WATCHING",
|
||||||
|
startedAt: new Date(),
|
||||||
|
finishedAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bringt den Status in Einklang mit den Sichtungen:
|
||||||
|
* erste Sichtung startet, die letzte schließt ab.
|
||||||
|
* Manuell gesetzte Zustände (Pausiert, Abgebrochen) bleiben unangetastet.
|
||||||
|
*/
|
||||||
|
async function reconcileStatus(userId: string, entryId: string): Promise<EntryStatus> {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
const { watched, total } = await countProgress(userId, entry.id, entry.titleId, entry.title.type, entry.currentRun);
|
||||||
|
|
||||||
|
if (entry.status === "PAUSED" || entry.status === "DROPPED") return entry.status;
|
||||||
|
|
||||||
|
let status: EntryStatus = entry.status;
|
||||||
|
const data: Prisma.LibraryEntryUpdateInput = {};
|
||||||
|
|
||||||
|
if (total > 0 && watched >= total) {
|
||||||
|
status = "COMPLETED";
|
||||||
|
data.finishedAt = entry.finishedAt ?? new Date();
|
||||||
|
} else if (watched > 0) {
|
||||||
|
status = "WATCHING";
|
||||||
|
data.finishedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (watched > 0 && !entry.startedAt) data.startedAt = new Date();
|
||||||
|
|
||||||
|
if (status !== entry.status || Object.keys(data).length > 0) {
|
||||||
|
await prisma.libraryEntry.update({ where: { id: entryId }, data: { ...data, status } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Fortschritt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function countProgress(
|
||||||
|
userId: string,
|
||||||
|
entryId: string,
|
||||||
|
titleId: string,
|
||||||
|
type: TitleType,
|
||||||
|
run: number,
|
||||||
|
) {
|
||||||
|
if (type === "MOVIE") {
|
||||||
|
const watched = await prisma.watch.count({
|
||||||
|
where: { userId, entryId, episodeId: null, run },
|
||||||
|
});
|
||||||
|
return { watched: Math.min(watched, 1), total: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [watched, total] = await Promise.all([
|
||||||
|
prisma.watch.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
entryId,
|
||||||
|
run,
|
||||||
|
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.episode.count({ where: { titleId, seasonNumber: { gt: SPECIALS_SEASON } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { watched, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Progress = {
|
||||||
|
watched: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
minutesWatched: number;
|
||||||
|
/** Verbleibende Laufzeit des aktuellen Durchlaufs in Minuten. */
|
||||||
|
minutesLeft: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getProgress(userId: string, entryId: string): Promise<Progress> {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
const { watched, total } = await countProgress(
|
||||||
|
userId,
|
||||||
|
entry.id,
|
||||||
|
entry.titleId,
|
||||||
|
entry.title.type,
|
||||||
|
entry.currentRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [minutes, remaining] = await Promise.all([
|
||||||
|
prisma.watch.aggregate({
|
||||||
|
where: { userId, entryId, run: entry.currentRun },
|
||||||
|
_sum: { minutes: true },
|
||||||
|
}),
|
||||||
|
entry.title.type === "SERIES"
|
||||||
|
? prisma.episode.aggregate({
|
||||||
|
where: {
|
||||||
|
titleId: entry.titleId,
|
||||||
|
seasonNumber: { gt: SPECIALS_SEASON },
|
||||||
|
watches: { none: { userId, run: entry.currentRun } },
|
||||||
|
},
|
||||||
|
_sum: { runtime: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve({ _sum: { runtime: null } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const minutesLeft =
|
||||||
|
entry.title.type === "MOVIE"
|
||||||
|
? watched > 0
|
||||||
|
? 0
|
||||||
|
: (entry.title.runtime ?? 0)
|
||||||
|
: (remaining._sum.runtime ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
watched,
|
||||||
|
total,
|
||||||
|
percent: total > 0 ? Math.round((watched / total) * 100) : 0,
|
||||||
|
minutesWatched: minutes._sum.minutes ?? 0,
|
||||||
|
minutesLeft,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die nächste offene Episode des aktuellen Durchlaufs. */
|
||||||
|
export async function getNextEpisode(userId: string, entryId: string) {
|
||||||
|
const entry = await requireEntry(userId, entryId);
|
||||||
|
if (entry.title.type !== "SERIES") return null;
|
||||||
|
|
||||||
|
return prisma.episode.findFirst({
|
||||||
|
where: {
|
||||||
|
titleId: entry.titleId,
|
||||||
|
seasonNumber: { gt: SPECIALS_SEASON },
|
||||||
|
watches: { none: { userId, run: entry.currentRun } },
|
||||||
|
},
|
||||||
|
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
seasonNumber: true,
|
||||||
|
episodeNumber: true,
|
||||||
|
airDate: true,
|
||||||
|
runtime: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die im aktuellen Durchlauf gesehenen Episoden-IDs — für die Häkchen im UI. */
|
||||||
|
export async function getWatchedEpisodeIds(userId: string, entryId: string): Promise<Set<string>> {
|
||||||
|
const entry = await prisma.libraryEntry.findFirst({
|
||||||
|
where: { id: entryId, userId },
|
||||||
|
select: { currentRun: true },
|
||||||
|
});
|
||||||
|
if (!entry) return new Set();
|
||||||
|
|
||||||
|
const watches = await prisma.watch.findMany({
|
||||||
|
where: { userId, entryId, run: entry.currentRun, episodeId: { not: null } },
|
||||||
|
select: { episodeId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Set(watches.map((watch) => watch.episodeId).filter((id): id is string => id !== null));
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { SPECIALS_SEASON } from "./catalog";
|
||||||
|
import type { EntryStatus, Prisma, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leseabfragen für die Bibliothek.
|
||||||
|
*
|
||||||
|
* Der Fortschritt wird für eine ganze Liste in **einer** Abfrage gezählt statt
|
||||||
|
* pro Eintrag — bei 300 Titeln wären das sonst 300 Rundreisen zur Datenbank.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type LibrarySort = "aktualisiert" | "titel" | "bewertung" | "jahr" | "hinzugefuegt";
|
||||||
|
|
||||||
|
export type LibraryFilters = {
|
||||||
|
query?: string;
|
||||||
|
type?: TitleType;
|
||||||
|
status?: EntryStatus;
|
||||||
|
genre?: string;
|
||||||
|
tag?: string;
|
||||||
|
provider?: string;
|
||||||
|
sort?: LibrarySort;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ORDER: Record<LibrarySort, Prisma.LibraryEntryOrderByWithRelationInput[]> = {
|
||||||
|
aktualisiert: [{ updatedAt: "desc" }],
|
||||||
|
hinzugefuegt: [{ createdAt: "desc" }],
|
||||||
|
titel: [{ title: { title: "asc" } }],
|
||||||
|
bewertung: [{ rating: { sort: "desc", nulls: "last" } }, { title: { title: "asc" } }],
|
||||||
|
jahr: [{ title: { releaseDate: { sort: "desc", nulls: "last" } } }],
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LibraryItem = {
|
||||||
|
entryId: string;
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
type: TitleType;
|
||||||
|
posterPath: string | null;
|
||||||
|
year: number | null;
|
||||||
|
status: EntryStatus;
|
||||||
|
rating: number | null;
|
||||||
|
favorite: boolean;
|
||||||
|
currentRun: number;
|
||||||
|
whereToWatch: string | null;
|
||||||
|
tags: string[];
|
||||||
|
watched: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getLibrary(
|
||||||
|
userId: string,
|
||||||
|
filters: LibraryFilters = {},
|
||||||
|
): Promise<LibraryItem[]> {
|
||||||
|
const where: Prisma.LibraryEntryWhereInput = {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
...(filters.status ? { status: filters.status } : {}),
|
||||||
|
...(filters.tag ? { tags: { some: { tag: { name: filters.tag } } } } : {}),
|
||||||
|
title: {
|
||||||
|
...(filters.type ? { type: filters.type } : {}),
|
||||||
|
...(filters.genre ? { genres: { has: filters.genre } } : {}),
|
||||||
|
...(filters.provider
|
||||||
|
? { providers: { some: { provider: { name: filters.provider } } } }
|
||||||
|
: {}),
|
||||||
|
...(filters.query
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: filters.query, mode: "insensitive" } },
|
||||||
|
{ originalTitle: { contains: filters.query, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = await prisma.libraryEntry.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: ORDER[filters.sort ?? "aktualisiert"],
|
||||||
|
include: {
|
||||||
|
title: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
posterPath: true,
|
||||||
|
releaseDate: true,
|
||||||
|
episodeCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: { include: { tag: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) return [];
|
||||||
|
|
||||||
|
const counts = await countWatchedByEntry(
|
||||||
|
userId,
|
||||||
|
entries.map((entry) => ({ id: entry.id, run: entry.currentRun })),
|
||||||
|
);
|
||||||
|
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const isSeries = entry.title.type === "SERIES";
|
||||||
|
const total = isSeries ? entry.title.episodeCount : 1;
|
||||||
|
const watched = Math.min(counts.get(entry.id) ?? 0, total);
|
||||||
|
|
||||||
|
return {
|
||||||
|
entryId: entry.id,
|
||||||
|
titleId: entry.title.id,
|
||||||
|
title: entry.title.title,
|
||||||
|
type: entry.title.type,
|
||||||
|
posterPath: entry.title.posterPath,
|
||||||
|
year: entry.title.releaseDate?.getFullYear() ?? null,
|
||||||
|
status: entry.status,
|
||||||
|
rating: entry.rating,
|
||||||
|
favorite: entry.favorite,
|
||||||
|
currentRun: entry.currentRun,
|
||||||
|
whereToWatch: entry.whereToWatch,
|
||||||
|
tags: entry.tags.map((link) => link.tag.name),
|
||||||
|
watched,
|
||||||
|
total,
|
||||||
|
percent: total > 0 ? Math.round((watched / total) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zählt für viele Einträge auf einmal, wie viel im jeweils aktuellen
|
||||||
|
* Durchlauf gesehen wurde. Specials bleiben außen vor.
|
||||||
|
*/
|
||||||
|
async function countWatchedByEntry(
|
||||||
|
userId: string,
|
||||||
|
entries: { id: string; run: number }[],
|
||||||
|
): Promise<Map<string, number>> {
|
||||||
|
if (entries.length === 0) return new Map();
|
||||||
|
|
||||||
|
const rows = await prisma.watch.groupBy({
|
||||||
|
by: ["entryId", "run"],
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
entryId: { in: entries.map((entry) => entry.id) },
|
||||||
|
OR: [{ episodeId: null }, { episode: { seasonNumber: { gt: SPECIALS_SEASON } } }],
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const runByEntry = new Map(entries.map((entry) => [entry.id, entry.run]));
|
||||||
|
const result = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.entryId) continue;
|
||||||
|
// Nur der aktuelle Durchlauf zählt für den Fortschrittsbalken.
|
||||||
|
if (runByEntry.get(row.entryId) !== row.run) continue;
|
||||||
|
result.set(row.entryId, row._count._all);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LibraryFacets = {
|
||||||
|
genres: string[];
|
||||||
|
tags: string[];
|
||||||
|
providers: string[];
|
||||||
|
counts: Record<EntryStatus | "ALLE", number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Werte für die Filterleiste — nur das, was in der Bibliothek wirklich vorkommt. */
|
||||||
|
export async function getLibraryFacets(userId: string): Promise<LibraryFacets> {
|
||||||
|
const [genreRows, tagRows, providerRows, statusRows, total] = await Promise.all([
|
||||||
|
prisma.$queryRaw<{ genre: string }[]>`
|
||||||
|
SELECT DISTINCT unnest(t."genres") AS genre
|
||||||
|
FROM "LibraryEntry" e
|
||||||
|
JOIN "Title" t ON t."id" = e."titleId"
|
||||||
|
WHERE e."userId" = ${userId} AND e."deletedAt" IS NULL
|
||||||
|
ORDER BY genre
|
||||||
|
`,
|
||||||
|
prisma.tag.findMany({
|
||||||
|
where: { userId, entries: { some: { entry: { deletedAt: null } } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { name: true },
|
||||||
|
}),
|
||||||
|
prisma.$queryRaw<{ name: string }[]>`
|
||||||
|
SELECT DISTINCT p."name"
|
||||||
|
FROM "LibraryEntry" e
|
||||||
|
JOIN "TitleProvider" tp ON tp."titleId" = e."titleId"
|
||||||
|
JOIN "WatchProvider" p ON p."id" = tp."providerId"
|
||||||
|
WHERE e."userId" = ${userId} AND e."deletedAt" IS NULL
|
||||||
|
ORDER BY p."name"
|
||||||
|
`,
|
||||||
|
prisma.libraryEntry.groupBy({
|
||||||
|
by: ["status"],
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.libraryEntry.count({ where: { userId, deletedAt: null } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const counts = {
|
||||||
|
ALLE: total,
|
||||||
|
WATCHING: 0,
|
||||||
|
PAUSED: 0,
|
||||||
|
COMPLETED: 0,
|
||||||
|
DROPPED: 0,
|
||||||
|
WATCHLIST: 0,
|
||||||
|
} as Record<EntryStatus | "ALLE", number>;
|
||||||
|
|
||||||
|
for (const row of statusRows) counts[row.status] = row._count._all;
|
||||||
|
|
||||||
|
return {
|
||||||
|
genres: genreRows.map((row) => row.genre).filter(Boolean),
|
||||||
|
tags: tagRows.map((row) => row.name),
|
||||||
|
providers: providerRows.map((row) => row.name),
|
||||||
|
counts,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Einträge im Papierkorb, jüngste zuerst. */
|
||||||
|
export async function getTrash(userId: string) {
|
||||||
|
return prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: { not: null } },
|
||||||
|
orderBy: { deletedAt: "desc" },
|
||||||
|
include: {
|
||||||
|
title: { select: { id: true, title: true, type: true, posterPath: true, releaseDate: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { SPECIALS_SEASON } from "./catalog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auswertungen für Übersicht, Kalender und Statistik.
|
||||||
|
*
|
||||||
|
* Alles wird aus den Sichtungen (`Watch`) berechnet — es gibt keine
|
||||||
|
* fortgeschriebenen Zähler, die auseinanderlaufen könnten. Wo es auf Tempo
|
||||||
|
* ankommt, laufen die Abfragen als SQL, weil Prisma für Zeitreihen keine
|
||||||
|
* Gruppierung nach Monat kennt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Übersicht
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type ContinueItem = {
|
||||||
|
entryId: string;
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
posterPath: string | null;
|
||||||
|
watched: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
lastWatchedAt: Date | null;
|
||||||
|
next: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
airDate: Date | null;
|
||||||
|
runtime: number | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* „Weiterschauen": laufende Serien, zuletzt angefasste zuerst.
|
||||||
|
* Serien ohne offene Episode fallen heraus — die sind durch.
|
||||||
|
*/
|
||||||
|
export async function getContinueWatching(userId: string, limit = 12): Promise<ContinueItem[]> {
|
||||||
|
const entries = await prisma.libraryEntry.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
status: { in: ["WATCHING", "PAUSED"] },
|
||||||
|
title: { type: "SERIES" },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: limit * 2,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
titleId: true,
|
||||||
|
currentRun: true,
|
||||||
|
updatedAt: true,
|
||||||
|
title: { select: { title: true, posterPath: true, episodeCount: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) return [];
|
||||||
|
|
||||||
|
const items: ContinueItem[] = [];
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const [watched, next, lastWatch] = await Promise.all([
|
||||||
|
prisma.watch.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
entryId: entry.id,
|
||||||
|
run: entry.currentRun,
|
||||||
|
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.episode.findFirst({
|
||||||
|
where: {
|
||||||
|
titleId: entry.titleId,
|
||||||
|
seasonNumber: { gt: SPECIALS_SEASON },
|
||||||
|
watches: { none: { userId, run: entry.currentRun } },
|
||||||
|
},
|
||||||
|
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
seasonNumber: true,
|
||||||
|
episodeNumber: true,
|
||||||
|
airDate: true,
|
||||||
|
runtime: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.watch.findFirst({
|
||||||
|
where: { userId, entryId: entry.id, run: entry.currentRun },
|
||||||
|
orderBy: { watchedAt: "desc" },
|
||||||
|
select: { watchedAt: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!next) continue;
|
||||||
|
|
||||||
|
const total = entry.title.episodeCount;
|
||||||
|
items.push({
|
||||||
|
entryId: entry.id,
|
||||||
|
titleId: entry.titleId,
|
||||||
|
title: entry.title.title,
|
||||||
|
posterPath: entry.title.posterPath,
|
||||||
|
watched,
|
||||||
|
total,
|
||||||
|
percent: total > 0 ? Math.round((watched / total) * 100) : 0,
|
||||||
|
lastWatchedAt: lastWatch?.watchedAt ?? null,
|
||||||
|
next,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpcomingEpisode = {
|
||||||
|
episodeId: string;
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
posterPath: string | null;
|
||||||
|
name: string;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
airDate: Date;
|
||||||
|
runtime: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kommende Episoden getrackter Serien.
|
||||||
|
*
|
||||||
|
* „Getrackt" heißt: in der Bibliothek und nicht abgebrochen. Abgeschlossene
|
||||||
|
* Serien bleiben bewusst drin — wer bei einer laufenden Serie auf dem Stand
|
||||||
|
* ist, gilt als „abgeschlossen" und will die nächste Folge erst recht im
|
||||||
|
* Kalender sehen. Nur Abgebrochenes fällt heraus.
|
||||||
|
*/
|
||||||
|
export async function getUpcomingEpisodes(
|
||||||
|
userId: string,
|
||||||
|
from: Date,
|
||||||
|
to: Date,
|
||||||
|
): Promise<UpcomingEpisode[]> {
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: {
|
||||||
|
airDate: { gte: from, lte: to },
|
||||||
|
seasonNumber: { gt: SPECIALS_SEASON },
|
||||||
|
title: {
|
||||||
|
entries: {
|
||||||
|
some: { userId, deletedAt: null, status: { not: "DROPPED" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ airDate: "asc" }, { seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
seasonNumber: true,
|
||||||
|
episodeNumber: true,
|
||||||
|
airDate: true,
|
||||||
|
runtime: true,
|
||||||
|
title: { select: { id: true, title: true, posterPath: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return episodes.flatMap((episode) =>
|
||||||
|
episode.airDate
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
episodeId: episode.id,
|
||||||
|
titleId: episode.title.id,
|
||||||
|
title: episode.title.title,
|
||||||
|
posterPath: episode.title.posterPath,
|
||||||
|
name: episode.name,
|
||||||
|
seasonNumber: episode.seasonNumber,
|
||||||
|
episodeNumber: episode.episodeNumber,
|
||||||
|
airDate: episode.airDate,
|
||||||
|
runtime: episode.runtime,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvailableItem = {
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
posterPath: string | null;
|
||||||
|
providers: { name: string; color: string | null }[];
|
||||||
|
since: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watchlist-Titel, die seit Kurzem auf einem Dienst verfügbar sind.
|
||||||
|
* Grundlage ist `firstSeenAt` der Verfügbarkeit — das steht seit dem ersten
|
||||||
|
* Abgleich fest und wandert nicht bei jedem Sync weiter.
|
||||||
|
*/
|
||||||
|
export async function getNewlyAvailable(
|
||||||
|
userId: string,
|
||||||
|
region: string,
|
||||||
|
sinceDays = 30,
|
||||||
|
): Promise<AvailableItem[]> {
|
||||||
|
const since = new Date(Date.now() - sinceDays * 86_400_000);
|
||||||
|
|
||||||
|
const rows = await prisma.titleProvider.findMany({
|
||||||
|
where: {
|
||||||
|
region,
|
||||||
|
kind: { in: ["FLATRATE", "FREE", "ADS"] },
|
||||||
|
firstSeenAt: { gte: since },
|
||||||
|
title: { entries: { some: { userId, deletedAt: null, status: "WATCHLIST" } } },
|
||||||
|
},
|
||||||
|
orderBy: { firstSeenAt: "desc" },
|
||||||
|
select: {
|
||||||
|
firstSeenAt: true,
|
||||||
|
provider: { select: { name: true, color: true } },
|
||||||
|
title: { select: { id: true, title: true, posterPath: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const byTitle = new Map<string, AvailableItem>();
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = byTitle.get(row.title.id);
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.providers.some((provider) => provider.name === row.provider.name)) {
|
||||||
|
existing.providers.push(row.provider);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
byTitle.set(row.title.id, {
|
||||||
|
titleId: row.title.id,
|
||||||
|
title: row.title.title,
|
||||||
|
posterPath: row.title.posterPath,
|
||||||
|
providers: [row.provider],
|
||||||
|
since: row.firstSeenAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...byTitle.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Statistik
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Totals = {
|
||||||
|
minutes: number;
|
||||||
|
episodes: number;
|
||||||
|
movies: number;
|
||||||
|
titlesCompleted: number;
|
||||||
|
seriesCount: number;
|
||||||
|
movieCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getTotals(userId: string): Promise<Totals> {
|
||||||
|
const [minutes, episodes, movies, completed, byType] = await Promise.all([
|
||||||
|
prisma.watch.aggregate({ where: { userId }, _sum: { minutes: true } }),
|
||||||
|
prisma.watch.count({ where: { userId, episodeId: { not: null } } }),
|
||||||
|
prisma.watch.count({ where: { userId, episodeId: null } }),
|
||||||
|
prisma.libraryEntry.count({ where: { userId, deletedAt: null, status: "COMPLETED" } }),
|
||||||
|
prisma.libraryEntry.groupBy({
|
||||||
|
by: ["titleId"],
|
||||||
|
where: { userId, deletedAt: null },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const typeCounts = await prisma.title.groupBy({
|
||||||
|
by: ["type"],
|
||||||
|
where: { id: { in: byType.map((row) => row.titleId) } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
minutes: minutes._sum.minutes ?? 0,
|
||||||
|
episodes,
|
||||||
|
movies,
|
||||||
|
titlesCompleted: completed,
|
||||||
|
seriesCount: typeCounts.find((row) => row.type === "SERIES")?._count._all ?? 0,
|
||||||
|
movieCount: typeCounts.find((row) => row.type === "MOVIE")?._count._all ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MonthlyPoint = {
|
||||||
|
/** Monatsanfang */
|
||||||
|
month: Date;
|
||||||
|
label: string;
|
||||||
|
episodes: number;
|
||||||
|
movies: number;
|
||||||
|
minutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Aktivität je Monat, lückenlos — auch Monate ohne Sichtung erscheinen. */
|
||||||
|
export async function getMonthlyActivity(userId: string, months = 12): Promise<MonthlyPoint[]> {
|
||||||
|
const start = new Date();
|
||||||
|
start.setDate(1);
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
start.setMonth(start.getMonth() - (months - 1));
|
||||||
|
|
||||||
|
// Der Monatsschlüssel kommt als Text aus der Datenbank („2026-04").
|
||||||
|
// Ein Zeitstempel würde hier schiefgehen: date_trunc liefert Mitternacht in
|
||||||
|
// der Sitzungszeitzone, JS legt beim Vergleich die lokale Zone an — zwei
|
||||||
|
// Stunden Versatz genügen, damit kein einziger Schlüssel mehr passt.
|
||||||
|
const rows = await prisma.$queryRaw<
|
||||||
|
{ month: string; episodes: bigint; movies: bigint; minutes: bigint | null }[]
|
||||||
|
>`
|
||||||
|
SELECT
|
||||||
|
to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month,
|
||||||
|
count(*) FILTER (WHERE "episodeId" IS NOT NULL) AS episodes,
|
||||||
|
count(*) FILTER (WHERE "episodeId" IS NULL) AS movies,
|
||||||
|
sum("minutes") AS minutes
|
||||||
|
FROM "Watch"
|
||||||
|
WHERE "userId" = ${userId} AND "watchedAt" >= ${start}
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const byMonth = new Map(rows.map((row) => [row.month, row]));
|
||||||
|
const points: MonthlyPoint[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < months; index += 1) {
|
||||||
|
const month = new Date(start);
|
||||||
|
month.setMonth(start.getMonth() + index);
|
||||||
|
const key = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
const row = byMonth.get(key);
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
month,
|
||||||
|
label: month.toLocaleDateString("de-DE", { month: "short" }),
|
||||||
|
episodes: Number(row?.episodes ?? 0),
|
||||||
|
movies: Number(row?.movies ?? 0),
|
||||||
|
minutes: Number(row?.minutes ?? 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Slice = { label: string; minutes: number; count: number; color: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verteilung der Schauzeit nach Streamingdienst.
|
||||||
|
* Grundlage ist das Feld „wo schaue ich das" — es sagt, wo tatsächlich
|
||||||
|
* geschaut wurde, nicht bloß, wo es verfügbar wäre.
|
||||||
|
*/
|
||||||
|
export async function getProviderDistribution(userId: string): Promise<Slice[]> {
|
||||||
|
const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>`
|
||||||
|
SELECT
|
||||||
|
coalesce(nullif(e."whereToWatch", ''), 'Ohne Angabe') AS label,
|
||||||
|
sum(w."minutes") AS minutes,
|
||||||
|
count(*) AS count
|
||||||
|
FROM "Watch" w
|
||||||
|
JOIN "LibraryEntry" e ON e."id" = w."entryId"
|
||||||
|
WHERE w."userId" = ${userId}
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY minutes DESC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const providers = await prisma.watchProvider.findMany({ select: { name: true, color: true } });
|
||||||
|
const colorByName = new Map(providers.map((provider) => [provider.name, provider.color]));
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
label: row.label,
|
||||||
|
minutes: Number(row.minutes ?? 0),
|
||||||
|
count: Number(row.count),
|
||||||
|
color: colorByName.get(row.label) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verteilung der Schauzeit nach Genre. Mehrfachnennungen zählen mehrfach. */
|
||||||
|
export async function getGenreDistribution(userId: string): Promise<Slice[]> {
|
||||||
|
const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>`
|
||||||
|
SELECT
|
||||||
|
genre AS label,
|
||||||
|
sum(w."minutes") AS minutes,
|
||||||
|
count(*) AS count
|
||||||
|
FROM "Watch" w
|
||||||
|
JOIN "Title" t ON t."id" = w."titleId"
|
||||||
|
CROSS JOIN LATERAL unnest(t."genres") AS genre
|
||||||
|
WHERE w."userId" = ${userId}
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY minutes DESC
|
||||||
|
LIMIT 12
|
||||||
|
`;
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
label: row.label,
|
||||||
|
minutes: Number(row.minutes ?? 0),
|
||||||
|
count: Number(row.count),
|
||||||
|
color: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TopTitle = {
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
posterPath: string | null;
|
||||||
|
rating: number | null;
|
||||||
|
minutes: number;
|
||||||
|
type: "SERIES" | "MOVIE";
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bestbewertete Titel. */
|
||||||
|
export async function getTopRated(userId: string, limit = 6): Promise<TopTitle[]> {
|
||||||
|
const entries = await prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, deletedAt: null, rating: { not: null } },
|
||||||
|
orderBy: [{ rating: "desc" }, { updatedAt: "desc" }],
|
||||||
|
take: limit,
|
||||||
|
select: {
|
||||||
|
rating: true,
|
||||||
|
title: { select: { id: true, title: true, posterPath: true, type: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return entries.map((entry) => ({
|
||||||
|
titleId: entry.title.id,
|
||||||
|
title: entry.title.title,
|
||||||
|
posterPath: entry.title.posterPath,
|
||||||
|
rating: entry.rating,
|
||||||
|
minutes: 0,
|
||||||
|
type: entry.title.type,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Titel mit der meisten investierten Zeit. */
|
||||||
|
export async function getMostWatched(userId: string, limit = 6): Promise<TopTitle[]> {
|
||||||
|
const rows = await prisma.watch.groupBy({
|
||||||
|
by: ["titleId"],
|
||||||
|
where: { userId },
|
||||||
|
_sum: { minutes: true },
|
||||||
|
orderBy: { _sum: { minutes: "desc" } },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
|
const titles = await prisma.title.findMany({
|
||||||
|
where: { id: { in: rows.map((row) => row.titleId) } },
|
||||||
|
select: { id: true, title: true, posterPath: true, type: true },
|
||||||
|
});
|
||||||
|
const byId = new Map(titles.map((title) => [title.id, title]));
|
||||||
|
|
||||||
|
return rows.flatMap((row) => {
|
||||||
|
const title = byId.get(row.titleId);
|
||||||
|
return title
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
titleId: title.id,
|
||||||
|
title: title.title,
|
||||||
|
posterPath: title.posterPath,
|
||||||
|
rating: null,
|
||||||
|
minutes: row._sum.minutes ?? 0,
|
||||||
|
type: title.type,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type YearReview = {
|
||||||
|
year: number;
|
||||||
|
minutes: number;
|
||||||
|
episodes: number;
|
||||||
|
movies: number;
|
||||||
|
titlesStarted: number;
|
||||||
|
titlesCompleted: number;
|
||||||
|
busiestMonth: { label: string; minutes: number } | null;
|
||||||
|
topTitle: TopTitle | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** „Das Jahr in Zahlen". */
|
||||||
|
export async function getYearReview(userId: string, year: number): Promise<YearReview> {
|
||||||
|
const from = new Date(Date.UTC(year, 0, 1));
|
||||||
|
const to = new Date(Date.UTC(year + 1, 0, 1));
|
||||||
|
|
||||||
|
const [aggregate, episodes, movies, started, completed, months, top] = await Promise.all([
|
||||||
|
prisma.watch.aggregate({
|
||||||
|
where: { userId, watchedAt: { gte: from, lt: to } },
|
||||||
|
_sum: { minutes: true },
|
||||||
|
}),
|
||||||
|
prisma.watch.count({
|
||||||
|
where: { userId, watchedAt: { gte: from, lt: to }, episodeId: { not: null } },
|
||||||
|
}),
|
||||||
|
prisma.watch.count({ where: { userId, watchedAt: { gte: from, lt: to }, episodeId: null } }),
|
||||||
|
prisma.libraryEntry.count({ where: { userId, startedAt: { gte: from, lt: to } } }),
|
||||||
|
prisma.libraryEntry.count({ where: { userId, finishedAt: { gte: from, lt: to } } }),
|
||||||
|
prisma.$queryRaw<{ month: string; minutes: bigint }[]>`
|
||||||
|
SELECT to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month, sum("minutes") AS minutes
|
||||||
|
FROM "Watch"
|
||||||
|
WHERE "userId" = ${userId} AND "watchedAt" >= ${from} AND "watchedAt" < ${to}
|
||||||
|
GROUP BY 1
|
||||||
|
ORDER BY minutes DESC
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
prisma.watch.groupBy({
|
||||||
|
by: ["titleId"],
|
||||||
|
where: { userId, watchedAt: { gte: from, lt: to } },
|
||||||
|
_sum: { minutes: true },
|
||||||
|
orderBy: { _sum: { minutes: "desc" } },
|
||||||
|
take: 1,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const busiest = months[0];
|
||||||
|
const topTitleRow = top[0];
|
||||||
|
const topTitle = topTitleRow
|
||||||
|
? await prisma.title.findUnique({
|
||||||
|
where: { id: topTitleRow.titleId },
|
||||||
|
select: { id: true, title: true, posterPath: true, type: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
minutes: aggregate._sum.minutes ?? 0,
|
||||||
|
episodes,
|
||||||
|
movies,
|
||||||
|
titlesStarted: started,
|
||||||
|
titlesCompleted: completed,
|
||||||
|
busiestMonth: busiest
|
||||||
|
? {
|
||||||
|
label: monthLabel(busiest.month),
|
||||||
|
minutes: Number(busiest.minutes ?? 0),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
topTitle:
|
||||||
|
topTitle && topTitleRow
|
||||||
|
? {
|
||||||
|
titleId: topTitle.id,
|
||||||
|
title: topTitle.title,
|
||||||
|
posterPath: topTitle.posterPath,
|
||||||
|
rating: null,
|
||||||
|
minutes: topTitleRow._sum.minutes ?? 0,
|
||||||
|
type: topTitle.type,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** „2026-04" → „April 2026" */
|
||||||
|
function monthLabel(key: string): string {
|
||||||
|
const [year, month] = key.split("-").map(Number);
|
||||||
|
if (!year || !month) return key;
|
||||||
|
return new Date(year, month - 1, 1).toLocaleDateString("de-DE", {
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jahre, in denen überhaupt etwas gesehen wurde — für die Jahresauswahl. */
|
||||||
|
export async function getActiveYears(userId: string): Promise<number[]> {
|
||||||
|
const rows = await prisma.$queryRaw<{ year: number }[]>`
|
||||||
|
SELECT DISTINCT extract(year FROM "watchedAt")::int AS year
|
||||||
|
FROM "Watch"
|
||||||
|
WHERE "userId" = ${userId}
|
||||||
|
ORDER BY year DESC
|
||||||
|
`;
|
||||||
|
return rows.map((row) => row.year);
|
||||||
|
}
|
||||||
+309
@@ -0,0 +1,309 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff auf die TMDB-API.
|
||||||
|
*
|
||||||
|
* Nur die Endpunkte, die Flimmer wirklich braucht — samt der Typen, die uns
|
||||||
|
* interessieren (TMDB liefert weit mehr Felder, als hier stehen). Antworten
|
||||||
|
* werden kurz zwischengespeichert, damit Tippen im Suchfeld nicht jedes
|
||||||
|
* Zeichen einzeln nach draußen trägt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = "https://api.themoviedb.org/3";
|
||||||
|
export const IMAGE_BASE = "https://image.tmdb.org/t/p";
|
||||||
|
|
||||||
|
export class TmdbNotConfiguredError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"Es ist kein TMDB-Schlüssel hinterlegt. Bitte TMDB_API_KEY oder TMDB_ACCESS_TOKEN setzen.",
|
||||||
|
);
|
||||||
|
this.name = "TmdbNotConfiguredError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TmdbError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "TmdbError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- kleiner Antwort-Zwischenspeicher ---------------------------------------
|
||||||
|
|
||||||
|
type CacheEntry = { at: number; value: unknown };
|
||||||
|
const cache = new Map<string, CacheEntry>();
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const CACHE_MAX = 500;
|
||||||
|
|
||||||
|
function readCache<T>(key: string): T | null {
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (Date.now() - hit.at > CACHE_TTL_MS) {
|
||||||
|
cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return hit.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCache(key: string, value: unknown) {
|
||||||
|
if (cache.size >= CACHE_MAX) {
|
||||||
|
// Ältesten Eintrag verwerfen — Map bewahrt die Einfügereihenfolge.
|
||||||
|
const oldest = cache.keys().next().value;
|
||||||
|
if (oldest) cache.delete(oldest);
|
||||||
|
}
|
||||||
|
cache.set(key, { at: Date.now(), value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearTmdbCache() {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Anfragen ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async function request<T>(
|
||||||
|
path: string,
|
||||||
|
params: Record<string, string | number | undefined> = {},
|
||||||
|
): Promise<T> {
|
||||||
|
if (!env.tmdb.isConfigured) throw new TmdbNotConfiguredError();
|
||||||
|
|
||||||
|
const url = new URL(`${BASE}${path}`);
|
||||||
|
url.searchParams.set("language", env.tmdb.language);
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined && value !== "") url.searchParams.set(key, String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = { accept: "application/json" };
|
||||||
|
if (env.tmdb.accessToken) {
|
||||||
|
headers.authorization = `Bearer ${env.tmdb.accessToken}`;
|
||||||
|
} else {
|
||||||
|
url.searchParams.set("api_key", env.tmdb.apiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = url.toString();
|
||||||
|
const cached = readCache<T>(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const response = await fetch(url, { headers, cache: "no-store" });
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = response.statusText;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { status_message?: string };
|
||||||
|
if (body.status_message) detail = body.status_message;
|
||||||
|
} catch {
|
||||||
|
// Antwort war kein JSON — dann bleibt es beim Statustext.
|
||||||
|
}
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new TmdbError("TMDB weist den Schlüssel zurück (401).", 401);
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new TmdbError("Bei TMDB gibt es dazu keinen Eintrag (404).", 404);
|
||||||
|
}
|
||||||
|
throw new TmdbError(`TMDB-Anfrage fehlgeschlagen: ${detail}`, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as T;
|
||||||
|
writeCache(cacheKey, data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Typen (nur die genutzten Felder) ---------------------------------------
|
||||||
|
|
||||||
|
export type TmdbSearchResult = {
|
||||||
|
id: number;
|
||||||
|
media_type?: "tv" | "movie" | "person";
|
||||||
|
name?: string;
|
||||||
|
title?: string;
|
||||||
|
original_name?: string;
|
||||||
|
original_title?: string;
|
||||||
|
overview?: string;
|
||||||
|
poster_path?: string | null;
|
||||||
|
backdrop_path?: string | null;
|
||||||
|
first_air_date?: string;
|
||||||
|
release_date?: string;
|
||||||
|
vote_average?: number;
|
||||||
|
genre_ids?: number[];
|
||||||
|
popularity?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbPaged<T> = {
|
||||||
|
page: number;
|
||||||
|
results: T[];
|
||||||
|
total_pages: number;
|
||||||
|
total_results: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbGenre = { id: number; name: string };
|
||||||
|
|
||||||
|
export type TmdbProvider = {
|
||||||
|
provider_id: number;
|
||||||
|
provider_name: string;
|
||||||
|
logo_path?: string | null;
|
||||||
|
display_priority?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbWatchProviders = {
|
||||||
|
results?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
link?: string;
|
||||||
|
flatrate?: TmdbProvider[];
|
||||||
|
rent?: TmdbProvider[];
|
||||||
|
buy?: TmdbProvider[];
|
||||||
|
ads?: TmdbProvider[];
|
||||||
|
free?: TmdbProvider[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbMovieDetails = {
|
||||||
|
id: number;
|
||||||
|
imdb_id?: string | null;
|
||||||
|
title: string;
|
||||||
|
original_title?: string;
|
||||||
|
overview?: string | null;
|
||||||
|
tagline?: string | null;
|
||||||
|
poster_path?: string | null;
|
||||||
|
backdrop_path?: string | null;
|
||||||
|
release_date?: string;
|
||||||
|
runtime?: number | null;
|
||||||
|
status?: string;
|
||||||
|
genres?: TmdbGenre[];
|
||||||
|
original_language?: string;
|
||||||
|
homepage?: string | null;
|
||||||
|
vote_average?: number;
|
||||||
|
belongs_to_collection?: { id: number; name: string; poster_path?: string | null } | null;
|
||||||
|
"watch/providers"?: TmdbWatchProviders;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbSeasonSummary = {
|
||||||
|
id: number;
|
||||||
|
season_number: number;
|
||||||
|
name: string;
|
||||||
|
overview?: string | null;
|
||||||
|
poster_path?: string | null;
|
||||||
|
air_date?: string | null;
|
||||||
|
episode_count?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbSeriesDetails = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
original_name?: string;
|
||||||
|
overview?: string | null;
|
||||||
|
tagline?: string | null;
|
||||||
|
poster_path?: string | null;
|
||||||
|
backdrop_path?: string | null;
|
||||||
|
first_air_date?: string;
|
||||||
|
last_air_date?: string | null;
|
||||||
|
episode_run_time?: number[];
|
||||||
|
status?: string;
|
||||||
|
in_production?: boolean;
|
||||||
|
genres?: TmdbGenre[];
|
||||||
|
original_language?: string;
|
||||||
|
homepage?: string | null;
|
||||||
|
vote_average?: number;
|
||||||
|
number_of_seasons?: number;
|
||||||
|
number_of_episodes?: number;
|
||||||
|
seasons?: TmdbSeasonSummary[];
|
||||||
|
external_ids?: { imdb_id?: string | null };
|
||||||
|
"watch/providers"?: TmdbWatchProviders;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbEpisode = {
|
||||||
|
id: number;
|
||||||
|
season_number: number;
|
||||||
|
episode_number: number;
|
||||||
|
name: string;
|
||||||
|
overview?: string | null;
|
||||||
|
still_path?: string | null;
|
||||||
|
air_date?: string | null;
|
||||||
|
runtime?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TmdbSeasonDetails = TmdbSeasonSummary & { episodes?: TmdbEpisode[] };
|
||||||
|
|
||||||
|
export type TmdbCollection = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
overview?: string | null;
|
||||||
|
poster_path?: string | null;
|
||||||
|
backdrop_path?: string | null;
|
||||||
|
parts?: (TmdbSearchResult & { media_type?: "movie" })[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Endpunkte --------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Gemeinsame Suche über Serien und Filme (Personen werden herausgefiltert). */
|
||||||
|
export async function searchMulti(query: string, page = 1) {
|
||||||
|
const data = await request<TmdbPaged<TmdbSearchResult>>("/search/multi", {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
include_adult: "false",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
results: data.results.filter((item) => item.media_type === "tv" || item.media_type === "movie"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchSeries(query: string, page = 1) {
|
||||||
|
return request<TmdbPaged<TmdbSearchResult>>("/search/tv", { query, page, include_adult: "false" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchMovies(query: string, page = 1) {
|
||||||
|
return request<TmdbPaged<TmdbSearchResult>>("/search/movie", {
|
||||||
|
query,
|
||||||
|
page,
|
||||||
|
include_adult: "false",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeries(tmdbId: number) {
|
||||||
|
return request<TmdbSeriesDetails>(`/tv/${tmdbId}`, {
|
||||||
|
append_to_response: "watch/providers,external_ids",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSeason(tmdbId: number, seasonNumber: number) {
|
||||||
|
return request<TmdbSeasonDetails>(`/tv/${tmdbId}/season/${seasonNumber}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMovie(tmdbId: number) {
|
||||||
|
return request<TmdbMovieDetails>(`/movie/${tmdbId}`, {
|
||||||
|
append_to_response: "watch/providers,external_ids",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCollection(tmdbId: number) {
|
||||||
|
return request<TmdbCollection>(`/collection/${tmdbId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrending(window: "day" | "week" = "week") {
|
||||||
|
return request<TmdbPaged<TmdbSearchResult>>(`/trending/all/${window}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Alle Anbieter einer Region — für Filterlisten und Akzentfarben. */
|
||||||
|
export function getProviderList(kind: "tv" | "movie", region = env.tmdb.region) {
|
||||||
|
return request<{ results: TmdbProvider[] }>(`/watch/providers/${kind}`, {
|
||||||
|
watch_region: region,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hilfen -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Bildpfad zu einer vollständigen TMDB-URL. */
|
||||||
|
export function imageUrl(path: string, size = "w500"): string {
|
||||||
|
return `${IMAGE_BASE}/${size}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TMDB-Datum ("2019-04-14" oder "") in ein Date oder null. */
|
||||||
|
export function parseDate(value: string | null | undefined): Date | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(`${value}T00:00:00.000Z`);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { SPECIALS_SEASON } from "./catalog";
|
||||||
|
import type { OrderKind, SharePermission, TitleType } from "@/generated/prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universen: eigene Sammlungen aus Filmen **und** Serien.
|
||||||
|
*
|
||||||
|
* Zwei Dinge unterscheiden ein Universum von einer Liste:
|
||||||
|
*
|
||||||
|
* 1. Es hat **mehrere Reihenfolgen**. Die Erscheinungsreihenfolge wird aus den
|
||||||
|
* Daten berechnet und nie gespeichert — sie stimmt damit immer. Alles
|
||||||
|
* andere (Story-Chronologie, Einsteiger-Route) ist frei sortierbar.
|
||||||
|
* 2. Der **Fortschritt** ist der des jeweiligen Betrachters. Ein geteiltes
|
||||||
|
* Universum zeigt jeder Person ihren eigenen Stand.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class UniverseError extends Error {}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zugriff
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type UniverseAccess = { canRead: boolean; canWrite: boolean; isOwner: boolean };
|
||||||
|
|
||||||
|
export async function getAccess(userId: string, universeId: string): Promise<UniverseAccess> {
|
||||||
|
const universe = await prisma.universe.findFirst({
|
||||||
|
where: { id: universeId, deletedAt: null },
|
||||||
|
select: {
|
||||||
|
ownerId: true,
|
||||||
|
members: { where: { userId }, select: { permission: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!universe) return { canRead: false, canWrite: false, isOwner: false };
|
||||||
|
|
||||||
|
const isOwner = universe.ownerId === userId;
|
||||||
|
const membership = universe.members[0];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOwner,
|
||||||
|
canRead: isOwner || Boolean(membership),
|
||||||
|
canWrite: isOwner || membership?.permission === "WRITE",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireWrite(userId: string, universeId: string) {
|
||||||
|
const access = await getAccess(userId, universeId);
|
||||||
|
if (!access.canRead) throw new UniverseError("Dieses Universum gibt es nicht.");
|
||||||
|
if (!access.canWrite) throw new UniverseError("Du darfst dieses Universum nur ansehen.");
|
||||||
|
return access;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Anlegen und pflegen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 60) || "universum";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUniverse(userId: string, name: string, description?: string) {
|
||||||
|
const base = slugify(name);
|
||||||
|
let slug = base;
|
||||||
|
|
||||||
|
// Der Slug ist je Besitzer eindeutig — bei Dubletten durchzählen.
|
||||||
|
for (let suffix = 2; suffix < 100; suffix += 1) {
|
||||||
|
const taken = await prisma.universe.findUnique({
|
||||||
|
where: { ownerId_slug: { ownerId: userId, slug } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!taken) break;
|
||||||
|
slug = `${base}-${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const universe = await prisma.universe.create({
|
||||||
|
data: {
|
||||||
|
ownerId: userId,
|
||||||
|
name: name.trim(),
|
||||||
|
slug,
|
||||||
|
description: description?.trim() || null,
|
||||||
|
orders: {
|
||||||
|
create: [
|
||||||
|
// Die Erscheinungsreihenfolge braucht keine Einträge — sie wird
|
||||||
|
// aus den Erscheinungsdaten berechnet.
|
||||||
|
{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return universe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameUniverse(
|
||||||
|
userId: string,
|
||||||
|
universeId: string,
|
||||||
|
data: { name?: string; description?: string | null },
|
||||||
|
) {
|
||||||
|
await requireWrite(userId, universeId);
|
||||||
|
return prisma.universe.update({
|
||||||
|
where: { id: universeId },
|
||||||
|
data: {
|
||||||
|
...(data.name ? { name: data.name.trim() } : {}),
|
||||||
|
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trashUniverse(userId: string, universeId: string) {
|
||||||
|
const access = await getAccess(userId, universeId);
|
||||||
|
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es löschen.");
|
||||||
|
return prisma.universe.update({
|
||||||
|
where: { id: universeId },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nimmt einen Titel ins Universum auf und hängt ihn an alle Reihenfolgen an. */
|
||||||
|
export async function addTitle(userId: string, universeId: string, titleId: string) {
|
||||||
|
await requireWrite(userId, universeId);
|
||||||
|
|
||||||
|
const existing = await prisma.universeItem.findUnique({
|
||||||
|
where: { universeId_titleId: { universeId, titleId } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const item = await prisma.universeItem.create({
|
||||||
|
data: { universeId, titleId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await appendToCustomOrders(universeId, item.id);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neue Einträge landen ans Ende jeder frei sortierten Reihenfolge. */
|
||||||
|
async function appendToCustomOrders(universeId: string, itemId: string) {
|
||||||
|
const orders = await prisma.universeOrder.findMany({
|
||||||
|
where: { universeId, kind: "CUSTOM" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const last = await prisma.universeOrderItem.findFirst({
|
||||||
|
where: { orderId: order.id },
|
||||||
|
orderBy: { position: "desc" },
|
||||||
|
select: { position: true },
|
||||||
|
});
|
||||||
|
await prisma.universeOrderItem.create({
|
||||||
|
data: { orderId: order.id, itemId, position: (last?.position ?? -1) + 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTitle(userId: string, universeId: string, itemId: string) {
|
||||||
|
await requireWrite(userId, universeId);
|
||||||
|
await prisma.universeItem.delete({ where: { id: itemId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Übernimmt alle Filme einer TMDB-Collection, die bereits im Katalog liegen.
|
||||||
|
* Der eigentliche Abruf bei TMDB passiert vorher in der Katalogschicht.
|
||||||
|
*/
|
||||||
|
export async function addCollection(
|
||||||
|
userId: string,
|
||||||
|
universeId: string,
|
||||||
|
tmdbCollectionId: number,
|
||||||
|
): Promise<number> {
|
||||||
|
await requireWrite(userId, universeId);
|
||||||
|
|
||||||
|
const titles = await prisma.title.findMany({
|
||||||
|
where: { tmdbCollectionId },
|
||||||
|
orderBy: { releaseDate: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
let added = 0;
|
||||||
|
for (const title of titles) {
|
||||||
|
const existing = await prisma.universeItem.findUnique({
|
||||||
|
where: { universeId_titleId: { universeId, titleId: title.id } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing) continue;
|
||||||
|
const item = await prisma.universeItem.create({
|
||||||
|
data: { universeId, titleId: title.id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await appendToCustomOrders(universeId, item.id);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Reihenfolgen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function createOrder(userId: string, universeId: string, name: string) {
|
||||||
|
await requireWrite(userId, universeId);
|
||||||
|
|
||||||
|
const order = await prisma.universeOrder.create({
|
||||||
|
data: { universeId, name: name.trim(), kind: "CUSTOM" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Startbelegung: Erscheinungsreihenfolge — von dort aus umsortiert es sich
|
||||||
|
// leichter als aus einer zufälligen Anordnung.
|
||||||
|
const items = await prisma.universeItem.findMany({
|
||||||
|
where: { universeId },
|
||||||
|
orderBy: [{ title: { releaseDate: "asc" } }, { title: { title: "asc" } }],
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.universeOrderItem.createMany({
|
||||||
|
data: items.map((item, index) => ({ orderId: order.id, itemId: item.id, position: index })),
|
||||||
|
});
|
||||||
|
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameOrder(userId: string, orderId: string, name: string) {
|
||||||
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { universeId: true, kind: true },
|
||||||
|
});
|
||||||
|
await requireWrite(userId, order.universeId);
|
||||||
|
if (order.kind === "RELEASE") {
|
||||||
|
throw new UniverseError("Die Erscheinungsreihenfolge lässt sich nicht umbenennen.");
|
||||||
|
}
|
||||||
|
return prisma.universeOrder.update({ where: { id: orderId }, data: { name: name.trim() } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteOrder(userId: string, orderId: string) {
|
||||||
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { universeId: true, kind: true },
|
||||||
|
});
|
||||||
|
await requireWrite(userId, order.universeId);
|
||||||
|
if (order.kind === "RELEASE") {
|
||||||
|
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
||||||
|
}
|
||||||
|
await prisma.universeOrder.delete({ where: { id: orderId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
||||||
|
export async function setOrderPositions(userId: string, orderId: string, itemIds: string[]) {
|
||||||
|
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||||
|
where: { id: orderId },
|
||||||
|
select: { universeId: true, kind: true },
|
||||||
|
});
|
||||||
|
await requireWrite(userId, order.universeId);
|
||||||
|
if (order.kind === "RELEASE") {
|
||||||
|
throw new UniverseError("Die Erscheinungsreihenfolge ergibt sich aus den Daten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erst alle auf negative Positionen schieben: die Eindeutigkeit von
|
||||||
|
// (orderId, itemId) bleibt, aber wir vermeiden Kollisionen beim Umnummerieren.
|
||||||
|
await prisma.$transaction(
|
||||||
|
itemIds.map((itemId, index) =>
|
||||||
|
prisma.universeOrderItem.updateMany({
|
||||||
|
where: { orderId, itemId },
|
||||||
|
data: { position: index },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Teilen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function shareUniverse(
|
||||||
|
userId: string,
|
||||||
|
universeId: string,
|
||||||
|
targetEmail: string,
|
||||||
|
permission: SharePermission,
|
||||||
|
) {
|
||||||
|
const access = await getAccess(userId, universeId);
|
||||||
|
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es teilen.");
|
||||||
|
|
||||||
|
const target = await prisma.user.findUnique({
|
||||||
|
where: { email: targetEmail.trim().toLowerCase() },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (!target) throw new UniverseError("Zu dieser E-Mail-Adresse gibt es auf dieser Instanz kein Konto.");
|
||||||
|
if (target.id === userId) throw new UniverseError("Mit dir selbst musst du nicht teilen.");
|
||||||
|
|
||||||
|
await prisma.universeMember.upsert({
|
||||||
|
where: { universeId_userId: { universeId, userId: target.id } },
|
||||||
|
create: { universeId, userId: target.id, permission },
|
||||||
|
update: { permission },
|
||||||
|
});
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unshareUniverse(userId: string, universeId: string, memberId: string) {
|
||||||
|
const access = await getAccess(userId, universeId);
|
||||||
|
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf das ändern.");
|
||||||
|
await prisma.universeMember.delete({ where: { id: memberId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lesen und Fortschritt
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type UniverseEntry = {
|
||||||
|
itemId: string;
|
||||||
|
titleId: string;
|
||||||
|
title: string;
|
||||||
|
type: TitleType;
|
||||||
|
posterPath: string | null;
|
||||||
|
year: number | null;
|
||||||
|
note: string | null;
|
||||||
|
/** Gesamtlaufzeit des Titels in Minuten. */
|
||||||
|
totalMinutes: number;
|
||||||
|
/** Noch offene Laufzeit für diesen Betrachter. */
|
||||||
|
remainingMinutes: number;
|
||||||
|
watchedMinutes: number;
|
||||||
|
inLibrary: boolean;
|
||||||
|
completed: boolean;
|
||||||
|
status: string | null;
|
||||||
|
watchedEpisodes: number;
|
||||||
|
totalEpisodes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UniverseView = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isOwner: boolean;
|
||||||
|
canWrite: boolean;
|
||||||
|
ownerName: string;
|
||||||
|
orders: { id: string; name: string; kind: OrderKind; isDefault: boolean }[];
|
||||||
|
activeOrderId: string;
|
||||||
|
entries: UniverseEntry[];
|
||||||
|
progress: {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
percent: number;
|
||||||
|
watchedMinutes: number;
|
||||||
|
remainingMinutes: number;
|
||||||
|
totalMinutes: number;
|
||||||
|
};
|
||||||
|
/** Der erste noch nicht abgeschlossene Eintrag in der gewählten Reihenfolge. */
|
||||||
|
nextUp: UniverseEntry | null;
|
||||||
|
members: { id: string; name: string; email: string; permission: SharePermission }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getUniverse(
|
||||||
|
userId: string,
|
||||||
|
universeId: string,
|
||||||
|
orderId?: string,
|
||||||
|
): Promise<UniverseView | null> {
|
||||||
|
const access = await getAccess(userId, universeId);
|
||||||
|
if (!access.canRead) return null;
|
||||||
|
|
||||||
|
const universe = await prisma.universe.findUnique({
|
||||||
|
where: { id: universeId },
|
||||||
|
include: {
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
orders: { orderBy: [{ kind: "asc" }, { createdAt: "asc" }] },
|
||||||
|
members: {
|
||||||
|
include: { user: { select: { name: true, email: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
title: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
type: true,
|
||||||
|
posterPath: true,
|
||||||
|
releaseDate: true,
|
||||||
|
runtime: true,
|
||||||
|
episodeCount: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
positions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!universe) return null;
|
||||||
|
|
||||||
|
const activeOrder =
|
||||||
|
universe.orders.find((order) => order.id === orderId) ??
|
||||||
|
universe.orders.find((order) => order.isDefault) ??
|
||||||
|
universe.orders[0];
|
||||||
|
if (!activeOrder) return null;
|
||||||
|
|
||||||
|
const titleIds = universe.items.map((item) => item.titleId);
|
||||||
|
|
||||||
|
const [entries, seriesRuntimes, watchedByTitle, watchedEpisodes] = await Promise.all([
|
||||||
|
prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, titleId: { in: titleIds }, deletedAt: null },
|
||||||
|
select: { titleId: true, status: true, currentRun: true },
|
||||||
|
}),
|
||||||
|
// Gesamtlaufzeit je Serie aus den Episoden (ohne Specials).
|
||||||
|
prisma.episode.groupBy({
|
||||||
|
by: ["titleId"],
|
||||||
|
where: { titleId: { in: titleIds }, seasonNumber: { gt: SPECIALS_SEASON } },
|
||||||
|
_sum: { runtime: true },
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
prisma.watch.groupBy({
|
||||||
|
by: ["titleId"],
|
||||||
|
where: { userId, titleId: { in: titleIds } },
|
||||||
|
_sum: { minutes: true },
|
||||||
|
}),
|
||||||
|
// Gesehene reguläre Episoden im jeweils aktuellen Durchlauf.
|
||||||
|
prisma.watch.groupBy({
|
||||||
|
by: ["titleId", "run"],
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
titleId: { in: titleIds },
|
||||||
|
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
|
||||||
|
},
|
||||||
|
_count: { _all: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entryByTitle = new Map(entries.map((entry) => [entry.titleId, entry]));
|
||||||
|
const runtimeByTitle = new Map(
|
||||||
|
seriesRuntimes.map((row) => [row.titleId, { minutes: row._sum.runtime ?? 0, episodes: row._count._all }]),
|
||||||
|
);
|
||||||
|
const minutesByTitle = new Map(watchedByTitle.map((row) => [row.titleId, row._sum.minutes ?? 0]));
|
||||||
|
|
||||||
|
const seenByTitle = new Map<string, number>();
|
||||||
|
for (const row of watchedEpisodes) {
|
||||||
|
const entry = entryByTitle.get(row.titleId);
|
||||||
|
if (!entry || entry.currentRun !== row.run) continue;
|
||||||
|
seenByTitle.set(row.titleId, row._count._all);
|
||||||
|
}
|
||||||
|
|
||||||
|
const built: UniverseEntry[] = universe.items.map((item) => {
|
||||||
|
const title = item.title;
|
||||||
|
const isSeries = title.type === "SERIES";
|
||||||
|
const runtimeInfo = runtimeByTitle.get(title.id);
|
||||||
|
|
||||||
|
const totalEpisodes = isSeries ? (runtimeInfo?.episodes ?? title.episodeCount) : 1;
|
||||||
|
const totalMinutes = isSeries
|
||||||
|
? (runtimeInfo?.minutes ?? (title.runtime ?? 0) * title.episodeCount)
|
||||||
|
: (title.runtime ?? 0);
|
||||||
|
|
||||||
|
const entry = entryByTitle.get(title.id);
|
||||||
|
const watchedEpisodeCount = isSeries
|
||||||
|
? Math.min(seenByTitle.get(title.id) ?? 0, totalEpisodes)
|
||||||
|
: entry?.status === "COMPLETED"
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const completed =
|
||||||
|
entry?.status === "COMPLETED" ||
|
||||||
|
(totalEpisodes > 0 && watchedEpisodeCount >= totalEpisodes);
|
||||||
|
|
||||||
|
// Verbleibende Zeit: bei Serien anteilig über die offenen Episoden,
|
||||||
|
// bei Filmen alles oder nichts.
|
||||||
|
const remainingMinutes = completed
|
||||||
|
? 0
|
||||||
|
: isSeries && totalEpisodes > 0
|
||||||
|
? Math.round((totalMinutes * (totalEpisodes - watchedEpisodeCount)) / totalEpisodes)
|
||||||
|
: totalMinutes;
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemId: item.id,
|
||||||
|
titleId: title.id,
|
||||||
|
title: title.title,
|
||||||
|
type: title.type,
|
||||||
|
posterPath: title.posterPath,
|
||||||
|
year: title.releaseDate?.getFullYear() ?? null,
|
||||||
|
note: item.note,
|
||||||
|
totalMinutes,
|
||||||
|
remainingMinutes,
|
||||||
|
watchedMinutes: minutesByTitle.get(title.id) ?? 0,
|
||||||
|
inLibrary: Boolean(entry),
|
||||||
|
completed,
|
||||||
|
status: entry?.status ?? null,
|
||||||
|
watchedEpisodes: watchedEpisodeCount,
|
||||||
|
totalEpisodes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sortieren: Erscheinungsreihenfolge rechnerisch, sonst nach den Positionen.
|
||||||
|
const positionByItem = new Map<string, number>();
|
||||||
|
for (const item of universe.items) {
|
||||||
|
const position = item.positions.find((row) => row.orderId === activeOrder.id);
|
||||||
|
if (position) positionByItem.set(item.id, position.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseYear = new Map(
|
||||||
|
universe.items.map((item) => [item.id, item.title.releaseDate?.getTime() ?? Number.MAX_SAFE_INTEGER]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = [...built].sort((a, b) => {
|
||||||
|
if (activeOrder.kind === "RELEASE") {
|
||||||
|
const diff = (releaseYear.get(a.itemId) ?? 0) - (releaseYear.get(b.itemId) ?? 0);
|
||||||
|
return diff !== 0 ? diff : a.title.localeCompare(b.title, "de");
|
||||||
|
}
|
||||||
|
const positionA = positionByItem.get(a.itemId) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const positionB = positionByItem.get(b.itemId) ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
return positionA !== positionB
|
||||||
|
? positionA - positionB
|
||||||
|
: a.title.localeCompare(b.title, "de");
|
||||||
|
});
|
||||||
|
|
||||||
|
const completed = sorted.filter((entry) => entry.completed).length;
|
||||||
|
const watchedMinutes = sorted.reduce((sum, entry) => sum + entry.watchedMinutes, 0);
|
||||||
|
const remainingMinutes = sorted.reduce((sum, entry) => sum + entry.remainingMinutes, 0);
|
||||||
|
const totalMinutes = sorted.reduce((sum, entry) => sum + entry.totalMinutes, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: universe.id,
|
||||||
|
name: universe.name,
|
||||||
|
description: universe.description,
|
||||||
|
isOwner: access.isOwner,
|
||||||
|
canWrite: access.canWrite,
|
||||||
|
ownerName: universe.owner.name,
|
||||||
|
orders: universe.orders.map((order) => ({
|
||||||
|
id: order.id,
|
||||||
|
name: order.name,
|
||||||
|
kind: order.kind,
|
||||||
|
isDefault: order.isDefault,
|
||||||
|
})),
|
||||||
|
activeOrderId: activeOrder.id,
|
||||||
|
entries: sorted,
|
||||||
|
progress: {
|
||||||
|
completed,
|
||||||
|
total: sorted.length,
|
||||||
|
percent: sorted.length > 0 ? Math.round((completed / sorted.length) * 100) : 0,
|
||||||
|
watchedMinutes,
|
||||||
|
remainingMinutes,
|
||||||
|
totalMinutes,
|
||||||
|
},
|
||||||
|
nextUp: sorted.find((entry) => !entry.completed) ?? null,
|
||||||
|
members: universe.members.map((member) => ({
|
||||||
|
id: member.id,
|
||||||
|
name: member.user.name,
|
||||||
|
email: member.user.email,
|
||||||
|
permission: member.permission,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UniverseSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
isOwner: boolean;
|
||||||
|
ownerName: string;
|
||||||
|
itemCount: number;
|
||||||
|
completed: number;
|
||||||
|
percent: number;
|
||||||
|
posters: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Eigene und mit einem geteilte Universen. */
|
||||||
|
export async function listUniverses(userId: string): Promise<UniverseSummary[]> {
|
||||||
|
const universes = await prisma.universe.findMany({
|
||||||
|
where: {
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
include: {
|
||||||
|
owner: { select: { name: true } },
|
||||||
|
items: {
|
||||||
|
include: {
|
||||||
|
title: { select: { id: true, posterPath: true, releaseDate: true, type: true } },
|
||||||
|
},
|
||||||
|
orderBy: { title: { releaseDate: "asc" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (universes.length === 0) return [];
|
||||||
|
|
||||||
|
const allTitleIds = universes.flatMap((universe) => universe.items.map((item) => item.titleId));
|
||||||
|
const completedEntries = await prisma.libraryEntry.findMany({
|
||||||
|
where: { userId, titleId: { in: allTitleIds }, status: "COMPLETED", deletedAt: null },
|
||||||
|
select: { titleId: true },
|
||||||
|
});
|
||||||
|
const completedTitles = new Set(completedEntries.map((entry) => entry.titleId));
|
||||||
|
|
||||||
|
return universes.map((universe) => {
|
||||||
|
const completed = universe.items.filter((item) => completedTitles.has(item.titleId)).length;
|
||||||
|
return {
|
||||||
|
id: universe.id,
|
||||||
|
name: universe.name,
|
||||||
|
description: universe.description,
|
||||||
|
isOwner: universe.ownerId === userId,
|
||||||
|
ownerName: universe.owner.name,
|
||||||
|
itemCount: universe.items.length,
|
||||||
|
completed,
|
||||||
|
percent: universe.items.length > 0 ? Math.round((completed / universe.items.length) * 100) : 0,
|
||||||
|
posters: universe.items
|
||||||
|
.map((item) => item.title.posterPath)
|
||||||
|
.filter((path): path is string => Boolean(path))
|
||||||
|
.slice(0, 5),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TMDB-Collections, die in der Bibliothek des Nutzers vorkommen. */
|
||||||
|
export async function suggestCollections(userId: string) {
|
||||||
|
const rows = await prisma.title.findMany({
|
||||||
|
where: {
|
||||||
|
tmdbCollectionId: { not: null },
|
||||||
|
entries: { some: { userId, deletedAt: null } },
|
||||||
|
},
|
||||||
|
select: { tmdbCollectionId: true, tmdbCollectionName: true },
|
||||||
|
distinct: ["tmdbCollectionId"],
|
||||||
|
orderBy: { tmdbCollectionName: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.filter((row) => row.tmdbCollectionId && row.tmdbCollectionName)
|
||||||
|
.map((row) => ({ id: row.tmdbCollectionId!, name: row.tmdbCollectionName! }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Prüft den TMDB-Importpfad gegen nachgebildete Antworten.
|
||||||
|
*
|
||||||
|
* npm test
|
||||||
|
*
|
||||||
|
* Braucht eine erreichbare Entwicklungsdatenbank (docker-compose.dev.yml) und
|
||||||
|
* keinen TMDB-Schlüssel. Angelegt werden ausschließlich Titel mit TMDB-IDs
|
||||||
|
* ab 999000 — echte Daten bleiben unberührt und werden am Ende aufgeräumt.
|
||||||
|
*/
|
||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before } from "node:test";
|
||||||
|
|
||||||
|
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID, COLLECTION_ID } from "./fixtures/tmdb";
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import {
|
||||||
|
clearCatalogCaches,
|
||||||
|
ensureTitle,
|
||||||
|
importCollection,
|
||||||
|
importMovie,
|
||||||
|
importSeries,
|
||||||
|
} from "../src/lib/catalog";
|
||||||
|
|
||||||
|
const stub = installTmdbStub();
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||||
|
await prisma.watchProvider.deleteMany({ where: { tmdbId: { in: [8, 30, 3, 337] } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(cleanup);
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await cleanup();
|
||||||
|
stub.restore();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Serie mit Staffeln und Episoden übernehmen", async () => {
|
||||||
|
const title = await importSeries(SERIES_ID, { region: "DE" });
|
||||||
|
|
||||||
|
assert.equal(title.title, "Nordlicht");
|
||||||
|
assert.equal(title.type, "SERIES");
|
||||||
|
assert.equal(title.imdbId, "tt9990010");
|
||||||
|
assert.equal(title.inProduction, true);
|
||||||
|
assert.deepEqual(title.genres, ["Drama", "Mystery"]);
|
||||||
|
assert.equal(title.runtime, 48, "durchschnittliche Episodenlänge aus episode_run_time");
|
||||||
|
|
||||||
|
// Specials zählen nicht mit: 2 reguläre Staffeln, 5 reguläre Episoden.
|
||||||
|
assert.equal(title.seasonCount, 2);
|
||||||
|
assert.equal(title.episodeCount, 5);
|
||||||
|
|
||||||
|
const seasons = await prisma.season.findMany({
|
||||||
|
where: { titleId: title.id },
|
||||||
|
orderBy: { seasonNumber: "asc" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
seasons.map((s) => s.seasonNumber),
|
||||||
|
[0, 1, 2],
|
||||||
|
"Specials werden importiert, aber nicht mitgezählt",
|
||||||
|
);
|
||||||
|
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: { titleId: title.id, seasonNumber: 1 },
|
||||||
|
orderBy: { episodeNumber: "asc" },
|
||||||
|
});
|
||||||
|
assert.equal(episodes.length, 3);
|
||||||
|
assert.equal(episodes[0]?.name, "Ankunft");
|
||||||
|
assert.equal(
|
||||||
|
episodes[2]?.runtime,
|
||||||
|
48,
|
||||||
|
"Episode ohne eigene Laufzeit fällt auf die Serienlänge zurück",
|
||||||
|
);
|
||||||
|
assert.equal(episodes[0]?.airDate?.toISOString().slice(0, 10), "2023-03-14");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Streamingdienste werden je Region übernommen", async () => {
|
||||||
|
const title = await prisma.title.findUniqueOrThrow({
|
||||||
|
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
|
||||||
|
include: { providers: { include: { provider: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const de = title.providers.filter((p) => p.region === "DE");
|
||||||
|
assert.equal(de.length, 3, "zwei Abo-Dienste und ein Kauf-Dienst für DE");
|
||||||
|
|
||||||
|
const flatrate = de.filter((p) => p.kind === "FLATRATE").map((p) => p.provider.name).sort();
|
||||||
|
assert.deepEqual(flatrate, ["Netflix", "WOW"]);
|
||||||
|
|
||||||
|
const netflix = de.find((p) => p.provider.name === "Netflix");
|
||||||
|
assert.equal(netflix?.provider.color, "#e50914", "bekannter Dienst bekommt seine Akzentfarbe");
|
||||||
|
|
||||||
|
const at = title.providers.filter((p) => p.region === "AT");
|
||||||
|
assert.equal(at.length, 0, "nur die angeforderte Region wird geschrieben");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("erneuter Import legt nichts doppelt an", async () => {
|
||||||
|
clearCatalogCaches();
|
||||||
|
const before = await prisma.episode.count({
|
||||||
|
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await importSeries(SERIES_ID, { region: "DE" });
|
||||||
|
|
||||||
|
const after = await prisma.episode.count({
|
||||||
|
where: { title: { tmdbId: SERIES_ID, type: "SERIES" } },
|
||||||
|
});
|
||||||
|
assert.equal(after, before, "Episoden werden aktualisiert, nicht dupliziert");
|
||||||
|
|
||||||
|
const titles = await prisma.title.count({ where: { tmdbId: SERIES_ID, type: "SERIES" } });
|
||||||
|
assert.equal(titles, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Film mit Collection übernehmen", async () => {
|
||||||
|
const movie = await importMovie(MOVIE_ID, "DE");
|
||||||
|
|
||||||
|
assert.equal(movie.type, "MOVIE");
|
||||||
|
assert.equal(movie.runtime, 118);
|
||||||
|
assert.equal(movie.tmdbCollectionId, COLLECTION_ID);
|
||||||
|
assert.equal(movie.tmdbCollectionName, "Vorführer-Reihe");
|
||||||
|
assert.equal(movie.releaseDate?.toISOString().slice(0, 10), "2024-10-17");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Collection wird nach Erscheinungsdatum sortiert importiert", async () => {
|
||||||
|
clearCatalogCaches();
|
||||||
|
const { collection, titles } = await importCollection(COLLECTION_ID, "DE");
|
||||||
|
|
||||||
|
assert.equal(collection.name, "Vorführer-Reihe");
|
||||||
|
assert.equal(titles.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
titles.map((t) => t.tmdbId),
|
||||||
|
[MOVIE_ID, MOVIE_2_ID],
|
||||||
|
"die Fixture liefert die Teile verdreht — der Import muss sortieren",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensureTitle greift auf den Katalog zurück, statt erneut abzurufen", async () => {
|
||||||
|
clearCatalogCaches();
|
||||||
|
const countBefore = stub.calls.length;
|
||||||
|
|
||||||
|
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
|
||||||
|
|
||||||
|
assert.equal(stub.calls.length, countBefore, "frisch importierter Titel löst keinen Abruf aus");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ensureTitle frischt veraltete Einträge auf", async () => {
|
||||||
|
clearCatalogCaches();
|
||||||
|
await prisma.title.update({
|
||||||
|
where: { type_tmdbId: { type: "SERIES", tmdbId: SERIES_ID } },
|
||||||
|
data: { syncedAt: new Date(Date.now() - 40 * 24 * 3_600_000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const countBefore = stub.calls.length;
|
||||||
|
await ensureTitle("SERIES", SERIES_ID, { maxAgeHours: 24, region: "DE" });
|
||||||
|
|
||||||
|
assert.ok(stub.calls.length > countBefore, "veralteter Eintrag wird neu geholt");
|
||||||
|
});
|
||||||
Vendored
+289
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* Nachgebildete TMDB-Antworten.
|
||||||
|
*
|
||||||
|
* Damit lässt sich der komplette Importpfad (Kopfdaten, Staffeln, Episoden,
|
||||||
|
* Streamingdienste, Collections) prüfen, ohne einen echten Schlüssel zu
|
||||||
|
* brauchen — und ohne bei jedem Lauf die TMDB-API zu belasten. Der Aufbau
|
||||||
|
* entspricht dem, was TMDB tatsächlich liefert; gekürzt auf die Felder, die
|
||||||
|
* Flimmer verwendet.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SERIES_ID = 999001;
|
||||||
|
export const MOVIE_ID = 999002;
|
||||||
|
export const MOVIE_2_ID = 999003;
|
||||||
|
export const COLLECTION_ID = 999100;
|
||||||
|
|
||||||
|
export const seriesDetails = {
|
||||||
|
id: SERIES_ID,
|
||||||
|
name: "Nordlicht",
|
||||||
|
original_name: "Nordlys",
|
||||||
|
overview:
|
||||||
|
"Eine Meteorologin kehrt in ihr Heimatdorf am Polarkreis zurück und stößt auf ein Wetterphänomen, das es nicht geben dürfte.",
|
||||||
|
tagline: "Manche Lichter wollen nicht gesehen werden.",
|
||||||
|
poster_path: "/abcdef1234567890.jpg",
|
||||||
|
backdrop_path: "/bcdefa1234567890.jpg",
|
||||||
|
first_air_date: "2023-03-14",
|
||||||
|
last_air_date: "2025-05-02",
|
||||||
|
episode_run_time: [48],
|
||||||
|
status: "Returning Series",
|
||||||
|
in_production: true,
|
||||||
|
genres: [
|
||||||
|
{ id: 18, name: "Drama" },
|
||||||
|
{ id: 9648, name: "Mystery" },
|
||||||
|
],
|
||||||
|
original_language: "no",
|
||||||
|
homepage: "https://example.org/nordlicht",
|
||||||
|
vote_average: 8.1,
|
||||||
|
number_of_seasons: 2,
|
||||||
|
number_of_episodes: 5,
|
||||||
|
external_ids: { imdb_id: "tt9990010" },
|
||||||
|
seasons: [
|
||||||
|
{
|
||||||
|
id: 500000,
|
||||||
|
season_number: 0,
|
||||||
|
name: "Specials",
|
||||||
|
overview: "Zusätzliche Folgen außerhalb der Handlung.",
|
||||||
|
poster_path: null,
|
||||||
|
air_date: "2023-12-24",
|
||||||
|
episode_count: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 500001,
|
||||||
|
season_number: 1,
|
||||||
|
name: "Staffel 1",
|
||||||
|
overview: "Die Rückkehr.",
|
||||||
|
poster_path: "/cdefab1234567890.jpg",
|
||||||
|
air_date: "2023-03-14",
|
||||||
|
episode_count: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 500002,
|
||||||
|
season_number: 2,
|
||||||
|
name: "Staffel 2",
|
||||||
|
overview: "Der lange Winter.",
|
||||||
|
poster_path: "/defabc1234567890.jpg",
|
||||||
|
air_date: "2025-04-11",
|
||||||
|
episode_count: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"watch/providers": {
|
||||||
|
results: {
|
||||||
|
DE: {
|
||||||
|
link: "https://www.themoviedb.org/tv/999001/watch?locale=DE",
|
||||||
|
flatrate: [
|
||||||
|
{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" },
|
||||||
|
{ provider_id: 30, provider_name: "WOW", logo_path: "/wowlogo123456789.jpg" },
|
||||||
|
],
|
||||||
|
buy: [
|
||||||
|
{
|
||||||
|
provider_id: 3,
|
||||||
|
provider_name: "Google Play Movies",
|
||||||
|
logo_path: "/googleplay123456.jpg",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AT: {
|
||||||
|
flatrate: [{ provider_id: 8, provider_name: "Netflix", logo_path: "/netflix12345678.jpg" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const seasons: Record<number, unknown> = {
|
||||||
|
0: {
|
||||||
|
id: 500000,
|
||||||
|
season_number: 0,
|
||||||
|
name: "Specials",
|
||||||
|
air_date: "2023-12-24",
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: 600000,
|
||||||
|
season_number: 0,
|
||||||
|
episode_number: 1,
|
||||||
|
name: "Weihnachten am Fjord",
|
||||||
|
overview: "Eine Folge außer der Reihe.",
|
||||||
|
still_path: "/still000000000001.jpg",
|
||||||
|
air_date: "2023-12-24",
|
||||||
|
runtime: 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
id: 500001,
|
||||||
|
season_number: 1,
|
||||||
|
name: "Staffel 1",
|
||||||
|
air_date: "2023-03-14",
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: 600001,
|
||||||
|
season_number: 1,
|
||||||
|
episode_number: 1,
|
||||||
|
name: "Ankunft",
|
||||||
|
overview: "Ida steigt in Bodø aus dem Zug.",
|
||||||
|
still_path: "/still000000000002.jpg",
|
||||||
|
air_date: "2023-03-14",
|
||||||
|
runtime: 52,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 600002,
|
||||||
|
season_number: 1,
|
||||||
|
episode_number: 2,
|
||||||
|
name: "Der Messwert",
|
||||||
|
overview: "Ein Gerät zeigt etwas Unmögliches an.",
|
||||||
|
still_path: null,
|
||||||
|
air_date: "2023-03-21",
|
||||||
|
runtime: 47,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 600003,
|
||||||
|
season_number: 1,
|
||||||
|
episode_number: 3,
|
||||||
|
name: "Polarnacht",
|
||||||
|
overview: "Die Sonne bleibt weg — länger als sie darf.",
|
||||||
|
still_path: null,
|
||||||
|
air_date: "2023-03-28",
|
||||||
|
// Ohne Laufzeit: muss auf episode_run_time zurückfallen.
|
||||||
|
runtime: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
id: 500002,
|
||||||
|
season_number: 2,
|
||||||
|
name: "Staffel 2",
|
||||||
|
air_date: "2025-04-11",
|
||||||
|
episodes: [
|
||||||
|
{
|
||||||
|
id: 600004,
|
||||||
|
season_number: 2,
|
||||||
|
episode_number: 1,
|
||||||
|
name: "Rückkehr des Lichts",
|
||||||
|
overview: null,
|
||||||
|
still_path: null,
|
||||||
|
air_date: "2025-04-11",
|
||||||
|
runtime: 50,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 600005,
|
||||||
|
season_number: 2,
|
||||||
|
episode_number: 2,
|
||||||
|
name: "Der zweite Winter",
|
||||||
|
overview: null,
|
||||||
|
still_path: null,
|
||||||
|
// Noch nicht ausgestrahlt — kommt in Etappe 5 im Kalender vor.
|
||||||
|
air_date: "2099-01-08",
|
||||||
|
runtime: 50,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const movieDetails = {
|
||||||
|
id: MOVIE_ID,
|
||||||
|
imdb_id: "tt9990020",
|
||||||
|
title: "Die Stille zwischen den Bildern",
|
||||||
|
original_title: "The Silence Between Frames",
|
||||||
|
overview: "Ein Filmvorführer entdeckt in einer alten Rolle Bilder, die dort nicht sein können.",
|
||||||
|
tagline: "24 Bilder pro Sekunde. Eins davon lügt.",
|
||||||
|
poster_path: "/movie0001234567890.jpg",
|
||||||
|
backdrop_path: "/movieback01234567.jpg",
|
||||||
|
release_date: "2024-10-17",
|
||||||
|
runtime: 118,
|
||||||
|
status: "Released",
|
||||||
|
genres: [
|
||||||
|
{ id: 27, name: "Horror" },
|
||||||
|
{ id: 53, name: "Thriller" },
|
||||||
|
],
|
||||||
|
original_language: "en",
|
||||||
|
homepage: null,
|
||||||
|
vote_average: 7.4,
|
||||||
|
belongs_to_collection: {
|
||||||
|
id: COLLECTION_ID,
|
||||||
|
name: "Vorführer-Reihe",
|
||||||
|
poster_path: "/collection00123456.jpg",
|
||||||
|
},
|
||||||
|
"watch/providers": {
|
||||||
|
results: {
|
||||||
|
DE: {
|
||||||
|
flatrate: [
|
||||||
|
{ provider_id: 337, provider_name: "Disney Plus", logo_path: "/disney1234567890.jpg" },
|
||||||
|
],
|
||||||
|
rent: [
|
||||||
|
{ provider_id: 3, provider_name: "Google Play Movies", logo_path: "/googleplay123456.jpg" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const movie2Details = {
|
||||||
|
...movieDetails,
|
||||||
|
id: MOVIE_2_ID,
|
||||||
|
imdb_id: "tt9990030",
|
||||||
|
title: "Die Stille zwischen den Bildern II",
|
||||||
|
original_title: "The Silence Between Frames II",
|
||||||
|
release_date: "2026-02-06",
|
||||||
|
runtime: 126,
|
||||||
|
poster_path: "/movie0002234567890.jpg",
|
||||||
|
"watch/providers": { results: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const collectionDetails = {
|
||||||
|
id: COLLECTION_ID,
|
||||||
|
name: "Vorführer-Reihe",
|
||||||
|
overview: "Zwei Filme über alte Projektoren und das, was sie zeigen.",
|
||||||
|
poster_path: "/collection00123456.jpg",
|
||||||
|
backdrop_path: null,
|
||||||
|
parts: [
|
||||||
|
// Absichtlich in falscher Reihenfolge — der Import muss nach Datum sortieren.
|
||||||
|
{ id: MOVIE_2_ID, title: movie2Details.title, release_date: "2026-02-06" },
|
||||||
|
{ id: MOVIE_ID, title: movieDetails.title, release_date: "2024-10-17" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ersetzt `fetch` durch eine Fassung, die aus den Fixtures antwortet.
|
||||||
|
* Liefert eine Funktion zum Zurücksetzen sowie die Liste der Aufrufe.
|
||||||
|
*/
|
||||||
|
export function installTmdbStub() {
|
||||||
|
const original = globalThis.fetch;
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
globalThis.fetch = (async (input: string | URL | Request) => {
|
||||||
|
const url = new URL(typeof input === "string" ? input : input.toString());
|
||||||
|
calls.push(url.pathname);
|
||||||
|
|
||||||
|
const json = (body: unknown) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const path = url.pathname;
|
||||||
|
|
||||||
|
if (path === `/3/tv/${SERIES_ID}`) return json(seriesDetails);
|
||||||
|
const seasonMatch = path.match(new RegExp(`^/3/tv/${SERIES_ID}/season/(\\d+)$`));
|
||||||
|
if (seasonMatch?.[1] !== undefined) {
|
||||||
|
const number = Number(seasonMatch[1]);
|
||||||
|
const season = seasons[number];
|
||||||
|
return season
|
||||||
|
? json(season)
|
||||||
|
: new Response(JSON.stringify({ status_message: "Not found" }), { status: 404 });
|
||||||
|
}
|
||||||
|
if (path === `/3/movie/${MOVIE_ID}`) return json(movieDetails);
|
||||||
|
if (path === `/3/movie/${MOVIE_2_ID}`) return json(movie2Details);
|
||||||
|
if (path === `/3/collection/${COLLECTION_ID}`) return json(collectionDetails);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ status_message: `Kein Fixture für ${path}` }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls,
|
||||||
|
restore() {
|
||||||
|
globalThis.fetch = original;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before, beforeEach } from "node:test";
|
||||||
|
|
||||||
|
import { installTmdbStub, SERIES_ID, MOVIE_ID } from "./fixtures/tmdb";
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||||
|
import {
|
||||||
|
addToLibrary,
|
||||||
|
getNextEpisode,
|
||||||
|
getProgress,
|
||||||
|
getWatchedEpisodeIds,
|
||||||
|
markEpisodesWatched,
|
||||||
|
markMovieWatched,
|
||||||
|
markSeasonWatched,
|
||||||
|
markUpTo,
|
||||||
|
moveToTrash,
|
||||||
|
restoreFromTrash,
|
||||||
|
setStatus,
|
||||||
|
startRewatch,
|
||||||
|
undoWatches,
|
||||||
|
unmarkEpisodes,
|
||||||
|
updateEntryDetails,
|
||||||
|
watchNextEpisode,
|
||||||
|
} from "../src/lib/library";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Kern-Tracking: Aufnehmen, Abhaken, Fortschritt, Rewatch.
|
||||||
|
* Läuft gegen die Entwicklungsdatenbank mit eigenem Testkonto und
|
||||||
|
* nachgebildeten TMDB-Daten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stub = installTmdbStub();
|
||||||
|
const TEST_EMAIL = "test-library@flimmer.invalid";
|
||||||
|
const OTHER_EMAIL = "test-library-other@flimmer.invalid";
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
let seriesTitleId: string;
|
||||||
|
let movieTitleId: string;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: { in: [TEST_EMAIL, OTHER_EMAIL] } } });
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await cleanup();
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { email: TEST_EMAIL, name: "Testkonto", region: "DE" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
userId = user.id;
|
||||||
|
|
||||||
|
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
|
||||||
|
movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Jeder Test startet mit einer leeren Bibliothek, aber demselben Katalog.
|
||||||
|
await prisma.watch.deleteMany({ where: { userId } });
|
||||||
|
await prisma.libraryEntry.deleteMany({ where: { userId } });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await cleanup();
|
||||||
|
stub.restore();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Alle regulären Episoden der Testserie, in Reihenfolge. */
|
||||||
|
async function regularEpisodes() {
|
||||||
|
return prisma.episode.findMany({
|
||||||
|
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
|
||||||
|
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||||
|
select: { id: true, seasonNumber: true, episodeNumber: true, runtime: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("Titel aufnehmen ist wiederholbar und schlägt den Streamingdienst vor", async () => {
|
||||||
|
const first = await addToLibrary(userId, seriesTitleId, "WATCHLIST");
|
||||||
|
const second = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||||
|
|
||||||
|
assert.equal(first.id, second.id, "kein zweiter Eintrag für denselben Titel");
|
||||||
|
assert.equal(first.status, "WATCHLIST");
|
||||||
|
assert.equal(
|
||||||
|
first.whereToWatch,
|
||||||
|
"Netflix",
|
||||||
|
"der erste Abo-Dienst der Region wird als Startwert übernommen",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Papierkorb behält den Fortschritt", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
|
||||||
|
await moveToTrash(userId, entry.id);
|
||||||
|
const trashed = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
|
||||||
|
assert.ok(trashed.deletedAt, "liegt im Papierkorb statt gelöscht zu sein");
|
||||||
|
|
||||||
|
// Erneutes Aufnehmen belebt denselben Eintrag wieder.
|
||||||
|
const revived = await addToLibrary(userId, seriesTitleId);
|
||||||
|
assert.equal(revived.id, entry.id);
|
||||||
|
assert.equal(revived.deletedAt, null);
|
||||||
|
|
||||||
|
const progress = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(progress.watched, 1, "die Sichtung hat den Umweg überlebt");
|
||||||
|
|
||||||
|
await moveToTrash(userId, entry.id);
|
||||||
|
await restoreFromTrash(userId, entry.id);
|
||||||
|
const restored = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
|
||||||
|
assert.equal(restored.deletedAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fortschritt zählt Specials nicht mit", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const progress = await getProgress(userId, entry.id);
|
||||||
|
|
||||||
|
assert.equal(progress.total, 5, "5 reguläre Episoden, die Weihnachtsfolge zählt nicht");
|
||||||
|
assert.equal(progress.watched, 0);
|
||||||
|
assert.equal(progress.percent, 0);
|
||||||
|
|
||||||
|
const special = await prisma.episode.findFirstOrThrow({
|
||||||
|
where: { titleId: seriesTitleId, seasonNumber: 0 },
|
||||||
|
});
|
||||||
|
await markEpisodesWatched(userId, entry.id, [special.id]);
|
||||||
|
|
||||||
|
const after = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(after.watched, 0, "ein Special bewegt den Fortschrittsbalken nicht");
|
||||||
|
assert.ok(after.minutesWatched > 0, "die Schauzeit wird trotzdem gezählt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nächste Episode folgt der Reihenfolge und überspringt Specials", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
|
||||||
|
const first = await getNextEpisode(userId, entry.id);
|
||||||
|
assert.equal(first?.seasonNumber, 1);
|
||||||
|
assert.equal(first?.episodeNumber, 1);
|
||||||
|
|
||||||
|
await watchNextEpisode(userId, entry.id);
|
||||||
|
const second = await getNextEpisode(userId, entry.id);
|
||||||
|
assert.equal(second?.episodeNumber, 2);
|
||||||
|
|
||||||
|
// Über die Staffelgrenze hinweg
|
||||||
|
await markSeasonWatched(
|
||||||
|
userId,
|
||||||
|
entry.id,
|
||||||
|
(await prisma.season.findFirstOrThrow({ where: { titleId: seriesTitleId, seasonNumber: 1 } }))
|
||||||
|
.id,
|
||||||
|
);
|
||||||
|
const third = await getNextEpisode(userId, entry.id);
|
||||||
|
assert.equal(third?.seasonNumber, 2, "nach Staffel 1 kommt Staffel 2");
|
||||||
|
assert.equal(third?.episodeNumber, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Status ergibt sich aus den Sichtungen", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId, "WATCHLIST");
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
|
||||||
|
const afterFirst = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
assert.equal(afterFirst.status, "WATCHING", "die erste Episode startet die Serie");
|
||||||
|
|
||||||
|
const afterAll = await markEpisodesWatched(
|
||||||
|
userId,
|
||||||
|
entry.id,
|
||||||
|
episodes.map((episode) => episode.id),
|
||||||
|
);
|
||||||
|
assert.equal(afterAll.status, "COMPLETED", "die letzte Episode schließt sie ab");
|
||||||
|
|
||||||
|
const completed = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
|
||||||
|
assert.ok(completed.startedAt && completed.finishedAt);
|
||||||
|
|
||||||
|
await unmarkEpisodes(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
const reopened = await prisma.libraryEntry.findUniqueOrThrow({ where: { id: entry.id } });
|
||||||
|
assert.equal(reopened.status, "WATCHING", "eine zurückgenommene Episode öffnet sie wieder");
|
||||||
|
assert.equal(reopened.finishedAt, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Pausiert und Abgebrochen bleiben stehen", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
|
||||||
|
await setStatus(userId, entry.id, "PAUSED");
|
||||||
|
const result = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
assert.equal(result.status, "PAUSED", "eine Sichtung hebt die Pause nicht selbsttätig auf");
|
||||||
|
|
||||||
|
await setStatus(userId, entry.id, "DROPPED");
|
||||||
|
const dropped = await markEpisodesWatched(userId, entry.id, [episodes[1]!.id]);
|
||||||
|
assert.equal(dropped.status, "DROPPED");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("„bis hierhin gesehen\" hakt alles davor ab", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
|
||||||
|
// Staffel 2, Episode 1 → alles aus Staffel 1 plus diese eine
|
||||||
|
const target = episodes.find((e) => e.seasonNumber === 2 && e.episodeNumber === 1)!;
|
||||||
|
const result = await markUpTo(userId, entry.id, target.id);
|
||||||
|
|
||||||
|
assert.equal(result.watchIds.length, 4, "3 aus Staffel 1 und die Zielepisode");
|
||||||
|
const progress = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(progress.watched, 4);
|
||||||
|
assert.equal(progress.percent, 80);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("doppeltes Abhaken legt keine zweite Sichtung an", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
|
||||||
|
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
const again = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
|
||||||
|
assert.equal(again.watchIds.length, 0);
|
||||||
|
const count = await prisma.watch.count({ where: { userId, episodeId: episodes[0]!.id } });
|
||||||
|
assert.equal(count, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Rückgängig entfernt genau die eben angelegten Sichtungen", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
|
||||||
|
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
const season = await markSeasonWatched(
|
||||||
|
userId,
|
||||||
|
entry.id,
|
||||||
|
(await prisma.season.findFirstOrThrow({ where: { titleId: seriesTitleId, seasonNumber: 1 } }))
|
||||||
|
.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(season.watchIds.length, 2, "die bereits gesehene Episode ist nicht dabei");
|
||||||
|
|
||||||
|
await undoWatches(userId, entry.id, season.watchIds);
|
||||||
|
const progress = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(progress.watched, 1, "die erste Episode bleibt gesehen");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Rewatch setzt den Fortschritt zurück, behält aber die Schauzeit", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
await markEpisodesWatched(
|
||||||
|
userId,
|
||||||
|
entry.id,
|
||||||
|
episodes.map((episode) => episode.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const firstRun = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(firstRun.percent, 100);
|
||||||
|
|
||||||
|
await startRewatch(userId, entry.id);
|
||||||
|
const secondRun = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(secondRun.watched, 0, "der neue Durchlauf beginnt bei null");
|
||||||
|
assert.equal(secondRun.minutesWatched, 0);
|
||||||
|
|
||||||
|
const total = await prisma.watch.aggregate({ where: { userId }, _sum: { minutes: true } });
|
||||||
|
assert.equal(
|
||||||
|
total._sum.minutes,
|
||||||
|
firstRun.minutesWatched,
|
||||||
|
"die Sichtungen des ersten Durchlaufs bleiben für die Statistik erhalten",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dieselbe Episode lässt sich im neuen Durchlauf erneut abhaken.
|
||||||
|
const result = await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
assert.equal(result.watchIds.length, 1);
|
||||||
|
const watched = await getWatchedEpisodeIds(userId, entry.id);
|
||||||
|
assert.deepEqual([...watched], [episodes[0]!.id], "die Häkchen zeigen nur den aktuellen Durchlauf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("verbleibende Laufzeit schrumpft mit dem Fortschritt", async () => {
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId);
|
||||||
|
const episodes = await regularEpisodes();
|
||||||
|
|
||||||
|
const before = await getProgress(userId, entry.id);
|
||||||
|
const sum = episodes.reduce((total, episode) => total + (episode.runtime ?? 0), 0);
|
||||||
|
assert.equal(before.minutesLeft, sum);
|
||||||
|
|
||||||
|
await markEpisodesWatched(userId, entry.id, [episodes[0]!.id]);
|
||||||
|
const after = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(after.minutesLeft, sum - (episodes[0]!.runtime ?? 0));
|
||||||
|
assert.equal(after.minutesWatched, episodes[0]!.runtime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Filme: gesehen, Rewatch und Laufzeit", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId, "WATCHLIST");
|
||||||
|
|
||||||
|
const result = await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal(result.status, "COMPLETED");
|
||||||
|
assert.equal(result.watchIds.length, 1);
|
||||||
|
|
||||||
|
const progress = await getProgress(userId, entry.id);
|
||||||
|
assert.equal(progress.percent, 100);
|
||||||
|
assert.equal(progress.minutesWatched, 118);
|
||||||
|
assert.equal(progress.minutesLeft, 0);
|
||||||
|
|
||||||
|
const again = await markMovieWatched(userId, entry.id);
|
||||||
|
assert.equal(again.watchIds.length, 0, "zweimal im selben Durchlauf zählt nicht doppelt");
|
||||||
|
|
||||||
|
await startRewatch(userId, entry.id);
|
||||||
|
await markMovieWatched(userId, entry.id);
|
||||||
|
const runs = await prisma.watch.count({ where: { userId, entryId: entry.id } });
|
||||||
|
assert.equal(runs, 2, "der zweite Durchlauf wird eigenständig gezählt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Bewertung, Notiz und Fundort lassen sich pflegen", async () => {
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId);
|
||||||
|
|
||||||
|
const updated = await updateEntryDetails(userId, entry.id, {
|
||||||
|
rating: 9,
|
||||||
|
notes: " Zweimal gesehen, beim zweiten Mal besser. ",
|
||||||
|
whereToWatch: "Blu-ray",
|
||||||
|
favorite: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(updated.rating, 9);
|
||||||
|
assert.equal(updated.notes, "Zweimal gesehen, beim zweiten Mal besser.", "Rand wird beschnitten");
|
||||||
|
assert.equal(updated.whereToWatch, "Blu-ray");
|
||||||
|
assert.equal(updated.favorite, true);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => updateEntryDetails(userId, entry.id, { rating: 11 }),
|
||||||
|
/zwischen 1 und 10/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fremde Einträge sind nicht erreichbar", async () => {
|
||||||
|
const other = await prisma.user.create({
|
||||||
|
data: { email: OTHER_EMAIL, name: "Andere" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
const entry = await addToLibrary(userId, movieTitleId);
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => updateEntryDetails(other.id, entry.id, { rating: 1 }),
|
||||||
|
/gehört nicht zu deiner Bibliothek/,
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.user.delete({ where: { id: other.id } });
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Testumgebung.
|
||||||
|
*
|
||||||
|
* Muss als allererster Import stehen: statische Importe werden vor jedem
|
||||||
|
* anderen Anweisungscode ausgeführt, und die Module lesen ihre Konfiguration
|
||||||
|
* beim Laden. Die Werte hier sind Platzhalter — die Tests sprechen nie mit
|
||||||
|
* der echten TMDB-API.
|
||||||
|
*/
|
||||||
|
process.env.TMDB_API_KEY = process.env.TMDB_API_KEY || "fixture-key";
|
||||||
|
process.env.TMDB_REGION = "DE";
|
||||||
|
process.env.TMDB_LANGUAGE = "de-DE";
|
||||||
|
process.env.AUTH_SECRET = process.env.AUTH_SECRET || "fixture-secret-fixture-secret-fixture";
|
||||||
|
process.env.APP_URL = process.env.APP_URL || "http://localhost:3011";
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL) {
|
||||||
|
throw new Error(
|
||||||
|
"DATABASE_URL fehlt. Tests brauchen die Entwicklungsdatenbank:\n" +
|
||||||
|
" docker compose -f docker-compose.dev.yml up -d\n" +
|
||||||
|
"und eine .env (npm test lädt sie mit --env-file).",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before } from "node:test";
|
||||||
|
|
||||||
|
import { installTmdbStub, SERIES_ID, MOVIE_ID } from "./fixtures/tmdb";
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||||
|
import { addToLibrary, markEpisodesWatched, markMovieWatched } from "../src/lib/library";
|
||||||
|
import {
|
||||||
|
getContinueWatching,
|
||||||
|
getMonthlyActivity,
|
||||||
|
getTotals,
|
||||||
|
getUpcomingEpisodes,
|
||||||
|
getYearReview,
|
||||||
|
} from "../src/lib/stats";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft die Auswertungen. Besonderes Augenmerk auf die Monatsgruppierung:
|
||||||
|
* dort führte ein Zeitzonenversatz zwischen Datenbank und JavaScript einmal
|
||||||
|
* dazu, dass das Diagramm leer blieb, obwohl Sichtungen vorlagen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stub = installTmdbStub();
|
||||||
|
const TEST_EMAIL = "test-stats@flimmer.invalid";
|
||||||
|
|
||||||
|
let userId: string;
|
||||||
|
let entryId: string;
|
||||||
|
let seriesTitleId: string;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await cleanup();
|
||||||
|
userId = (
|
||||||
|
await prisma.user.create({ data: { email: TEST_EMAIL, name: "Statistik" }, select: { id: true } })
|
||||||
|
).id;
|
||||||
|
|
||||||
|
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
|
||||||
|
const movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
|
||||||
|
|
||||||
|
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||||
|
entryId = entry.id;
|
||||||
|
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
|
||||||
|
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drei Episoden: heute, vor einem Monat, vor zwei Monaten.
|
||||||
|
for (const [index, episode] of episodes.slice(0, 3).entries()) {
|
||||||
|
const when = new Date();
|
||||||
|
when.setMonth(when.getMonth() - index);
|
||||||
|
when.setHours(20, 30, 0, 0);
|
||||||
|
await markEpisodesWatched(userId, entry.id, [episode.id], when);
|
||||||
|
}
|
||||||
|
|
||||||
|
const movieEntry = await addToLibrary(userId, movieTitleId, "WATCHING");
|
||||||
|
await markMovieWatched(userId, movieEntry.id, new Date());
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await cleanup();
|
||||||
|
stub.restore();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Gesamtzahlen zählen Episoden und Filme getrennt", async () => {
|
||||||
|
const totals = await getTotals(userId);
|
||||||
|
|
||||||
|
assert.equal(totals.episodes, 3);
|
||||||
|
assert.equal(totals.movies, 1);
|
||||||
|
// 52 + 47 + 48 aus den Episoden, 118 aus dem Film
|
||||||
|
assert.equal(totals.minutes, 52 + 47 + 48 + 118);
|
||||||
|
assert.equal(totals.seriesCount, 1);
|
||||||
|
assert.equal(totals.movieCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Monatsverlauf ordnet Sichtungen dem richtigen Monat zu", async () => {
|
||||||
|
const points = await getMonthlyActivity(userId, 12);
|
||||||
|
|
||||||
|
assert.equal(points.length, 12);
|
||||||
|
|
||||||
|
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
|
||||||
|
assert.equal(total, 4, "keine Sichtung fällt beim Gruppieren durchs Raster");
|
||||||
|
|
||||||
|
const current = points[points.length - 1];
|
||||||
|
assert.ok(current);
|
||||||
|
assert.equal(current.episodes, 1, "die Sichtung von heute liegt im laufenden Monat");
|
||||||
|
assert.equal(current.movies, 1);
|
||||||
|
|
||||||
|
const previous = points[points.length - 2];
|
||||||
|
assert.equal(previous?.episodes, 1, "die Sichtung von vor einem Monat liegt im Vormonat");
|
||||||
|
|
||||||
|
// Lückenlos: auch Monate ohne Sichtung sind vertreten.
|
||||||
|
assert.ok(points.some((point) => point.episodes === 0 && point.movies === 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Jahresrückblick fasst das laufende Jahr zusammen", async () => {
|
||||||
|
const review = await getYearReview(userId, new Date().getFullYear());
|
||||||
|
|
||||||
|
assert.ok(review.minutes > 0);
|
||||||
|
assert.ok(review.episodes >= 1);
|
||||||
|
assert.equal(review.movies, 1);
|
||||||
|
assert.ok(review.busiestMonth, "der stärkste Monat wird benannt");
|
||||||
|
assert.match(
|
||||||
|
review.busiestMonth!.label,
|
||||||
|
/\d{4}$/,
|
||||||
|
"die Beschriftung endet auf die Jahreszahl, nicht auf einen rohen Zeitstempel",
|
||||||
|
);
|
||||||
|
assert.ok(review.topTitle);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Weiterschauen nennt die nächste offene Episode", async () => {
|
||||||
|
const items = await getContinueWatching(userId, 10);
|
||||||
|
const series = items.find((item) => item.titleId === seriesTitleId);
|
||||||
|
|
||||||
|
assert.ok(series, "die laufende Serie steht in der Liste");
|
||||||
|
assert.equal(series.watched, 3);
|
||||||
|
assert.equal(series.total, 5);
|
||||||
|
assert.equal(series.next?.episodeNumber, 1);
|
||||||
|
assert.equal(series.next?.seasonNumber, 2, "nach Staffel 1 kommt Staffel 2");
|
||||||
|
assert.ok(series.lastWatchedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Weiterschauen lässt durchgeschaute Serien weg", async () => {
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await markEpisodesWatched(userId, entryId, episodes.map((episode) => episode.id));
|
||||||
|
|
||||||
|
const items = await getContinueWatching(userId, 10);
|
||||||
|
assert.equal(
|
||||||
|
items.find((item) => item.titleId === seriesTitleId),
|
||||||
|
undefined,
|
||||||
|
"wo nichts mehr offen ist, gibt es auch nichts weiterzuschauen",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Kalender liefert nur Episoden im gefragten Zeitraum", async () => {
|
||||||
|
// Die Fixture hat eine Episode am 08.01.2099. Der vorige Test hat die Serie
|
||||||
|
// auf „Abgeschlossen" gebracht — sie muss trotzdem im Kalender stehen, denn
|
||||||
|
// bei einer laufenden Serie heißt „abgeschlossen" nur: auf dem Stand.
|
||||||
|
const episodes = await getUpcomingEpisodes(
|
||||||
|
userId,
|
||||||
|
new Date("2098-12-01T00:00:00.000Z"),
|
||||||
|
new Date("2099-03-01T00:00:00.000Z"),
|
||||||
|
);
|
||||||
|
assert.equal(episodes.length, 1);
|
||||||
|
assert.equal(episodes[0]?.seasonNumber, 2);
|
||||||
|
assert.equal(episodes[0]?.episodeNumber, 2);
|
||||||
|
|
||||||
|
const empty = await getUpcomingEpisodes(
|
||||||
|
userId,
|
||||||
|
new Date("2050-01-01T00:00:00.000Z"),
|
||||||
|
new Date("2050-12-31T00:00:00.000Z"),
|
||||||
|
);
|
||||||
|
assert.equal(empty.length, 0, "außerhalb des Zeitraums kommt nichts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Abgebrochenes taucht im Kalender nicht mehr auf", async () => {
|
||||||
|
await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "DROPPED" } });
|
||||||
|
|
||||||
|
const episodes = await getUpcomingEpisodes(
|
||||||
|
userId,
|
||||||
|
new Date("2098-12-01T00:00:00.000Z"),
|
||||||
|
new Date("2099-03-01T00:00:00.000Z"),
|
||||||
|
);
|
||||||
|
assert.equal(episodes.length, 0, "wer abgebrochen hat, braucht keine Erinnerung");
|
||||||
|
|
||||||
|
await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "COMPLETED" } });
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import "./setup";
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test, { after, before, beforeEach } from "node:test";
|
||||||
|
|
||||||
|
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID, COLLECTION_ID } from "./fixtures/tmdb";
|
||||||
|
import { prisma } from "../src/lib/db";
|
||||||
|
import { importCollection, importSeries } from "../src/lib/catalog";
|
||||||
|
import { addToLibrary, markEpisodesWatched, markMovieWatched } from "../src/lib/library";
|
||||||
|
import {
|
||||||
|
addCollection,
|
||||||
|
addTitle,
|
||||||
|
createOrder,
|
||||||
|
createUniverse,
|
||||||
|
getAccess,
|
||||||
|
getUniverse,
|
||||||
|
listUniverses,
|
||||||
|
setOrderPositions,
|
||||||
|
shareUniverse,
|
||||||
|
trashUniverse,
|
||||||
|
} from "../src/lib/universes";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft Universen: Reihenfolgen, Fortschritt aus Sicht des Betrachters,
|
||||||
|
* Teilen und die Rechteprüfung.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stub = installTmdbStub();
|
||||||
|
const OWNER_EMAIL = "test-uni-owner@flimmer.invalid";
|
||||||
|
const GUEST_EMAIL = "test-uni-guest@flimmer.invalid";
|
||||||
|
|
||||||
|
let ownerId: string;
|
||||||
|
let guestId: string;
|
||||||
|
let seriesTitleId: string;
|
||||||
|
let movieTitleId: string;
|
||||||
|
let movie2TitleId: string;
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: { in: [OWNER_EMAIL, GUEST_EMAIL] } } });
|
||||||
|
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
await cleanup();
|
||||||
|
ownerId = (
|
||||||
|
await prisma.user.create({ data: { email: OWNER_EMAIL, name: "Besitzerin" }, select: { id: true } })
|
||||||
|
).id;
|
||||||
|
guestId = (
|
||||||
|
await prisma.user.create({ data: { email: GUEST_EMAIL, name: "Gast" }, select: { id: true } })
|
||||||
|
).id;
|
||||||
|
|
||||||
|
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
|
||||||
|
const { titles } = await importCollection(COLLECTION_ID, "DE");
|
||||||
|
movieTitleId = titles.find((title) => title.tmdbId === MOVIE_ID)!.id;
|
||||||
|
movie2TitleId = titles.find((title) => title.tmdbId === MOVIE_2_ID)!.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.universe.deleteMany({ where: { ownerId } });
|
||||||
|
await prisma.watch.deleteMany({ where: { userId: { in: [ownerId, guestId] } } });
|
||||||
|
await prisma.libraryEntry.deleteMany({ where: { userId: { in: [ownerId, guestId] } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await cleanup();
|
||||||
|
stub.restore();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ein neues Universum bringt die Erscheinungsreihenfolge mit", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Vorführer-Universum", "Zwei Filme, eine Rolle.");
|
||||||
|
|
||||||
|
const view = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.ok(view);
|
||||||
|
assert.equal(view.orders.length, 1);
|
||||||
|
assert.equal(view.orders[0]?.kind, "RELEASE");
|
||||||
|
assert.equal(view.orders[0]?.isDefault, true);
|
||||||
|
assert.equal(view.entries.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Slugs bleiben je Besitzer eindeutig", async () => {
|
||||||
|
const first = await createUniverse(ownerId, "Sternenpfad");
|
||||||
|
const second = await createUniverse(ownerId, "Sternenpfad");
|
||||||
|
|
||||||
|
assert.equal(first.slug, "sternenpfad");
|
||||||
|
assert.equal(second.slug, "sternenpfad-2", "die Dublette wird durchgezählt");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Filme und Serien landen gemeinsam in einem Universum", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Gemischt");
|
||||||
|
await addTitle(ownerId, universe.id, seriesTitleId);
|
||||||
|
await addTitle(ownerId, universe.id, movieTitleId);
|
||||||
|
|
||||||
|
const view = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(view?.entries.length, 2);
|
||||||
|
assert.deepEqual(
|
||||||
|
view?.entries.map((entry) => entry.type).sort(),
|
||||||
|
["MOVIE", "SERIES"],
|
||||||
|
"Serien und Filme stehen gleichberechtigt nebeneinander",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zweimal derselbe Titel legt keinen zweiten Eintrag an.
|
||||||
|
await addTitle(ownerId, universe.id, movieTitleId);
|
||||||
|
const again = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(again?.entries.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("TMDB-Collection lässt sich als Startpunkt übernehmen", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Aus Collection");
|
||||||
|
const added = await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
|
||||||
|
assert.equal(added, 2);
|
||||||
|
const view = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.deepEqual(
|
||||||
|
view?.entries.map((entry) => entry.title),
|
||||||
|
[
|
||||||
|
"Die Stille zwischen den Bildern",
|
||||||
|
"Die Stille zwischen den Bildern II",
|
||||||
|
],
|
||||||
|
"Erscheinungsreihenfolge ergibt sich aus den Daten",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Erneutes Übernehmen fügt nichts doppelt hinzu.
|
||||||
|
assert.equal(await addCollection(ownerId, universe.id, COLLECTION_ID), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("eigene Reihenfolge lässt sich frei sortieren", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Sortierbar");
|
||||||
|
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||||
|
await addTitle(ownerId, universe.id, seriesTitleId);
|
||||||
|
|
||||||
|
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||||
|
const before = await getUniverse(ownerId, universe.id, order.id);
|
||||||
|
assert.equal(before?.entries.length, 3);
|
||||||
|
|
||||||
|
// Umdrehen
|
||||||
|
const reversed = [...before!.entries].reverse().map((entry) => entry.itemId);
|
||||||
|
await setOrderPositions(ownerId, order.id, reversed);
|
||||||
|
|
||||||
|
const after = await getUniverse(ownerId, universe.id, order.id);
|
||||||
|
assert.deepEqual(
|
||||||
|
after?.entries.map((entry) => entry.itemId),
|
||||||
|
reversed,
|
||||||
|
"die eigene Reihenfolge steht so, wie sie gesetzt wurde",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Die Erscheinungsreihenfolge bleibt davon unberührt.
|
||||||
|
const release = await getUniverse(ownerId, universe.id, before!.orders.find((o) => o.kind === "RELEASE")!.id);
|
||||||
|
assert.notDeepEqual(release?.entries.map((entry) => entry.itemId), reversed);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die Erscheinungsreihenfolge lässt sich nicht umsortieren", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Fest");
|
||||||
|
await addTitle(ownerId, universe.id, movieTitleId);
|
||||||
|
const view = await getUniverse(ownerId, universe.id);
|
||||||
|
const releaseOrder = view!.orders.find((order) => order.kind === "RELEASE")!;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => setOrderPositions(ownerId, releaseOrder.id, [view!.entries[0]!.itemId]),
|
||||||
|
/ergibt sich aus den Daten/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fortschritt und Restlaufzeit rechnen über Filme und Serien", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Fortschritt");
|
||||||
|
await addTitle(ownerId, universe.id, movieTitleId);
|
||||||
|
await addTitle(ownerId, universe.id, seriesTitleId);
|
||||||
|
|
||||||
|
const fresh = await getUniverse(ownerId, universe.id);
|
||||||
|
// Film 118 min + Serie 5 Episoden (52+47+48+50+50 = 247 min)
|
||||||
|
assert.equal(fresh?.progress.total, 2);
|
||||||
|
assert.equal(fresh?.progress.completed, 0);
|
||||||
|
assert.equal(fresh?.progress.totalMinutes, 118 + 247);
|
||||||
|
assert.equal(fresh?.progress.remainingMinutes, 118 + 247);
|
||||||
|
// Erscheinungsreihenfolge: die Serie (2023) läuft vor dem Film (2024).
|
||||||
|
assert.equal(fresh?.nextUp?.title, "Nordlicht");
|
||||||
|
|
||||||
|
// Film sehen
|
||||||
|
const movieEntry = await addToLibrary(ownerId, movieTitleId);
|
||||||
|
await markMovieWatched(ownerId, movieEntry.id);
|
||||||
|
|
||||||
|
const afterMovie = await getUniverse(ownerId, universe.id);
|
||||||
|
assert.equal(afterMovie?.progress.completed, 1);
|
||||||
|
assert.equal(afterMovie?.progress.percent, 50);
|
||||||
|
assert.equal(afterMovie?.progress.watchedMinutes, 118);
|
||||||
|
assert.equal(afterMovie?.progress.remainingMinutes, 247);
|
||||||
|
assert.equal(
|
||||||
|
afterMovie?.nextUp?.type,
|
||||||
|
"SERIES",
|
||||||
|
"der Film ist durch, offen bleibt die Serie",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Zwei Episoden der Serie
|
||||||
|
const seriesEntry = await addToLibrary(ownerId, seriesTitleId);
|
||||||
|
const episodes = await prisma.episode.findMany({
|
||||||
|
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
|
||||||
|
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await markEpisodesWatched(ownerId, seriesEntry.id, [episodes[0]!.id, episodes[1]!.id]);
|
||||||
|
|
||||||
|
const afterEpisodes = await getUniverse(ownerId, universe.id);
|
||||||
|
const series = afterEpisodes!.entries.find((entry) => entry.type === "SERIES")!;
|
||||||
|
assert.equal(series.watchedEpisodes, 2);
|
||||||
|
assert.equal(series.totalEpisodes, 5);
|
||||||
|
assert.equal(series.completed, false);
|
||||||
|
assert.ok(
|
||||||
|
series.remainingMinutes < 247 && series.remainingMinutes > 0,
|
||||||
|
"die Restlaufzeit schrumpft anteilig",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("geteilte Universen zeigen jedem den eigenen Fortschritt", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Geteilt");
|
||||||
|
await addTitle(ownerId, universe.id, movieTitleId);
|
||||||
|
|
||||||
|
const entry = await addToLibrary(ownerId, movieTitleId);
|
||||||
|
await markMovieWatched(ownerId, entry.id);
|
||||||
|
|
||||||
|
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
|
||||||
|
|
||||||
|
const ownerView = await getUniverse(ownerId, universe.id);
|
||||||
|
const guestView = await getUniverse(guestId, universe.id);
|
||||||
|
|
||||||
|
assert.equal(ownerView?.progress.completed, 1);
|
||||||
|
assert.equal(guestView?.progress.completed, 0, "der Gast hat den Film noch nicht gesehen");
|
||||||
|
assert.equal(guestView?.canWrite, false, "Leserecht bleibt Leserecht");
|
||||||
|
assert.equal(guestView?.isOwner, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Schreibrecht lässt sich vergeben, Löschen bleibt beim Besitzer", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Gemeinsam");
|
||||||
|
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
|
||||||
|
|
||||||
|
const access = await getAccess(guestId, universe.id);
|
||||||
|
assert.equal(access.canWrite, true);
|
||||||
|
assert.equal(access.isOwner, false);
|
||||||
|
|
||||||
|
// Mit Schreibrecht darf der Gast Titel aufnehmen …
|
||||||
|
await addTitle(guestId, universe.id, movieTitleId);
|
||||||
|
const view = await getUniverse(guestId, universe.id);
|
||||||
|
assert.equal(view?.entries.length, 1);
|
||||||
|
|
||||||
|
// … aber nicht löschen.
|
||||||
|
await assert.rejects(() => trashUniverse(guestId, universe.id), /Nur wer das Universum angelegt hat/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Fremde sehen ein Universum gar nicht", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Privat");
|
||||||
|
const access = await getAccess(guestId, universe.id);
|
||||||
|
|
||||||
|
assert.equal(access.canRead, false);
|
||||||
|
assert.equal(await getUniverse(guestId, universe.id), null);
|
||||||
|
await assert.rejects(() => addTitle(guestId, universe.id, movieTitleId), /gibt es nicht/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("die Übersicht listet eigene und geteilte Universen", async () => {
|
||||||
|
const own = await createUniverse(ownerId, "Meins");
|
||||||
|
await addTitle(ownerId, own.id, movieTitleId);
|
||||||
|
await addTitle(ownerId, own.id, movie2TitleId);
|
||||||
|
await shareUniverse(ownerId, own.id, GUEST_EMAIL, "READ");
|
||||||
|
|
||||||
|
const entry = await addToLibrary(guestId, movieTitleId);
|
||||||
|
await markMovieWatched(guestId, entry.id);
|
||||||
|
|
||||||
|
const guestList = await listUniverses(guestId);
|
||||||
|
assert.equal(guestList.length, 1);
|
||||||
|
assert.equal(guestList[0]?.isOwner, false);
|
||||||
|
assert.equal(guestList[0]?.ownerName, "Besitzerin");
|
||||||
|
assert.equal(guestList[0]?.itemCount, 2);
|
||||||
|
assert.equal(guestList[0]?.completed, 1, "der Gast hat einen der beiden Filme gesehen");
|
||||||
|
assert.equal(guestList[0]?.percent, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gelöschte Universen verschwinden aus der Übersicht", async () => {
|
||||||
|
const universe = await createUniverse(ownerId, "Weg damit");
|
||||||
|
await trashUniverse(ownerId, universe.id);
|
||||||
|
|
||||||
|
assert.equal(await getUniverse(ownerId, universe.id), null);
|
||||||
|
const list = await listUniverses(ownerId);
|
||||||
|
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user