Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2565b600f | ||
|
|
a3c74c157b | ||
|
|
6f5bdcc20c | ||
|
|
f9ce2f7021 | ||
|
|
f613136e8a | ||
|
|
4c3c878980 | ||
|
|
bb6ce4ab35 |
+54
-1
@@ -7,6 +7,58 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
|
## [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
|
## [0.2.0] — 2026-09-01
|
||||||
|
|
||||||
Metadaten: Flimmer spricht mit TMDB.
|
Metadaten: Flimmer spricht mit TMDB.
|
||||||
@@ -80,6 +132,7 @@ 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.2.0...HEAD
|
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.3.0...HEAD
|
||||||
|
[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.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
|
||||||
|
|||||||
@@ -34,11 +34,27 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 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 |
|
| 2 — TMDB-Anbindung | 0.2.0 | 14 min |
|
||||||
|
| 3 — Kern-Tracking | 0.3.0 | 24 min |
|
||||||
|
|
||||||
**Bisher gesamt: 52 min**
|
**Bisher gesamt: 76 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
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "flimmer",
|
"name": "flimmer",
|
||||||
"version": "0.2.0",
|
"version": "0.3.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",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"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 --import tsx --test tests/*.test.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 };
|
||||||
|
}
|
||||||
+466
-12
@@ -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,440 @@ 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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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 +472,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 +486,35 @@ 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();
|
||||||
|
|
||||||
async function main() {
|
console.log("Seed: Katalog mit Postern ...");
|
||||||
console.log("Seed: lege Demo-Konten an ...");
|
const titleIds = new Map<string, string>();
|
||||||
const { admin, friend } = await seedUsers();
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Seed: Bibliothek und Sichtungen ...");
|
||||||
|
await seedLibrary(admin.id, titleIds);
|
||||||
|
|
||||||
|
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,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,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,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,
|
||||||
|
|||||||
+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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -56,6 +56,13 @@ export default async function SearchPage({
|
|||||||
|
|
||||||
<SearchField initialQuery={query} filter={filter} filters={FILTERS} />
|
<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">
|
<div className="mt-8">
|
||||||
{!env.tmdb.isConfigured ? (
|
{!env.tmdb.isConfigured ? (
|
||||||
<Notice tone="warning">
|
<Notice tone="warning">
|
||||||
@@ -107,7 +114,7 @@ async function Results({ query, filter }: { query: string; filter: Filter }) {
|
|||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<li key={`${item.type}-${item.tmdbId}`}>
|
<li key={`${item.type}-${item.tmdbId}`}>
|
||||||
<Link
|
<Link
|
||||||
href={`/titel/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
|
href={`/tmdb/${item.type === "tv" ? "serie" : "film"}/${item.tmdbId}`}
|
||||||
className="fl-poster-card group block focus:outline-none"
|
className="fl-poster-card group block focus:outline-none"
|
||||||
>
|
>
|
||||||
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
<Poster path={item.posterPath} alt={item.title} size="w342" />
|
||||||
|
|||||||
@@ -1,219 +0,0 @@
|
|||||||
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 { TmdbError, TmdbNotConfiguredError } from "@/lib/tmdb";
|
|
||||||
import { AppShell } from "@/components/AppShell";
|
|
||||||
import { Backdrop, Poster } from "@/components/Poster";
|
|
||||||
import { ProviderList, type ProviderBadgeData, type ProviderKindKey } from "@/components/ProviderBadges";
|
|
||||||
import { SeasonList, type SeasonData } from "@/components/SeasonList";
|
|
||||||
import type { TitleType } from "@/generated/prisma/client";
|
|
||||||
|
|
||||||
/** URL-Segment auf den Titeltyp abbilden — /titel/serie/1396 bzw. /titel/film/550. */
|
|
||||||
const ART_TO_TYPE: Record<string, TitleType> = { serie: "SERIES", film: "MOVIE" };
|
|
||||||
|
|
||||||
type Params = Promise<{ art: string; tmdbId: string }>;
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
|
||||||
const { art, tmdbId } = await params;
|
|
||||||
const type = ART_TO_TYPE[art];
|
|
||||||
if (!type) return { title: "Nicht gefunden" };
|
|
||||||
|
|
||||||
const title = await prisma.title.findUnique({
|
|
||||||
where: { type_tmdbId: { type, tmdbId: Number(tmdbId) } },
|
|
||||||
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 { art, tmdbId: rawId } = await params;
|
|
||||||
const type = ART_TO_TYPE[art];
|
|
||||||
const tmdbId = Number.parseInt(rawId, 10);
|
|
||||||
if (!type || !Number.isFinite(tmdbId)) notFound();
|
|
||||||
|
|
||||||
let importError: string | null = null;
|
|
||||||
try {
|
|
||||||
await ensureTitle(type, tmdbId, { region: user.region });
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof TmdbError && error.status === 404) notFound();
|
|
||||||
importError =
|
|
||||||
error instanceof TmdbNotConfiguredError
|
|
||||||
? error.message
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Der Titel konnte nicht von TMDB geladen werden.";
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = await prisma.title.findUnique({
|
|
||||||
where: { type_tmdbId: { type, tmdbId } },
|
|
||||||
include: {
|
|
||||||
seasons: {
|
|
||||||
orderBy: { seasonNumber: "asc" },
|
|
||||||
include: { episodes: { orderBy: { episodeNumber: "asc" } } },
|
|
||||||
},
|
|
||||||
providers: {
|
|
||||||
where: { region: user.region },
|
|
||||||
include: { provider: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
return (
|
|
||||||
<AppShell user={user} instanceName={settings.instanceName}>
|
|
||||||
<div className="fl-card p-6">
|
|
||||||
<h1 className="text-xl font-semibold">Titel nicht verfügbar</h1>
|
|
||||||
<p className="mt-2 text-sm text-[var(--fl-muted)]">
|
|
||||||
{importError ?? "Zu dieser Kennung liegt nichts vor."}
|
|
||||||
</p>
|
|
||||||
<Link href="/suche" className="fl-btn fl-btn-ghost mt-4">
|
|
||||||
Zurück zur Suche
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</AppShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = title.releaseDate?.getFullYear() ?? null;
|
|
||||||
const providerGroups: Partial<Record<ProviderKindKey, ProviderBadgeData[]>> = {};
|
|
||||||
for (const row of title.providers) {
|
|
||||||
const kind = row.kind as ProviderKindKey;
|
|
||||||
(providerGroups[kind] ??= []).push({
|
|
||||||
id: row.provider.id,
|
|
||||||
name: row.provider.name,
|
|
||||||
logoPath: row.provider.logoPath,
|
|
||||||
color: row.provider.color,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppShell user={user} instanceName={settings.instanceName}>
|
|
||||||
<article className="relative">
|
|
||||||
<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">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
|
||||||
{title.type === "SERIES" ? "Serie" : "Film"}
|
|
||||||
{year ? ` · ${year}` : ""}
|
|
||||||
{title.status ? ` · ${title.status}` : ""}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<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">
|
|
||||||
{title.type === "SERIES" ? (
|
|
||||||
<Fact label="Umfang">
|
|
||||||
{title.seasonCount} {title.seasonCount === 1 ? "Staffel" : "Staffeln"} ·{" "}
|
|
||||||
{title.episodeCount} Episoden
|
|
||||||
</Fact>
|
|
||||||
) : null}
|
|
||||||
{title.runtime ? (
|
|
||||||
<Fact label={title.type === "SERIES" ? "Pro Episode" : "Laufzeit"}>
|
|
||||||
{title.runtime} min
|
|
||||||
</Fact>
|
|
||||||
) : null}
|
|
||||||
{title.voteAverage ? (
|
|
||||||
<Fact label="TMDB">{title.voteAverage.toFixed(1)} / 10</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-5 rounded-xl border border-dashed border-[var(--fl-border)] px-3 py-2 text-xs text-[var(--fl-faint)]">
|
|
||||||
Die Bibliothek kommt in der nächsten Etappe — dann lässt sich der Titel von hier
|
|
||||||
aus aufnehmen und der Fortschritt abhaken.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{title.type === "SERIES" && (
|
|
||||||
<section className="mt-6">
|
|
||||||
<h2 className="mb-3 text-lg font-semibold">Staffeln</h2>
|
|
||||||
<SeasonList seasons={seasons} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{importError && (
|
|
||||||
<p className="mt-6 text-xs text-[var(--fl-warning)]">
|
|
||||||
Hinweis: Die Daten stammen aus dem lokalen Katalog — die Auffrischung bei TMDB ist
|
|
||||||
fehlgeschlagen ({importError}).
|
|
||||||
</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,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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { clsx } from "clsx";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Staffel- und Episodenliste.
|
|
||||||
*
|
|
||||||
* Staffeln sind eingeklappt, damit eine Serie mit zwanzig Staffeln die Seite
|
|
||||||
* nicht sprengt; die erste reguläre Staffel steht offen. In Etappe 3 kommen
|
|
||||||
* hier die Häkchen für den Fortschritt dazu.
|
|
||||||
*/
|
|
||||||
|
|
||||||
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[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatDate(value: string | null): string | null {
|
|
||||||
if (!value) return null;
|
|
||||||
return new Date(value).toLocaleDateString("de-DE", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SeasonList({ seasons }: { seasons: SeasonData[] }) {
|
|
||||||
const firstRegular = seasons.find((season) => season.seasonNumber > 0) ?? seasons[0];
|
|
||||||
const [open, setOpen] = useState<string[]>(firstRegular ? [firstRegular.id] : []);
|
|
||||||
|
|
||||||
function toggle(id: string) {
|
|
||||||
setOpen((current) =>
|
|
||||||
current.includes(id) ? current.filter((entry) => entry !== id) : [...current, id],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 air = formatDate(season.airDate);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section key={season.id} className="fl-card overflow-hidden">
|
|
||||||
<h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggle(season.id)}
|
|
||||||
aria-expanded={isOpen}
|
|
||||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--fl-surface-2)]"
|
|
||||||
>
|
|
||||||
<Chevron open={isOpen} />
|
|
||||||
<span className="flex-1">
|
|
||||||
<span className="font-medium">{season.name}</span>
|
|
||||||
<span className="ml-2 text-sm text-[var(--fl-faint)]">
|
|
||||||
{season.episodes.length}{" "}
|
|
||||||
{season.episodes.length === 1 ? "Episode" : "Episoden"}
|
|
||||||
{air ? ` · ab ${air}` : ""}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<ol className="border-t border-[var(--fl-border)]">
|
|
||||||
{season.episodes.map((episode) => (
|
|
||||||
<li
|
|
||||||
key={episode.id}
|
|
||||||
className="flex gap-3 border-b border-[var(--fl-border)] px-4 py-2.5 last:border-b-0"
|
|
||||||
>
|
|
||||||
<span className="w-14 shrink-0 pt-0.5 font-mono text-xs text-[var(--fl-faint)]">
|
|
||||||
S{String(season.seasonNumber).padStart(2, "0")}E
|
|
||||||
{String(episode.episodeNumber).padStart(2, "0")}
|
|
||||||
</span>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block text-sm font-medium">{episode.name}</span>
|
|
||||||
{episode.overview && (
|
|
||||||
<span className="mt-0.5 line-clamp-2 block text-xs text-[var(--fl-faint)]">
|
|
||||||
{episode.overview}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className="shrink-0 pt-0.5 text-right text-xs text-[var(--fl-faint)]">
|
|
||||||
{formatDate(episode.airDate) ?? "—"}
|
|
||||||
{episode.runtime ? <span className="block">{episode.runtime} min</span> : null}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{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 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,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,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,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,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 } });
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user