Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ac598ea81 | ||
|
|
075b42584a | ||
|
|
8c872b240a | ||
|
|
0e51df7bad | ||
|
|
b53ee828ba | ||
|
|
ed6011384a | ||
|
|
5cf4fc48a6 | ||
|
|
5ee6946fd5 | ||
|
|
90497ddc3b | ||
|
|
190d7b3796 |
+85
-1
@@ -7,6 +7,88 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unveröffentlicht]
|
||||
|
||||
## [0.5.0] — 2026-09-01
|
||||
|
||||
Übersicht, Kalender und Statistik.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
|
||||
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
|
||||
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
|
||||
verfügbar geworden sind.
|
||||
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
|
||||
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
|
||||
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
|
||||
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
|
||||
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
|
||||
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
|
||||
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
|
||||
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
|
||||
zählen mit.
|
||||
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
|
||||
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
|
||||
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
|
||||
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
|
||||
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
|
||||
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
|
||||
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
|
||||
Standardton.
|
||||
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
|
||||
„Weiterschauen" und den Kalenderzeitraum.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
|
||||
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
|
||||
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
|
||||
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
|
||||
(„2026-04") aus der Datenbank.
|
||||
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
|
||||
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
|
||||
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
|
||||
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
|
||||
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
|
||||
hängt jetzt als `aria-label` am `<svg>`.
|
||||
|
||||
## [0.4.0] — 2026-09-01
|
||||
|
||||
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
|
||||
Wizarding World oder was auch immer zusammengehört.
|
||||
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
|
||||
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
|
||||
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
|
||||
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
|
||||
Tastatur nicht bedienbar und auf dem Handy fummelig.
|
||||
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
|
||||
vorkommen, lassen sich mit einem Klick übernehmen.
|
||||
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
|
||||
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
|
||||
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
|
||||
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
|
||||
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
|
||||
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
|
||||
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
|
||||
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
|
||||
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
|
||||
Serien hinweg, Freigaben und die Rechteprüfung.
|
||||
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
|
||||
Serien und einer abweichenden Story-Chronologie.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
|
||||
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
|
||||
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
|
||||
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
|
||||
serverseitig.
|
||||
|
||||
## [0.3.0] — 2026-09-01
|
||||
|
||||
Das Kernstück: Bibliothek, Status und Episodenfortschritt.
|
||||
@@ -132,7 +214,9 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
|
||||
dass weitere Sprachen als zusätzliche Datei dazukommen können.
|
||||
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
|
||||
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.3.0...HEAD
|
||||
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.5.0...HEAD
|
||||
[0.5.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.5.0
|
||||
[0.4.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.4.0
|
||||
[0.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
|
||||
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
|
||||
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
|
||||
|
||||
@@ -49,6 +49,32 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
|
||||
---
|
||||
|
||||
## Etappe 4 — Universen mit Reihenfolgen und Fortschritt (v0.4.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 4.1 | 19:25 | 19:30 | 5 min | Domänenlogik: Universen, berechnete Erscheinungsreihenfolge, frei sortierbare Reihenfolgen, Fortschritt aus Sicht des Betrachters, Freigaben |
|
||||
| 4.2 | 19:30 | 19:33 | 3 min | 12 Tests dazu; eine falsche Testannahme zur Reihenfolge korrigiert (nicht den Code) |
|
||||
| 4.3 | 19:33 | 19:36 | 3 min | Server-Aktionen, sortierbare Liste mit Ziehen und Pfeilknöpfen, Reihenfolge-Umschalter, Aufnehmen, Teilen |
|
||||
| 4.4 | 19:36 | 19:39 | 3 min | Seiten für Übersicht, Anlegen und Detailansicht; Build-Fehler durch `server-only` im Browser-Bundle gefunden und behoben; Umsortieren im Browser geprüft |
|
||||
|
||||
**Etappe 4 gesamt: 14 min** (2026-09-01, 19:25 – 19:39)
|
||||
|
||||
---
|
||||
|
||||
## Etappe 5 — Dashboard, Kalender, Statistiken (v0.5.0)
|
||||
|
||||
| # | Start | Ende | Dauer | Arbeitsschritt |
|
||||
|---|---|---|---|---|
|
||||
| 5.1 | 19:39 | 19:43 | 4 min | Auswertungsschicht: Weiterschauen, kommende Episoden, neu verfügbar, Gesamtzahlen, Monatsverlauf, Verteilungen, Jahresrückblick |
|
||||
| 5.2 | 19:43 | 19:46 | 3 min | Diagrammfarben gegen die Prüfungen auf Helligkeit, Sättigung, Farbfehlsichtigkeit und Kontrast gerechnet; SVG-Diagramme und Verteilungsbalken gebaut |
|
||||
| 5.3 | 19:46 | 19:50 | 4 min | Seiten für Übersicht, Kalender und Statistik |
|
||||
| 5.4 | 19:50 | 19:54 | 4 min | Drei Fehler gefunden und behoben: leeres Monatsdiagramm durch Zeitzonenversatz, Hydrationsbruch durch SVG-Titel, Kalender ohne abgeschlossene Serien; 7 Tests nachgezogen |
|
||||
|
||||
**Etappe 5 gesamt: 15 min** (2026-09-01, 19:39 – 19:54)
|
||||
|
||||
---
|
||||
|
||||
## Gesamtübersicht
|
||||
|
||||
| Etappe | Version | Dauer |
|
||||
@@ -56,5 +82,7 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
|
||||
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
|
||||
| 2 — TMDB-Anbindung | 0.2.0 | 14 min |
|
||||
| 3 — Kern-Tracking | 0.3.0 | 24 min |
|
||||
| 4 — Universen | 0.4.0 | 14 min |
|
||||
| 5 — Dashboard, Kalender, Statistik | 0.5.0 | 15 min |
|
||||
|
||||
**Bisher gesamt: 76 min**
|
||||
**Bisher gesamt: 105 min**
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "flimmer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -458,6 +458,73 @@ async function seedLibrary(userId: string, titleIds: Map<string, string>) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Universum
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Die Erscheinungsreihenfolge berechnet Flimmer selbst — hier steht nur die
|
||||
* abweichende Story-Chronologie, damit die Seite zwei echte Reihenfolgen zeigt.
|
||||
*/
|
||||
const CHRONOLOGICAL = [
|
||||
"sternenpfad-null",
|
||||
"sternenpfad-1",
|
||||
"sternenpfad-akademie",
|
||||
"sternenpfad-2",
|
||||
"sternenpfad-serie",
|
||||
"sternenpfad-3",
|
||||
"sternenpfad-4",
|
||||
];
|
||||
|
||||
async function seedUniverse(ownerId: string, titleIds: Map<string, string>) {
|
||||
const existing = await prisma.universe.findUnique({
|
||||
where: { ownerId_slug: { ownerId, slug: "sternenpfad" } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await prisma.universe.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
const universe = await prisma.universe.create({
|
||||
data: {
|
||||
ownerId,
|
||||
name: "Sternenpfad",
|
||||
slug: "sternenpfad",
|
||||
description:
|
||||
"Fünf Filme und zwei Serien. Die Kinoreihenfolge und die Story-Chronologie gehen auseinander — deshalb zwei Reihenfolgen.",
|
||||
orders: {
|
||||
create: [{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true }],
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const itemIdByKey = new Map<string, string>();
|
||||
for (const key of CHRONOLOGICAL) {
|
||||
const titleId = titleIds.get(key);
|
||||
if (!titleId) continue;
|
||||
const item = await prisma.universeItem.create({
|
||||
data: { universeId: universe.id, titleId },
|
||||
select: { id: true },
|
||||
});
|
||||
itemIdByKey.set(key, item.id);
|
||||
}
|
||||
|
||||
const order = await prisma.universeOrder.create({
|
||||
data: { universeId: universe.id, name: "Chronologisch", kind: "CUSTOM" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.universeOrderItem.createMany({
|
||||
data: CHRONOLOGICAL.flatMap((key, index) => {
|
||||
const itemId = itemIdByKey.get(key);
|
||||
return itemId ? [{ orderId: order.id, itemId, position: index }] : [];
|
||||
}),
|
||||
});
|
||||
|
||||
return universe;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function main() {
|
||||
@@ -509,6 +576,15 @@ async function main() {
|
||||
console.log("Seed: Bibliothek und Sichtungen ...");
|
||||
await seedLibrary(admin.id, titleIds);
|
||||
|
||||
console.log("Seed: Universum ...");
|
||||
const universe = await seedUniverse(admin.id, titleIds);
|
||||
// Mit dem zweiten Konto teilen, damit die Freigabe sichtbar ist.
|
||||
await prisma.universeMember.upsert({
|
||||
where: { universeId_userId: { universeId: universe.id, userId: friend.id } },
|
||||
create: { universeId: universe.id, userId: friend.id, permission: "READ" },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const watches = await prisma.watch.count({ where: { userId: admin.id } });
|
||||
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Flimmer 0.4.0
|
||||
|
||||
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
|
||||
Wizarding World oder was auch immer zusammengehört.
|
||||
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
|
||||
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
|
||||
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
|
||||
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
|
||||
Tastatur nicht bedienbar und auf dem Handy fummelig.
|
||||
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
|
||||
vorkommen, lassen sich mit einem Klick übernehmen.
|
||||
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
|
||||
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
|
||||
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
|
||||
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
|
||||
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
|
||||
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
|
||||
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
|
||||
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
|
||||
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
|
||||
Serien hinweg, Freigaben und die Rechteprüfung.
|
||||
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
|
||||
Serien und einer abweichenden Story-Chronologie.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
|
||||
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
|
||||
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
|
||||
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
|
||||
serverseitig.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Flimmer 0.5.0
|
||||
|
||||
Übersicht, Kalender und Statistik.
|
||||
|
||||
### Hinzugefügt
|
||||
|
||||
- **Übersicht** mit „Weiterschauen" (laufende Serien samt nächster Episode,
|
||||
Fortschritt und „+1 Episode" direkt aus der Kachel), den Ausstrahlungen der
|
||||
nächsten vier Wochen und den Watchlist-Titeln, die zuletzt auf einem Dienst
|
||||
verfügbar geworden sind.
|
||||
- **Kalender** als Monatsraster mit Blättern und Sprung zum heutigen Tag,
|
||||
darunter die Termine des Monats als Liste. Ausgezeichnet als Tabelle — ein
|
||||
Kalender *ist* eine Tabelle, und mit Screenreader wird daraus dann
|
||||
„Woche 3, Donnerstag, 24." statt einer ununterscheidbaren Kachelwüste.
|
||||
- **Statistikseite** mit Gesamtschauzeit, Episoden und Filmen je Monat als
|
||||
Diagramm, Verteilung nach Streamingdienst und Genre, Jahresrückblick
|
||||
(„2026 in Zahlen") sowie den Listen „wo die meiste Zeit hinging" und
|
||||
„deine besten Bewertungen". Alles aus den Sichtungen gerechnet, Rewatches
|
||||
zählen mit.
|
||||
- **Diagramme als handgeschriebenes SVG** — ohne Chart-Bibliothek, dafür an
|
||||
den Design-Tokens. Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
Signaturfarben; sie bestehen in **beiden** Farbschemata die Prüfungen auf
|
||||
Helligkeitsband, Sättigung, Farbfehlsichtigkeit und Kontrast. Die vollen
|
||||
Signaturtöne wären auf dunklem Grund zu hell. Legende, Achsenwerte,
|
||||
Tooltip beim Überfahren und eine für Screenreader hinterlegte Tabelle
|
||||
sorgen dafür, dass die Aussage nie allein an der Farbe hängt.
|
||||
- Markenfarben der Streamingdienste in der Verteilung, aber nur, wenn sie auf
|
||||
hellem *und* dunklem Grund mindestens 3:1 erreichen — sonst der geprüfte
|
||||
Standardton.
|
||||
- **7 weitere Tests** für Gesamtzahlen, Monatsgruppierung, Jahresrückblick,
|
||||
„Weiterschauen" und den Kalenderzeitraum.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Das Monatsdiagramm blieb leer, obwohl Sichtungen vorlagen: `date_trunc`
|
||||
liefert Mitternacht in der Zeitzone der Datenbanksitzung, JavaScript legte
|
||||
beim Abgleich die lokale Zone an — zwei Stunden Versatz genügten, damit kein
|
||||
einziger Monatsschlüssel mehr passte. Der Schlüssel kommt jetzt als Text
|
||||
(„2026-04") aus der Datenbank.
|
||||
- Eine laufende Serie, bei der man auf dem Stand ist, gilt als „abgeschlossen"
|
||||
und verschwand damit aus dem Kalender — dabei will man die nächste Folge
|
||||
dort erst recht sehen. Jetzt fällt nur Abgebrochenes heraus.
|
||||
- Der SVG-Titel des Diagramms brach die Hydration: React behandelt `<title>`
|
||||
als Dokument-Metadatum und hebt es aus dem SVG heraus. Die Beschriftung
|
||||
hängt jetzt als `aria-label` am `<svg>`.
|
||||
@@ -0,0 +1,20 @@
|
||||
import { chromium } from "playwright";
|
||||
const [,, SP, ...specs] = process.argv;
|
||||
const b = await chromium.launch();
|
||||
const c = await b.newContext({ viewport: { width: 1280, height: 950 }, deviceScaleFactor: 2, colorScheme: "dark" });
|
||||
const p = await c.newPage();
|
||||
const errs = [];
|
||||
p.on("pageerror", e => errs.push(String(e).slice(0,200)));
|
||||
p.on("console", m => { if (m.type()==="error") errs.push("console: "+m.text().slice(0,160)); });
|
||||
await p.goto("http://localhost:3011/anmelden");
|
||||
await p.fill("#email", "demo@flimmer.local");
|
||||
await p.fill("#password", "flimmer-demo-2026");
|
||||
await Promise.all([p.waitForURL("http://localhost:3011/"), p.click("button[type=submit]")]);
|
||||
for (const spec of specs) {
|
||||
const [name, url, full] = spec.split("|");
|
||||
await p.goto(`http://localhost:3011${url}`, { waitUntil: "networkidle" });
|
||||
await p.waitForTimeout(900);
|
||||
await p.screenshot({ path: `${SP}/${name}.png`, fullPage: full === "full" });
|
||||
}
|
||||
console.log("Fehler:", errs.length ? errs.slice(0,4) : "keine");
|
||||
await b.close();
|
||||
@@ -0,0 +1,155 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { getSessionUser } from "@/lib/auth";
|
||||
import {
|
||||
addCollection,
|
||||
addTitle,
|
||||
createOrder,
|
||||
createUniverse,
|
||||
deleteOrder,
|
||||
removeTitle,
|
||||
renameOrder,
|
||||
renameUniverse,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
unshareUniverse,
|
||||
UniverseError,
|
||||
} from "@/lib/universes";
|
||||
|
||||
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
|
||||
|
||||
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
|
||||
|
||||
async function run(
|
||||
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
|
||||
): Promise<UniverseActionResult> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) return { ok: false, message: "Bitte melde dich an." };
|
||||
|
||||
try {
|
||||
const result = (await fn(user.id)) ?? {};
|
||||
revalidatePath("/universen", "layout");
|
||||
revalidatePath("/", "layout");
|
||||
return { ok: true, message: result.message, id: result.id };
|
||||
} catch (error) {
|
||||
if (error instanceof UniverseError) return { ok: false, message: error.message };
|
||||
console.error("Universums-Aktion fehlgeschlagen:", error);
|
||||
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
|
||||
}
|
||||
}
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const name = z.string().trim().min(1).max(120);
|
||||
|
||||
export async function createUniverseAction(rawName: string, description?: string) {
|
||||
return run(async (userId) => {
|
||||
const universe = await createUniverse(userId, name.parse(rawName), description);
|
||||
return { message: `„${universe.name}" angelegt`, id: universe.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameUniverseAction(
|
||||
universeId: string,
|
||||
data: { name?: string; description?: string | null },
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
await renameUniverse(userId, id.parse(universeId), {
|
||||
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
|
||||
...(data.description !== undefined
|
||||
? { description: z.string().max(2000).nullable().parse(data.description) }
|
||||
: {}),
|
||||
});
|
||||
return { message: "Gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function trashUniverseAction(universeId: string) {
|
||||
return run(async (userId) => {
|
||||
await trashUniverse(userId, id.parse(universeId));
|
||||
return { message: "Universum gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
|
||||
return run(async (userId) => {
|
||||
await addTitle(userId, id.parse(universeId), id.parse(titleId));
|
||||
return { message: "Aufgenommen" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeFromUniverseAction(universeId: string, itemId: string) {
|
||||
return run(async (userId) => {
|
||||
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
|
||||
return { message: "Aus dem Universum entfernt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
|
||||
return run(async (userId) => {
|
||||
const added = await addCollection(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.number().int().parse(tmdbCollectionId),
|
||||
);
|
||||
return {
|
||||
message:
|
||||
added === 0
|
||||
? "Alles daraus war schon drin"
|
||||
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function createOrderAction(universeId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
|
||||
return { message: "Reihenfolge angelegt", id: order.id };
|
||||
});
|
||||
}
|
||||
|
||||
export async function renameOrderAction(orderId: string, rawName: string) {
|
||||
return run(async (userId) => {
|
||||
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
|
||||
return { message: "Umbenannt" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteOrderAction(orderId: string) {
|
||||
return run(async (userId) => {
|
||||
await deleteOrder(userId, id.parse(orderId));
|
||||
return { message: "Reihenfolge gelöscht" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderAction(orderId: string, itemIds: string[]) {
|
||||
return run(async (userId) => {
|
||||
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
|
||||
return { message: "Reihenfolge gespeichert" };
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareUniverseAction(
|
||||
universeId: string,
|
||||
email: string,
|
||||
permission: "READ" | "WRITE",
|
||||
) {
|
||||
return run(async (userId) => {
|
||||
const target = await shareUniverse(
|
||||
userId,
|
||||
id.parse(universeId),
|
||||
z.string().trim().email().parse(email),
|
||||
z.enum(["READ", "WRITE"]).parse(permission),
|
||||
);
|
||||
return { message: `Mit ${target.name} geteilt` };
|
||||
});
|
||||
}
|
||||
|
||||
export async function unshareUniverseAction(universeId: string, memberId: string) {
|
||||
return run(async (userId) => {
|
||||
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
|
||||
return { message: "Freigabe aufgehoben" };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { getUpcomingEpisodes } from "@/lib/stats";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CalendarMonth } from "@/components/CalendarMonth";
|
||||
import { episodeCode, formatDateLong } from "@/lib/labels";
|
||||
|
||||
export const metadata: Metadata = { title: "Kalender" };
|
||||
|
||||
/** Monatsangabe aus der URL („2026-09") oder der laufende Monat. */
|
||||
function parseMonth(value: string | undefined): Date {
|
||||
const match = value?.match(/^(\d{4})-(\d{2})$/);
|
||||
if (match) {
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]) - 1;
|
||||
if (year >= 1900 && year <= 2200 && month >= 0 && month <= 11) {
|
||||
return new Date(year, month, 1);
|
||||
}
|
||||
}
|
||||
const now = new Date();
|
||||
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
}
|
||||
|
||||
function monthParam(date: Date): string {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export default async function CalendarPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ monat?: string }>;
|
||||
}) {
|
||||
const user = await requireUser("/kalender");
|
||||
const settings = await getSettings();
|
||||
const params = await searchParams;
|
||||
|
||||
const month = parseMonth(params.monat);
|
||||
const from = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
// Das Raster reicht in den Vor- und Folgemonat hinein — die Termine auch.
|
||||
const gridFrom = new Date(from.getTime() - 7 * 86_400_000);
|
||||
const gridTo = new Date(from.getFullYear(), from.getMonth() + 1, 7);
|
||||
|
||||
const episodes = await getUpcomingEpisodes(user.id, gridFrom, gridTo);
|
||||
const inMonth = episodes.filter((episode) => episode.airDate.getMonth() === month.getMonth());
|
||||
|
||||
const previous = new Date(month.getFullYear(), month.getMonth() - 1, 1);
|
||||
const next = new Date(month.getFullYear(), month.getMonth() + 1, 1);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Kalender</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Ausstrahlungstermine der Serien in deiner Bibliothek.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-2" aria-label="Monat wechseln">
|
||||
<Link
|
||||
href={`/kalender?monat=${monthParam(previous)}`}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
aria-label={`Zum ${previous.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
|
||||
>
|
||||
←
|
||||
</Link>
|
||||
<span className="min-w-40 text-center font-medium">
|
||||
{month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</span>
|
||||
<Link
|
||||
href={`/kalender?monat=${monthParam(next)}`}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
aria-label={`Zum ${next.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}`}
|
||||
>
|
||||
→
|
||||
</Link>
|
||||
<Link href="/kalender" className="ml-1 text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Heute
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="fl-card overflow-x-auto p-3 sm:p-4">
|
||||
<div className="min-w-[44rem]">
|
||||
<CalendarMonth month={month} episodes={episodes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-6" aria-labelledby="liste">
|
||||
<h2 id="liste" className="mb-3 text-lg font-semibold">
|
||||
Termine im {month.toLocaleDateString("de-DE", { month: "long" })}
|
||||
</h2>
|
||||
|
||||
{inMonth.length === 0 ? (
|
||||
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
In diesem Monat läuft nichts aus deiner Bibliothek.
|
||||
</div>
|
||||
) : (
|
||||
<ol className="fl-card divide-y divide-[var(--fl-border)]">
|
||||
{inMonth.map((episode) => (
|
||||
<li key={episode.episodeId} className="flex flex-wrap items-center gap-x-3 gap-y-1 px-4 py-3">
|
||||
<span className="w-52 shrink-0 text-sm text-[var(--fl-faint)]">
|
||||
{formatDateLong(episode.airDate)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
className="min-w-0 flex-1 text-sm hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
<span className="font-medium">{episode.title}</span>
|
||||
<span className="ml-2 font-mono text-xs text-[var(--fl-faint)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>
|
||||
<span className="ml-2 text-[var(--fl-muted)]">{episode.name}</span>
|
||||
</Link>
|
||||
{episode.runtime && (
|
||||
<span className="shrink-0 text-xs text-[var(--fl-faint)]">{episode.runtime} min</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
+201
-55
@@ -2,82 +2,228 @@ import Link from "next/link";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { env } from "@/lib/env";
|
||||
import {
|
||||
getContinueWatching,
|
||||
getNewlyAvailable,
|
||||
getUpcomingEpisodes,
|
||||
} from "@/lib/stats";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { episodeCode, formatDate, formatRelative, isUpcoming } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { QuickWatch } from "@/components/library/QuickWatch";
|
||||
|
||||
/**
|
||||
* Übersichtsseite.
|
||||
* Übersicht: der Einstieg für „was schaue ich als Nächstes".
|
||||
*
|
||||
* In dieser Etappe steht hier die Begrüßung und der Hinweis auf die nächsten
|
||||
* Schritte; ab Etappe 5 ziehen „Weiterschauen", Kalender und Neuzugänge ein.
|
||||
* Drei Fragen, drei Bereiche — weiterschauen, was demnächst kommt, was auf der
|
||||
* Watchlist verfügbar geworden ist.
|
||||
*/
|
||||
export default async function DashboardPage() {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
|
||||
const now = new Date();
|
||||
const inFourWeeks = new Date(now.getTime() + 28 * 86_400_000);
|
||||
|
||||
const [continueItems, upcoming, available, libraryCount] = await Promise.all([
|
||||
getContinueWatching(user.id, 8),
|
||||
getUpcomingEpisodes(user.id, now, inFourWeeks),
|
||||
getNewlyAvailable(user.id, user.region),
|
||||
prisma.libraryEntry.count({ where: { userId: user.id, deletedAt: null } }),
|
||||
]);
|
||||
|
||||
if (libraryCount === 0) {
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<EmptyStart tmdbReady={env.tmdb.isConfigured} name={user.name} />
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<section className="fl-card relative overflow-hidden p-6 sm:p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-24 -top-32 h-72 w-72 rounded-full opacity-30 blur-3xl"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<FlimmerMark className="h-12 w-12" />
|
||||
<h1 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Hallo {user.name.split(" ")[0]}.
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-[var(--fl-muted)]">
|
||||
Schön, dass du da bist. Sobald du deinen ersten Titel hinzufügst, findest du hier
|
||||
deine laufenden Serien, die nächsten Episoden und was auf deiner Watchlist gerade
|
||||
verfügbar geworden ist.
|
||||
</p>
|
||||
<h1 className="sr-only">Übersicht</h1>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary">
|
||||
Titel suchen und hinzufügen
|
||||
</Link>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-ghost">
|
||||
Zur Bibliothek
|
||||
<section aria-labelledby="weiterschauen">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 id="weiterschauen" className="text-lg font-semibold">
|
||||
Weiterschauen
|
||||
</h2>
|
||||
<Link href="/bibliothek?status=WATCHING" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Alle laufenden
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{continueItems.length === 0 ? (
|
||||
<div className="fl-card p-6 text-center text-sm text-[var(--fl-muted)]">
|
||||
Gerade läuft nichts. Setz eine Serie auf „Schaue gerade", dann steht sie hier.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{continueItems.map((item) => (
|
||||
<li key={item.entryId} className="fl-card flex gap-3 p-3">
|
||||
<Link href={`/titel/${item.titleId}`} className="w-16 shrink-0 sm:w-20">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w185" />
|
||||
</Link>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Link href={`/titel/${item.titleId}`} className="truncate font-medium hover:text-[var(--fl-accent)]">
|
||||
{item.title}
|
||||
</Link>
|
||||
|
||||
{item.next && (
|
||||
<p className="mt-0.5 truncate text-sm">
|
||||
<span className="fl-gradient-text font-mono font-semibold">
|
||||
{episodeCode(item.next.seasonNumber, item.next.episodeNumber)}
|
||||
</span>{" "}
|
||||
<span className="text-[var(--fl-muted)]">{item.next.name}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||
{item.watched} von {item.total} Episoden
|
||||
{item.lastWatchedAt ? ` · zuletzt ${formatRelative(item.lastWatchedAt)}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
percent={item.percent}
|
||||
label={`${item.watched} von ${item.total} Episoden gesehen`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5">
|
||||
{item.next && isUpcoming(item.next.airDate) ? (
|
||||
<p className="text-xs text-[var(--fl-faint)]">
|
||||
Nächste Episode erst am {formatDate(item.next.airDate)}
|
||||
</p>
|
||||
) : (
|
||||
<QuickWatch entryId={item.entryId} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="mt-8 grid gap-6 lg:grid-cols-2">
|
||||
<section aria-labelledby="demnaechst">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h2 id="demnaechst" className="text-lg font-semibold">
|
||||
Demnächst
|
||||
</h2>
|
||||
<Link href="/kalender" className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]">
|
||||
Zum Kalender
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!env.tmdb.isConfigured && (
|
||||
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
|
||||
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine
|
||||
Metadaten — trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte
|
||||
die Instanz neu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div className="fl-card p-4">
|
||||
{upcoming.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
In den nächsten vier Wochen ist keine Episode deiner Serien angekündigt.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="space-y-2.5">
|
||||
{upcoming.slice(0, 6).map((episode) => (
|
||||
<li key={episode.episodeId} className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs text-[var(--fl-faint)]">
|
||||
{formatDate(episode.airDate)}
|
||||
</span>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
className="min-w-0 flex-1 truncate text-sm hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
<span className="font-medium">{episode.title}</span>
|
||||
<span className="ml-1.5 font-mono text-xs text-[var(--fl-faint)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<PlaceholderCard
|
||||
title="Weiterschauen"
|
||||
text="Deine laufenden Serien mit der jeweils nächsten Episode."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Demnächst"
|
||||
text="Kalender der Episoden, die in den nächsten Wochen ausgestrahlt werden."
|
||||
/>
|
||||
<PlaceholderCard
|
||||
title="Neu verfügbar"
|
||||
text="Watchlist-Titel, die auf einem deiner Dienste aufgetaucht sind."
|
||||
/>
|
||||
<section aria-labelledby="verfuegbar">
|
||||
<h2 id="verfuegbar" className="mb-3 text-lg font-semibold">
|
||||
Neu verfügbar
|
||||
</h2>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
{available.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-muted)]">
|
||||
Auf deiner Watchlist ist zuletzt nichts neu dazugekommen. Sobald ein Titel auf
|
||||
einem Dienst in {user.region} auftaucht, steht er hier.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{available.slice(0, 5).map((item) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</Link>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/titel/${item.titleId}`}
|
||||
className="block truncate text-sm font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<p className="truncate text-xs text-[var(--fl-faint)]">
|
||||
{item.providers.map((provider) => provider.name).join(", ")} ·{" "}
|
||||
{formatRelative(item.since)}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderCard({ title, text }: { title: string; text: string }) {
|
||||
function EmptyStart({ name, tmdbReady }: { name: string; tmdbReady: boolean }) {
|
||||
return (
|
||||
<div className="fl-card p-5">
|
||||
<h2 className="text-sm font-semibold tracking-wide text-[var(--fl-muted)] uppercase">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-[var(--fl-faint)]">{text}</p>
|
||||
</div>
|
||||
<section className="fl-card relative overflow-hidden p-6 sm:p-10">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-24 -top-32 h-72 w-72 rounded-full opacity-30 blur-3xl"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
/>
|
||||
<div className="relative">
|
||||
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||
Hallo {name.split(" ")[0]}.
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-[var(--fl-muted)]">
|
||||
Deine Bibliothek ist noch leer. Such dir eine Serie oder einen Film — danach findest du
|
||||
hier, was als Nächstes dran ist, welche Episoden demnächst kommen und was auf deiner
|
||||
Watchlist verfügbar geworden ist.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link href="/suche" className="fl-btn fl-btn-primary">
|
||||
Titel suchen
|
||||
</Link>
|
||||
<Link href="/titel/neu" className="fl-btn fl-btn-ghost">
|
||||
Titel selbst anlegen
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{!tmdbReady && (
|
||||
<p className="mt-6 max-w-xl rounded-xl border border-[color-mix(in_oklab,var(--fl-warning)_40%,transparent)] bg-[color-mix(in_oklab,var(--fl-warning)_10%,transparent)] px-3 py-2 text-sm text-[var(--fl-warning)]">
|
||||
Es ist noch kein TMDB-Schlüssel hinterlegt. Ohne ihn findet die Suche keine Metadaten —
|
||||
trage <code>TMDB_API_KEY</code> in der <code>.env</code> ein und starte die Instanz neu.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import {
|
||||
getActiveYears,
|
||||
getGenreDistribution,
|
||||
getMonthlyActivity,
|
||||
getMostWatched,
|
||||
getProviderDistribution,
|
||||
getTopRated,
|
||||
getTotals,
|
||||
getYearReview,
|
||||
} from "@/lib/stats";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { DistributionBars, MonthlyChart } from "@/components/stats/Charts";
|
||||
|
||||
export const metadata: Metadata = { title: "Statistik" };
|
||||
|
||||
export default async function StatsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ jahr?: string }>;
|
||||
}) {
|
||||
const user = await requireUser("/statistik");
|
||||
const settings = await getSettings();
|
||||
const params = await searchParams;
|
||||
|
||||
const years = await getActiveYears(user.id);
|
||||
const requested = Number.parseInt(params.jahr ?? "", 10);
|
||||
const year = years.includes(requested) ? requested : (years[0] ?? new Date().getFullYear());
|
||||
|
||||
const [totals, monthly, providers, genres, topRated, mostWatched, review] = await Promise.all([
|
||||
getTotals(user.id),
|
||||
getMonthlyActivity(user.id, 12),
|
||||
getProviderDistribution(user.id),
|
||||
getGenreDistribution(user.id),
|
||||
getTopRated(user.id, 5),
|
||||
getMostWatched(user.id, 5),
|
||||
getYearReview(user.id, year),
|
||||
]);
|
||||
|
||||
if (totals.minutes === 0 && totals.episodes === 0 && totals.movies === 0) {
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
||||
</header>
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
|
||||
Noch keine Sichtungen. Sobald du die erste Episode abhakst, entstehen hier
|
||||
Schauzeit, Verteilungen und der Jahresrückblick.
|
||||
</p>
|
||||
<Link href="/bibliothek" className="fl-btn fl-btn-primary mt-4">
|
||||
Zur Bibliothek
|
||||
</Link>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const days = totals.minutes / (60 * 24);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Statistik</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Alles gerechnet aus deinen Sichtungen — Rewatches zählen mit.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="fl-card mb-6 p-5 sm:p-7" aria-label="Gesamtschauzeit">
|
||||
<p className="text-sm font-medium text-[var(--fl-muted)]">Gesamte Schauzeit</p>
|
||||
<p className="fl-gradient-text mt-1 text-5xl font-semibold tabular-nums sm:text-6xl">
|
||||
{Math.round(totals.minutes / 60).toLocaleString("de-DE")} h
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{formatMinutes(totals.minutes)} · das sind {days.toFixed(1).replace(".", ",")} Tage am Stück
|
||||
</p>
|
||||
|
||||
<dl className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<Stat label="Episoden" value={totals.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={totals.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={totals.titlesCompleted.toLocaleString("de-DE")} />
|
||||
<Stat
|
||||
label="In der Bibliothek"
|
||||
value={`${totals.seriesCount} Serien · ${totals.movieCount} Filme`}
|
||||
/>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="fl-card mb-6 p-5" aria-labelledby="verlauf">
|
||||
<h2 id="verlauf" className="mb-1 text-lg font-semibold">
|
||||
Die letzten zwölf Monate
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Abgehakte Episoden und Filme je Monat.
|
||||
</p>
|
||||
<MonthlyChart points={monthly.map(({ label, episodes, movies, minutes }) => ({ label, episodes, movies, minutes }))} />
|
||||
</section>
|
||||
|
||||
<div className="mb-6 grid gap-6 lg:grid-cols-2">
|
||||
<section className="fl-card p-5" aria-labelledby="dienste">
|
||||
<h2 id="dienste" className="mb-1 text-lg font-semibold">
|
||||
Nach Streamingdienst
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Wo du tatsächlich geschaut hast — aus dem Feld „wo schaue ich das".
|
||||
</p>
|
||||
<DistributionBars
|
||||
rows={providers.slice(0, 8)}
|
||||
emptyText="Noch keine Sichtung einem Dienst zugeordnet."
|
||||
useBrandColors
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="fl-card p-5" aria-labelledby="genres">
|
||||
<h2 id="genres" className="mb-1 text-lg font-semibold">
|
||||
Nach Genre
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-[var(--fl-muted)]">
|
||||
Titel mit mehreren Genres zählen in jedem mit.
|
||||
</p>
|
||||
<DistributionBars rows={genres.slice(0, 8)} emptyText="Noch keine Genres erfasst." />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="fl-card mb-6 p-5" aria-labelledby="rueckblick">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 id="rueckblick" className="text-lg font-semibold">
|
||||
{review.year} in Zahlen
|
||||
</h2>
|
||||
{years.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Jahr wählen">
|
||||
{years.slice(0, 6).map((entry) => (
|
||||
<Link
|
||||
key={entry}
|
||||
href={`/statistik?jahr=${entry}`}
|
||||
aria-current={entry === review.year ? "true" : undefined}
|
||||
className="rounded-full border px-3 py-1 text-sm font-medium transition-colors"
|
||||
style={
|
||||
entry === review.year
|
||||
? {
|
||||
backgroundImage: "var(--fl-gradient)",
|
||||
color: "var(--fl-on-accent)",
|
||||
borderColor: "transparent",
|
||||
}
|
||||
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
|
||||
}
|
||||
>
|
||||
{entry}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<Stat label="Schauzeit" value={formatMinutes(review.minutes)} />
|
||||
<Stat label="Episoden" value={review.episodes.toLocaleString("de-DE")} />
|
||||
<Stat label="Filme" value={review.movies.toLocaleString("de-DE")} />
|
||||
<Stat label="Neu angefangen" value={review.titlesStarted.toLocaleString("de-DE")} />
|
||||
<Stat label="Abgeschlossen" value={review.titlesCompleted.toLocaleString("de-DE")} />
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||||
{review.busiestMonth && (
|
||||
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
||||
<span className="text-[var(--fl-faint)]">Stärkster Monat: </span>
|
||||
<span className="font-medium">{review.busiestMonth.label}</span>
|
||||
<span className="text-[var(--fl-muted)]">
|
||||
{" "}
|
||||
mit {formatMinutes(review.busiestMonth.minutes)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{review.topTitle && (
|
||||
<p className="rounded-xl border border-[var(--fl-border)] p-3 text-sm">
|
||||
<span className="text-[var(--fl-faint)]">Die meiste Zeit ging an: </span>
|
||||
<Link href={`/titel/${review.topTitle.titleId}`} className="font-medium hover:text-[var(--fl-accent)]">
|
||||
{review.topTitle.title}
|
||||
</Link>
|
||||
<span className="text-[var(--fl-muted)]"> ({formatMinutes(review.topTitle.minutes)})</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<TitleList
|
||||
id="meistzeit"
|
||||
heading="Wo die meiste Zeit hinging"
|
||||
items={mostWatched}
|
||||
renderMeta={(item) => formatMinutes(item.minutes)}
|
||||
emptyText="Noch nichts gesehen."
|
||||
/>
|
||||
<TitleList
|
||||
id="bestbewertet"
|
||||
heading="Deine besten Bewertungen"
|
||||
items={topRated}
|
||||
renderMeta={(item) => (item.rating ? `${item.rating}/10` : "—")}
|
||||
emptyText="Noch nichts bewertet."
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs uppercase tracking-wide text-[var(--fl-faint)]">{label}</dt>
|
||||
<dd className="mt-0.5 text-lg font-semibold tabular-nums">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TitleList({
|
||||
id,
|
||||
heading,
|
||||
items,
|
||||
renderMeta,
|
||||
emptyText,
|
||||
}: {
|
||||
id: string;
|
||||
heading: string;
|
||||
items: { titleId: string; title: string; posterPath: string | null; rating: number | null; minutes: number; type: string }[];
|
||||
renderMeta: (item: { rating: number | null; minutes: number }) => string;
|
||||
emptyText: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="fl-card p-5" aria-labelledby={id}>
|
||||
<h2 id={id} className="mb-4 text-lg font-semibold">
|
||||
{heading}
|
||||
</h2>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">{emptyText}</p>
|
||||
) : (
|
||||
<ol className="space-y-3">
|
||||
{items.map((item, index) => (
|
||||
<li key={item.titleId} className="flex items-center gap-3">
|
||||
<span className="w-4 shrink-0 text-right font-mono text-sm text-[var(--fl-faint)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
<Link href={`/titel/${item.titleId}`} className="w-10 shrink-0">
|
||||
<Poster path={item.posterPath} alt={item.title} size="w154" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/titel/${item.titleId}`}
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
<span className="shrink-0 text-sm tabular-nums text-[var(--fl-muted)]">
|
||||
{renderMeta(item)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getUniverse, suggestCollections } from "@/lib/universes";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { OrderedList } from "@/components/universes/OrderedList";
|
||||
import {
|
||||
AddTitles,
|
||||
DeleteUniverse,
|
||||
OrderTabs,
|
||||
SharePanel,
|
||||
} from "@/components/universes/UniverseTools";
|
||||
|
||||
type Params = Promise<{ id: string }>;
|
||||
type Search = Promise<{ reihenfolge?: string }>;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const universe = await prisma.universe.findUnique({ where: { id }, select: { name: true } });
|
||||
return { title: universe?.name ?? "Universum" };
|
||||
}
|
||||
|
||||
export default async function UniversePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Params;
|
||||
searchParams: Search;
|
||||
}) {
|
||||
const user = await requireUser();
|
||||
const settings = await getSettings();
|
||||
const { id } = await params;
|
||||
const { reihenfolge } = await searchParams;
|
||||
|
||||
const universe = await getUniverse(user.id, id, reihenfolge);
|
||||
if (!universe) notFound();
|
||||
|
||||
const activeOrder = universe.orders.find((order) => order.id === universe.activeOrderId);
|
||||
const inUniverse = new Set(universe.entries.map((entry) => entry.titleId));
|
||||
|
||||
// Vorschläge zum Aufnehmen: alles aus der eigenen Bibliothek, was noch fehlt.
|
||||
const [libraryTitles, collections] = universe.canWrite
|
||||
? await Promise.all([
|
||||
prisma.libraryEntry.findMany({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
orderBy: { title: { title: "asc" } },
|
||||
select: {
|
||||
title: { select: { id: true, title: true, type: true, releaseDate: true } },
|
||||
},
|
||||
}),
|
||||
suggestCollections(user.id),
|
||||
])
|
||||
: [[], []];
|
||||
|
||||
const candidates = libraryTitles
|
||||
.map((entry) => entry.title)
|
||||
.filter((title) => !inUniverse.has(title.id))
|
||||
.map((title) => ({
|
||||
id: title.id,
|
||||
title: title.title,
|
||||
type: title.type,
|
||||
year: title.releaseDate?.getFullYear() ?? null,
|
||||
}));
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-5">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Universum
|
||||
{!universe.isOwner && ` · geteilt von ${universe.ownerName}`}
|
||||
{!universe.canWrite && " · nur ansehen"}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{universe.name}</h1>
|
||||
{universe.description && (
|
||||
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">{universe.description}</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<section className="fl-card mb-5 p-5" aria-label="Gesamtfortschritt">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-semibold">
|
||||
{universe.progress.completed}{" "}
|
||||
<span className="text-base font-normal text-[var(--fl-muted)]">
|
||||
von {universe.progress.total} abgeschlossen
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
{formatMinutes(universe.progress.watchedMinutes)} gesehen
|
||||
{universe.progress.remainingMinutes > 0 && (
|
||||
<>
|
||||
{" · noch "}
|
||||
<span className="text-[var(--fl-text)]">
|
||||
{formatMinutes(universe.progress.remainingMinutes)}
|
||||
</span>
|
||||
{" bis zum Ende des Universums"}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{universe.nextUp && (
|
||||
<div className="text-right">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Als Nächstes dran
|
||||
</p>
|
||||
<Link
|
||||
href={`/titel/${universe.nextUp.titleId}`}
|
||||
className="fl-gradient-text text-lg font-semibold"
|
||||
>
|
||||
{universe.nextUp.title}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<ProgressBar
|
||||
percent={universe.progress.percent}
|
||||
label={`${universe.progress.percent} Prozent des Universums abgeschlossen`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="mb-4">
|
||||
<OrderTabs universe={universe} canWrite={universe.canWrite} />
|
||||
{activeOrder?.kind === "RELEASE" ? (
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell.
|
||||
</p>
|
||||
) : universe.canWrite ? (
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
|
||||
<div className="order-2 lg:order-1">
|
||||
<OrderedList
|
||||
universeId={universe.id}
|
||||
orderId={universe.activeOrderId}
|
||||
entries={universe.entries}
|
||||
sortable={activeOrder?.kind === "CUSTOM"}
|
||||
canWrite={universe.canWrite}
|
||||
nextUpItemId={universe.nextUp?.itemId ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<aside className="order-1 space-y-5 lg:order-2">
|
||||
{universe.canWrite && (
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Titel aufnehmen
|
||||
</h2>
|
||||
<AddTitles
|
||||
universeId={universe.id}
|
||||
candidates={candidates}
|
||||
collections={collections}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{universe.isOwner && (
|
||||
<>
|
||||
<div className="fl-card p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
|
||||
Teilen
|
||||
</h2>
|
||||
<SharePanel universe={universe} />
|
||||
</div>
|
||||
|
||||
<div className="fl-card p-4">
|
||||
<DeleteUniverse universeId={universe.id} name={universe.name} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<p className="mt-8 text-sm">
|
||||
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
||||
← Alle Universen
|
||||
</Link>
|
||||
</p>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { NewUniverseForm } from "@/components/universes/UniverseTools";
|
||||
|
||||
export const metadata: Metadata = { title: "Neues Universum" };
|
||||
|
||||
export default async function NewUniversePage() {
|
||||
const user = await requireUser("/universen/neu");
|
||||
const settings = await getSettings();
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<div className="mx-auto max-w-xl">
|
||||
<header className="mb-6">
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Neues Universum</h1>
|
||||
<p className="mt-1 text-sm text-[var(--fl-muted)]">
|
||||
Ein Universum bündelt Filme und Serien, die zusammengehören. Die
|
||||
Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du
|
||||
danach an.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="fl-card p-5 sm:p-6">
|
||||
<NewUniverseForm />
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm">
|
||||
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
|
||||
← Zurück zur Übersicht
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { requireUser } from "@/lib/auth";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
import { listUniverses } from "@/lib/universes";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
|
||||
export const metadata: Metadata = { title: "Universen" };
|
||||
|
||||
export default async function UniversesPage() {
|
||||
const user = await requireUser("/universen");
|
||||
const settings = await getSettings();
|
||||
const universes = await listUniverses(user.id);
|
||||
|
||||
return (
|
||||
<AppShell user={user} instanceName={settings.instanceName}>
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Universen</h1>
|
||||
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">
|
||||
Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem
|
||||
Gesamtfortschritt.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/universen/neu" className="fl-btn fl-btn-primary">
|
||||
Neues Universum
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{universes.length === 0 ? (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
|
||||
Noch kein Universum angelegt. Ein Universum bündelt zusammengehörige Titel — etwa alle
|
||||
MCU-Filme samt Serien — und zeigt dir, was als Nächstes dran ist.
|
||||
</p>
|
||||
<Link href="/universen/neu" className="fl-btn fl-btn-primary mt-4">
|
||||
Erstes Universum anlegen
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{universes.map((universe) => (
|
||||
<li key={universe.id}>
|
||||
<Link
|
||||
href={`/universen/${universe.id}`}
|
||||
className="fl-card fl-poster-card block overflow-hidden p-4"
|
||||
>
|
||||
<div className="mb-3 flex gap-1.5">
|
||||
{universe.posters.length > 0 ? (
|
||||
universe.posters.map((path, index) => (
|
||||
<div key={`${path}-${index}`} className="w-1/5">
|
||||
<Poster path={path} alt="" size="w154" />
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="w-1/5">
|
||||
<Poster path={null} alt={universe.name} size="w154" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 className="font-semibold">{universe.name}</h2>
|
||||
{universe.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-[var(--fl-muted)]">
|
||||
{universe.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-xs text-[var(--fl-faint)]">
|
||||
{universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"}
|
||||
{!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
percent={universe.percent}
|
||||
label={`${universe.completed} von ${universe.itemCount} abgeschlossen`}
|
||||
showValue
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
import { episodeCode } from "@/lib/labels";
|
||||
import type { UpcomingEpisode } from "@/lib/stats";
|
||||
|
||||
/**
|
||||
* Monatsraster mit den Ausstrahlungsterminen.
|
||||
*
|
||||
* Bewusst als Tabelle ausgezeichnet: ein Kalender *ist* eine Tabelle, und mit
|
||||
* Screenreader wird daraus dann „Woche 3, Donnerstag, 14." statt einer
|
||||
* ununterscheidbaren Kachelwüste.
|
||||
*/
|
||||
|
||||
const WEEKDAYS = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
||||
|
||||
function startOfGrid(month: Date): Date {
|
||||
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
||||
// Montag als Wochenanfang: Sonntag (0) wird zu 6.
|
||||
const weekday = (first.getDay() + 6) % 7;
|
||||
return new Date(first.getFullYear(), first.getMonth(), 1 - weekday);
|
||||
}
|
||||
|
||||
function isSameDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export function CalendarMonth({
|
||||
month,
|
||||
episodes,
|
||||
}: {
|
||||
month: Date;
|
||||
episodes: UpcomingEpisode[];
|
||||
}) {
|
||||
const today = new Date();
|
||||
const start = startOfGrid(month);
|
||||
|
||||
// Sechs Wochen zeigen immer den ganzen Monat, egal auf welchen Tag er fällt.
|
||||
const days = Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(start);
|
||||
date.setDate(start.getDate() + index);
|
||||
return date;
|
||||
});
|
||||
|
||||
const byDay = new Map<string, UpcomingEpisode[]>();
|
||||
for (const episode of episodes) {
|
||||
const key = episode.airDate.toDateString();
|
||||
const list = byDay.get(key);
|
||||
if (list) list.push(episode);
|
||||
else byDay.set(key, [episode]);
|
||||
}
|
||||
|
||||
const weeks = Array.from({ length: 6 }, (_, index) => days.slice(index * 7, index * 7 + 7));
|
||||
|
||||
return (
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<caption className="sr-only">
|
||||
Ausstrahlungstermine im {month.toLocaleDateString("de-DE", { month: "long", year: "numeric" })}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{WEEKDAYS.map((day) => (
|
||||
<th
|
||||
key={day}
|
||||
scope="col"
|
||||
className="pb-2 text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]"
|
||||
>
|
||||
{day}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{weeks.map((week, weekIndex) => (
|
||||
<tr key={weekIndex}>
|
||||
{week.map((date) => {
|
||||
const inMonth = date.getMonth() === month.getMonth();
|
||||
const isToday = isSameDay(date, today);
|
||||
const list = byDay.get(date.toDateString()) ?? [];
|
||||
|
||||
return (
|
||||
<td
|
||||
key={date.toISOString()}
|
||||
className={clsx(
|
||||
"h-24 border border-[var(--fl-border)] p-1 align-top sm:h-28",
|
||||
!inMonth && "opacity-40",
|
||||
)}
|
||||
style={
|
||||
list.length > 0
|
||||
? { backgroundColor: "color-mix(in oklab, var(--fl-magenta) 7%, transparent)" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
"mb-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1 text-xs tabular-nums",
|
||||
isToday
|
||||
? "font-semibold text-[var(--fl-on-accent)]"
|
||||
: "text-[var(--fl-faint)]",
|
||||
)}
|
||||
style={isToday ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{date.getDate()}
|
||||
</span>
|
||||
|
||||
<ul className="space-y-0.5">
|
||||
{list.slice(0, 3).map((episode) => (
|
||||
<li key={episode.episodeId}>
|
||||
<Link
|
||||
href={`/titel/${episode.titleId}`}
|
||||
title={`${episode.title} — ${episodeCode(episode.seasonNumber, episode.episodeNumber)} ${episode.name}`}
|
||||
className="block truncate rounded px-1 py-0.5 text-[0.7rem] leading-tight text-[var(--fl-text)] transition-colors hover:bg-[var(--fl-surface-2)]"
|
||||
>
|
||||
<span className="font-mono text-[var(--fl-accent)]">
|
||||
{episodeCode(episode.seasonNumber, episode.episodeNumber)}
|
||||
</span>{" "}
|
||||
{episode.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{list.length > 3 && (
|
||||
<li className="px-1 text-[0.7rem] text-[var(--fl-faint)]">
|
||||
+ {list.length - 3} weitere
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { clsx } from "clsx";
|
||||
import { posterUrl, type ImageSize } from "@/lib/images";
|
||||
import { posterUrl, type ImageSize } from "@/lib/image-url";
|
||||
import { FlimmerMark } from "@/components/brand/FlimmerLogo";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { posterUrl } from "@/lib/images";
|
||||
import { posterUrl } from "@/lib/image-url";
|
||||
|
||||
/**
|
||||
* Streamingdienste als Badges.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { undoAction, watchNextAction } from "@/app/actions/library";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
|
||||
/**
|
||||
* „+1 Episode" für die Übersicht — derselbe Griff wie auf der Detailseite,
|
||||
* nur ohne Kontext drumherum.
|
||||
*/
|
||||
export function QuickWatch({ entryId }: { entryId: string }) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
async function undo(watchIds: string[]) {
|
||||
await undoAction(entryId, watchIds);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
const result = await watchNextAction(entryId);
|
||||
report(result, (ids) => undo(ids));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
|
||||
>
|
||||
+1 Episode
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { formatMinutes } from "@/lib/labels";
|
||||
|
||||
/**
|
||||
* Diagramme der Statistikseite.
|
||||
*
|
||||
* Handgeschriebenes SVG statt einer Chart-Bibliothek: die Diagramme sind
|
||||
* schlicht, sollen sich an die Design-Tokens halten und in beiden Farbschemata
|
||||
* funktionieren — dafür lohnt keine Abhängigkeit mit eigenem Versionsrhythmus.
|
||||
*
|
||||
* Farbwahl: Die beiden Serienfarben sind abgedunkelte Varianten der
|
||||
* Signaturfarben. Sie erfüllen in **beiden** Modi die Prüfungen auf
|
||||
* Helligkeitsband, Farbsättigung, Farbfehlsichtigkeit (schlechtestes Paar
|
||||
* ΔE 12,1 unter Deuteranopie) und Kontrast zum Untergrund. Die vollen
|
||||
* Signaturtöne wären auf dunklem Grund zu hell und fielen durch.
|
||||
*/
|
||||
|
||||
export const SERIES_COLORS = {
|
||||
episodes: "#c47a18",
|
||||
movies: "#d92d78",
|
||||
} as const;
|
||||
|
||||
/** Ein Ton für Balken, die nur eine Größe zeigen (keine Identität). */
|
||||
const SINGLE_HUE = "#c47a18";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Säulen je Monat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type MonthlyPointData = {
|
||||
label: string;
|
||||
episodes: number;
|
||||
movies: number;
|
||||
minutes: number;
|
||||
};
|
||||
|
||||
/** Rundet die Achsenspitze auf einen glatten Wert auf. */
|
||||
function niceMax(value: number): number {
|
||||
if (value <= 0) return 4;
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||
for (const step of [1, 2, 2.5, 5, 10]) {
|
||||
const candidate = step * magnitude;
|
||||
if (candidate >= value) return candidate;
|
||||
}
|
||||
return 10 * magnitude;
|
||||
}
|
||||
|
||||
export function MonthlyChart({ points }: { points: MonthlyPointData[] }) {
|
||||
const [active, setActive] = useState<number | null>(null);
|
||||
|
||||
const max = niceMax(Math.max(1, ...points.map((point) => point.episodes + point.movies)));
|
||||
const ticks = [0, max / 2, max];
|
||||
|
||||
// Breites Seitenverhältnis: das SVG füllt die Karte und bleibt trotzdem flach.
|
||||
const width = 1100;
|
||||
const height = 260;
|
||||
const padding = { top: 12, right: 8, bottom: 28, left: 38 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
|
||||
const band = plotWidth / Math.max(1, points.length);
|
||||
const barWidth = Math.min(24, band - 10);
|
||||
const scale = (value: number) => (value / max) * plotHeight;
|
||||
|
||||
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
|
||||
if (total === 0) {
|
||||
return (
|
||||
<p className="py-8 text-center text-sm text-[var(--fl-faint)]">
|
||||
In den letzten zwölf Monaten wurde noch nichts abgehakt.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<figure className="m-0">
|
||||
<Legend
|
||||
items={[
|
||||
{ label: "Episoden", color: SERIES_COLORS.episodes },
|
||||
{ label: "Filme", color: SERIES_COLORS.movies },
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="relative mt-2 overflow-x-auto">
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="h-auto w-full min-w-[32rem]"
|
||||
role="img"
|
||||
// Bewusst aria-label statt eines <title>-Kindes: React behandelt
|
||||
// <title> als Dokument-Metadatum und hebt es aus dem SVG heraus —
|
||||
// das brach die Hydration.
|
||||
aria-label={`Gesehene Episoden und Filme je Monat in den letzten ${points.length} Monaten`}
|
||||
onMouseLeave={() => setActive(null)}
|
||||
>
|
||||
{ticks.map((tick) => {
|
||||
const y = padding.top + plotHeight - scale(tick);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="var(--fl-border)"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 8}
|
||||
y={y + 4}
|
||||
textAnchor="end"
|
||||
className="fill-[var(--fl-faint)] text-[11px] tabular-nums"
|
||||
>
|
||||
{Math.round(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{points.map((point, index) => {
|
||||
const x = padding.left + index * band + (band - barWidth) / 2;
|
||||
const episodeHeight = scale(point.episodes);
|
||||
const movieHeight = scale(point.movies);
|
||||
const baseline = padding.top + plotHeight;
|
||||
const isActive = active === index;
|
||||
|
||||
return (
|
||||
<g
|
||||
key={point.label + index}
|
||||
onMouseEnter={() => setActive(index)}
|
||||
onFocus={() => setActive(index)}
|
||||
tabIndex={0}
|
||||
className="outline-none"
|
||||
>
|
||||
{/* Großzügige Trefferfläche — der Balken selbst ist schmal. */}
|
||||
<rect
|
||||
x={padding.left + index * band}
|
||||
y={padding.top}
|
||||
width={band}
|
||||
height={plotHeight}
|
||||
fill={isActive ? "var(--fl-surface-2)" : "transparent"}
|
||||
opacity={isActive ? 0.6 : 0}
|
||||
/>
|
||||
|
||||
{point.episodes > 0 && (
|
||||
<rect
|
||||
x={x}
|
||||
y={baseline - episodeHeight}
|
||||
width={barWidth}
|
||||
height={episodeHeight}
|
||||
rx={point.movies > 0 ? 0 : 4}
|
||||
fill={SERIES_COLORS.episodes}
|
||||
/>
|
||||
)}
|
||||
{point.movies > 0 && (
|
||||
<rect
|
||||
x={x}
|
||||
// 2 px Fuge in Untergrundfarbe zwischen den Segmenten
|
||||
y={baseline - episodeHeight - movieHeight - (point.episodes > 0 ? 2 : 0)}
|
||||
width={barWidth}
|
||||
height={movieHeight}
|
||||
rx={4}
|
||||
fill={SERIES_COLORS.movies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<text
|
||||
x={padding.left + index * band + band / 2}
|
||||
y={height - 8}
|
||||
textAnchor="middle"
|
||||
className={clsx(
|
||||
"text-[11px]",
|
||||
isActive ? "fill-[var(--fl-text)]" : "fill-[var(--fl-faint)]",
|
||||
)}
|
||||
>
|
||||
{point.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{active !== null && points[active] && (
|
||||
<Tooltip
|
||||
title={points[active].label}
|
||||
rows={[
|
||||
{ label: "Episoden", value: String(points[active].episodes), color: SERIES_COLORS.episodes },
|
||||
{ label: "Filme", value: String(points[active].movies), color: SERIES_COLORS.movies },
|
||||
{ label: "Schauzeit", value: formatMinutes(points[active].minutes) },
|
||||
]}
|
||||
position={active / Math.max(1, points.length - 1)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<figcaption className="sr-only">
|
||||
<table>
|
||||
<caption>Gesehene Episoden und Filme je Monat</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Monat</th>
|
||||
<th scope="col">Episoden</th>
|
||||
<th scope="col">Filme</th>
|
||||
<th scope="col">Schauzeit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{points.map((point, index) => (
|
||||
<tr key={point.label + index}>
|
||||
<th scope="row">{point.label}</th>
|
||||
<td>{point.episodes}</td>
|
||||
<td>{point.movies}</td>
|
||||
<td>{formatMinutes(point.minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
title,
|
||||
rows,
|
||||
position,
|
||||
}: {
|
||||
title: string;
|
||||
rows: { label: string; value: string; color?: string }[];
|
||||
position: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className="pointer-events-none absolute top-0 z-10 min-w-[9rem] -translate-x-1/2 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-solid)] px-3 py-2 text-xs shadow-[var(--fl-shadow)]"
|
||||
style={{ left: `${12 + position * 76}%` }}
|
||||
>
|
||||
<p className="mb-1 font-semibold">{title}</p>
|
||||
{rows.map((row) => (
|
||||
<p key={row.label} className="flex items-center gap-1.5 text-[var(--fl-muted)]">
|
||||
{row.color && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: row.color }}
|
||||
/>
|
||||
)}
|
||||
<span className="flex-1">{row.label}</span>
|
||||
<span className="font-medium tabular-nums text-[var(--fl-text)]">{row.value}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ items }: { items: { label: string; color: string }[] }) {
|
||||
return (
|
||||
<ul className="flex flex-wrap gap-4 text-xs text-[var(--fl-muted)]">
|
||||
{items.map((item) => (
|
||||
<li key={item.label} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Liegende Balken für Verteilungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type DistributionRow = {
|
||||
label: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
/** Markenfarbe eines Dienstes; wird nur genutzt, wenn sie kontrastreich genug ist. */
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
/** Relative Leuchtdichte nach WCAG — für die Kontrastprüfung der Markenfarben. */
|
||||
function luminance(hex: string): number | null {
|
||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||
if (!match?.[1]) return null;
|
||||
const value = Number.parseInt(match[1], 16);
|
||||
const channels = [(value >> 16) & 255, (value >> 8) & 255, value & 255].map((raw) => {
|
||||
const c = raw / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
|
||||
}
|
||||
|
||||
function contrast(hex: string, surfaceLuminance: number): number | null {
|
||||
const value = luminance(hex);
|
||||
if (value === null) return null;
|
||||
const lighter = Math.max(value, surfaceLuminance);
|
||||
const darker = Math.min(value, surfaceLuminance);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/** Leuchtdichte der beiden Untergründe (--fl-bg in dunkel und hell). */
|
||||
const SURFACE_DARK = 0.0055;
|
||||
const SURFACE_LIGHT = 0.9;
|
||||
|
||||
/**
|
||||
* Eine Markenfarbe wird nur benutzt, wenn sie auf **beiden** Untergründen
|
||||
* mindestens 3:1 erreicht. So bleibt die Wahl unabhängig vom eingestellten
|
||||
* Farbschema — sonst müsste die Komponente den Theme-Wechsel mitverfolgen und
|
||||
* liefe Gefahr, beim ersten Rendern etwas anderes zu zeigen als der Server.
|
||||
*/
|
||||
function safeBrandColor(hex: string | null | undefined): string | null {
|
||||
if (!hex) return null;
|
||||
const dark = contrast(hex, SURFACE_DARK);
|
||||
const light = contrast(hex, SURFACE_LIGHT);
|
||||
if (dark === null || light === null) return null;
|
||||
return dark >= 3 && light >= 3 ? hex : null;
|
||||
}
|
||||
|
||||
export function DistributionBars({
|
||||
rows,
|
||||
emptyText,
|
||||
useBrandColors = false,
|
||||
}: {
|
||||
rows: DistributionRow[];
|
||||
emptyText: string;
|
||||
/**
|
||||
* Streamingdienste tragen ihre Markenfarbe — das ist ihre Identität und
|
||||
* zieht sich durchs ganze UI. Zu dunkle oder zu blasse Marken fallen
|
||||
* automatisch auf den geprüften Standardton zurück, sonst verschwände der
|
||||
* Balken je nach Farbschema.
|
||||
*/
|
||||
useBrandColors?: boolean;
|
||||
}) {
|
||||
if (rows.length === 0) {
|
||||
return <p className="py-6 text-center text-sm text-[var(--fl-faint)]">{emptyText}</p>;
|
||||
}
|
||||
|
||||
const max = Math.max(...rows.map((row) => row.minutes), 1);
|
||||
|
||||
return (
|
||||
<ul className="space-y-2.5">
|
||||
{rows.map((row) => {
|
||||
const brand = useBrandColors ? safeBrandColor(row.color) : null;
|
||||
const color = brand ?? SINGLE_HUE;
|
||||
|
||||
return (
|
||||
<li key={row.label}>
|
||||
<div className="mb-1 flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate text-[var(--fl-text)]">{row.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-[var(--fl-muted)]">
|
||||
{formatMinutes(row.minutes)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2.5 w-full overflow-hidden rounded-full bg-[var(--fl-surface-2)]">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-500"
|
||||
style={{
|
||||
width: `${Math.max(2, (row.minutes / max) * 100)}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { clsx } from "clsx";
|
||||
import { reorderAction, removeFromUniverseAction } from "@/app/actions/universes";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import { Poster } from "@/components/Poster";
|
||||
import { ProgressBar } from "@/components/ProgressBar";
|
||||
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
|
||||
import type { UniverseEntry } from "@/lib/universes";
|
||||
|
||||
/**
|
||||
* Nummerierte Liste eines Universums in der gewählten Reihenfolge.
|
||||
*
|
||||
* Umsortieren geht per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop
|
||||
* wäre mit Tastatur nicht bedienbar und auf dem Handy fummelig; die Knöpfe
|
||||
* sind der verlässliche Weg, das Ziehen der bequeme.
|
||||
*
|
||||
* Die Erscheinungsreihenfolge (`sortable = false`) ergibt sich aus den Daten
|
||||
* und lässt sich deshalb gar nicht erst anfassen.
|
||||
*/
|
||||
export function OrderedList({
|
||||
universeId,
|
||||
orderId,
|
||||
entries,
|
||||
sortable,
|
||||
canWrite,
|
||||
nextUpItemId,
|
||||
}: {
|
||||
universeId: string;
|
||||
orderId: string;
|
||||
entries: UniverseEntry[];
|
||||
sortable: boolean;
|
||||
canWrite: boolean;
|
||||
nextUpItemId: string | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [items, setItems] = useState(entries);
|
||||
const [dragging, setDragging] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
useEffect(() => setItems(entries), [entries]);
|
||||
|
||||
const interactive = sortable && canWrite;
|
||||
|
||||
function persist(next: UniverseEntry[]) {
|
||||
setItems(next);
|
||||
startTransition(async () => {
|
||||
const result = await reorderAction(
|
||||
orderId,
|
||||
next.map((entry) => entry.itemId),
|
||||
);
|
||||
if (!result.ok) {
|
||||
report(result);
|
||||
setItems(entries);
|
||||
}
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const target = index + delta;
|
||||
if (target < 0 || target >= items.length) return;
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(index, 1);
|
||||
if (moved) next.splice(target, 0, moved);
|
||||
persist(next);
|
||||
}
|
||||
|
||||
function dropOn(targetItemId: string) {
|
||||
if (!dragging || dragging === targetItemId) return;
|
||||
const from = items.findIndex((entry) => entry.itemId === dragging);
|
||||
const to = items.findIndex((entry) => entry.itemId === targetItemId);
|
||||
if (from < 0 || to < 0) return;
|
||||
|
||||
const next = [...items];
|
||||
const [moved] = next.splice(from, 1);
|
||||
if (moved) next.splice(to, 0, moved);
|
||||
setDragging(null);
|
||||
persist(next);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="fl-card p-8 text-center">
|
||||
<p className="text-[var(--fl-muted)]">
|
||||
Dieses Universum ist noch leer. Nimm Filme und Serien aus deiner Bibliothek auf.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="space-y-2">
|
||||
{items.map((entry, index) => (
|
||||
<li
|
||||
key={entry.itemId}
|
||||
draggable={interactive}
|
||||
onDragStart={() => setDragging(entry.itemId)}
|
||||
onDragEnd={() => setDragging(null)}
|
||||
onDragOver={(event) => {
|
||||
if (interactive) event.preventDefault();
|
||||
}}
|
||||
onDrop={() => dropOn(entry.itemId)}
|
||||
className={clsx(
|
||||
"fl-card flex items-center gap-3 p-2.5 transition-opacity sm:gap-4 sm:p-3",
|
||||
dragging === entry.itemId && "opacity-40",
|
||||
entry.itemId === nextUpItemId && "ring-1",
|
||||
)}
|
||||
style={
|
||||
entry.itemId === nextUpItemId
|
||||
? {
|
||||
boxShadow: "var(--fl-glow)",
|
||||
borderColor: "color-mix(in oklab, var(--fl-magenta) 45%, transparent)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={clsx(
|
||||
"w-7 shrink-0 text-center font-mono text-sm",
|
||||
entry.completed ? "text-[var(--fl-success)]" : "text-[var(--fl-faint)]",
|
||||
)}
|
||||
>
|
||||
{entry.completed ? "✓" : index + 1}
|
||||
</span>
|
||||
|
||||
<Link href={`/titel/${entry.titleId}`} className="w-12 shrink-0 sm:w-14">
|
||||
<Poster path={entry.posterPath} alt={entry.title} size="w154" />
|
||||
</Link>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/titel/${entry.titleId}`}
|
||||
className="font-medium hover:text-[var(--fl-accent)]"
|
||||
>
|
||||
{entry.title}
|
||||
</Link>
|
||||
{entry.itemId === nextUpItemId && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
|
||||
style={{ backgroundImage: "var(--fl-gradient)" }}
|
||||
>
|
||||
Als Nächstes dran
|
||||
</span>
|
||||
)}
|
||||
{entry.status && (
|
||||
<span
|
||||
className="text-[0.65rem] font-medium"
|
||||
style={{ color: STATUS_COLOR[entry.status as StatusKey] }}
|
||||
>
|
||||
{STATUS_LABEL[entry.status as StatusKey]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||
{entry.type === "SERIES" ? "Serie" : "Film"}
|
||||
{entry.year ? ` · ${entry.year}` : ""}
|
||||
{entry.type === "SERIES"
|
||||
? ` · ${entry.watchedEpisodes}/${entry.totalEpisodes} Episoden`
|
||||
: ""}
|
||||
{entry.remainingMinutes > 0 ? ` · noch ${formatMinutes(entry.remainingMinutes)}` : ""}
|
||||
</p>
|
||||
|
||||
{entry.type === "SERIES" && entry.totalEpisodes > 0 && (
|
||||
<div className="mt-1.5 max-w-xs">
|
||||
<ProgressBar
|
||||
percent={(entry.watchedEpisodes / entry.totalEpisodes) * 100}
|
||||
label={`${entry.watchedEpisodes} von ${entry.totalEpisodes} Episoden gesehen`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{interactive && (
|
||||
<>
|
||||
<IconButton
|
||||
label={`${entry.title} nach oben`}
|
||||
disabled={index === 0}
|
||||
onClick={() => move(index, -1)}
|
||||
>
|
||||
<path d="m6 15 6-6 6 6" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label={`${entry.title} nach unten`}
|
||||
disabled={index === items.length - 1}
|
||||
onClick={() => move(index, 1)}
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<RemoveButton universeId={universeId} itemId={entry.itemId} title={entry.title} />
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-lg p-1.5 text-[var(--fl-faint)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)] disabled:opacity-30 disabled:hover:bg-transparent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RemoveButton({
|
||||
universeId,
|
||||
itemId,
|
||||
title,
|
||||
}: {
|
||||
universeId: string;
|
||||
itemId: string;
|
||||
title: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
label={`${title} aus dem Universum entfernen`}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await removeFromUniverseAction(universeId, itemId));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
>
|
||||
<path d="m6 6 12 12M18 6 6 18" />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
addCollectionAction,
|
||||
addTitleToUniverseAction,
|
||||
createOrderAction,
|
||||
createUniverseAction,
|
||||
deleteOrderAction,
|
||||
shareUniverseAction,
|
||||
trashUniverseAction,
|
||||
unshareUniverseAction,
|
||||
} from "@/app/actions/universes";
|
||||
import { useToasts } from "@/components/Toasts";
|
||||
import type { UniverseView } from "@/lib/universes";
|
||||
|
||||
/** Formular für ein neues Universum. */
|
||||
export function NewUniverseForm() {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("name") ?? "").trim();
|
||||
if (!name) return;
|
||||
startTransition(async () => {
|
||||
const result = await createUniverseAction(
|
||||
name,
|
||||
String(formData.get("description") ?? ""),
|
||||
);
|
||||
report(result);
|
||||
if (result.ok && result.id) router.push(`/universen/${result.id}`);
|
||||
});
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
required
|
||||
autoFocus
|
||||
placeholder="MCU, Star Wars, Wizarding World …"
|
||||
className="fl-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="description" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Beschreibung <span className="text-xs text-[var(--fl-faint)]">(optional)</span>
|
||||
</label>
|
||||
<textarea id="description" name="description" rows={3} className="fl-input resize-y" />
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
|
||||
{pending ? "Einen Moment …" : "Universum anlegen"}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Umschalter zwischen den Reihenfolgen eines Universums. */
|
||||
export function OrderTabs({
|
||||
universe,
|
||||
canWrite,
|
||||
}: {
|
||||
universe: UniverseView;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const { report } = useToasts();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
function select(orderId: string) {
|
||||
const next = new URLSearchParams(params.toString());
|
||||
next.set("reihenfolge", orderId);
|
||||
router.replace(`/universen/${universe.id}?${next}`, { scroll: false });
|
||||
}
|
||||
|
||||
const active = universe.orders.find((order) => order.id === universe.activeOrderId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Reihenfolge">
|
||||
{universe.orders.map((order) => {
|
||||
const isActive = order.id === universe.activeOrderId;
|
||||
return (
|
||||
<button
|
||||
key={order.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => select(order.id)}
|
||||
className={clsx(
|
||||
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "border-transparent text-[var(--fl-on-accent)]"
|
||||
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
|
||||
)}
|
||||
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
|
||||
>
|
||||
{order.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<>
|
||||
{adding ? (
|
||||
<form
|
||||
action={(formData) => {
|
||||
const name = String(formData.get("orderName") ?? "").trim();
|
||||
if (!name) return;
|
||||
setAdding(false);
|
||||
startTransition(async () => {
|
||||
const result = await createOrderAction(universe.id, name);
|
||||
report(result);
|
||||
if (result.ok && result.id) select(result.id);
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<input
|
||||
name="orderName"
|
||||
autoFocus
|
||||
placeholder="z. B. Chronologisch"
|
||||
className="fl-input w-48 py-1.5 text-sm"
|
||||
/>
|
||||
<button type="submit" className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
|
||||
Anlegen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(false)}
|
||||
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAdding(true)}
|
||||
className="rounded-full border border-dashed border-[var(--fl-border-strong)] px-3.5 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:text-[var(--fl-text)]"
|
||||
>
|
||||
+ Reihenfolge
|
||||
</button>
|
||||
)}
|
||||
|
||||
{active && active.kind === "CUSTOM" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await deleteOrderAction(active.id));
|
||||
router.replace(`/universen/${universe.id}`);
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
„{active.name}" löschen
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Titel aus der eigenen Bibliothek aufnehmen — mit Tippfilter. */
|
||||
export function AddTitles({
|
||||
universeId,
|
||||
candidates,
|
||||
collections,
|
||||
}: {
|
||||
universeId: string;
|
||||
candidates: { id: string; title: string; type: string; year: number | null }[];
|
||||
collections: { id: number; name: string }[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [query, setQuery] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const filtered = query.trim()
|
||||
? candidates
|
||||
.filter((item) => item.title.toLocaleLowerCase("de-DE").includes(query.toLocaleLowerCase("de-DE")))
|
||||
.slice(0, 8)
|
||||
: candidates.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{collections.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
|
||||
Aus einer TMDB-Reihe übernehmen
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{collections.map((collection) => (
|
||||
<button
|
||||
key={collection.id}
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await addCollectionAction(universeId, collection.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="rounded-full border border-[var(--fl-border)] px-3 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
{collection.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
|
||||
Aus der Bibliothek aufnehmen
|
||||
</h3>
|
||||
{candidates.length === 0 ? (
|
||||
<p className="text-sm text-[var(--fl-faint)]">
|
||||
Alles aus deiner Bibliothek ist schon in diesem Universum.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Titel filtern …"
|
||||
aria-label="Titel filtern"
|
||||
className="fl-input mb-2"
|
||||
/>
|
||||
<ul className="space-y-1">
|
||||
{filtered.map((item) => (
|
||||
<li key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await addTitleToUniverseAction(universeId, item.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--fl-surface-2)]"
|
||||
>
|
||||
<span className="min-w-0 truncate">{item.title}</span>
|
||||
<span className="ml-3 shrink-0 text-xs text-[var(--fl-faint)]">
|
||||
{item.type === "SERIES" ? "Serie" : "Film"}
|
||||
{item.year ? ` · ${item.year}` : ""}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<li className="px-3 py-2 text-sm text-[var(--fl-faint)]">Kein Treffer.</li>
|
||||
)}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Freigabe an andere Konten der Instanz. */
|
||||
export function SharePanel({ universe }: { universe: UniverseView }) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [permission, setPermission] = useState<"READ" | "WRITE">("READ");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<form
|
||||
action={(formData) => {
|
||||
const email = String(formData.get("email") ?? "").trim();
|
||||
if (!email) return;
|
||||
startTransition(async () => {
|
||||
report(await shareUniverseAction(universe.id, email, permission));
|
||||
router.refresh();
|
||||
});
|
||||
}}
|
||||
className="space-y-2"
|
||||
>
|
||||
<label htmlFor="share-email" className="block text-sm font-medium text-[var(--fl-muted)]">
|
||||
Mit jemandem auf dieser Instanz teilen
|
||||
</label>
|
||||
<input
|
||||
id="share-email"
|
||||
name="email"
|
||||
type="email"
|
||||
placeholder="person@beispiel.de"
|
||||
className="fl-input"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={permission}
|
||||
onChange={(event) => setPermission(event.target.value as "READ" | "WRITE")}
|
||||
aria-label="Recht"
|
||||
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm"
|
||||
>
|
||||
<option value="READ">Nur ansehen</option>
|
||||
<option value="WRITE">Gemeinsam pflegen</option>
|
||||
</select>
|
||||
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
|
||||
Teilen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{universe.members.length > 0 && (
|
||||
<ul className="space-y-1 border-t border-[var(--fl-border)] pt-3">
|
||||
{universe.members.map((member) => (
|
||||
<li key={member.id} className="flex items-center gap-2 text-sm">
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{member.name}
|
||||
<span className="ml-1.5 text-xs text-[var(--fl-faint)]">
|
||||
{member.permission === "WRITE" ? "pflegt mit" : "liest mit"}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await unshareUniverseAction(universe.id, member.id));
|
||||
router.refresh();
|
||||
})
|
||||
}
|
||||
className="text-xs text-[var(--fl-faint)] underline-offset-2 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
entfernen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Universum löschen — mit Rückfrage. */
|
||||
export function DeleteUniverse({ universeId, name }: { universeId: string; name: string }) {
|
||||
const router = useRouter();
|
||||
const { report } = useToasts();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
if (!confirming) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(true)}
|
||||
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
|
||||
>
|
||||
Universum löschen
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-[var(--fl-muted)]">„{name}" wirklich löschen?</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() =>
|
||||
startTransition(async () => {
|
||||
report(await trashUniverseAction(universeId));
|
||||
router.push("/universen");
|
||||
})
|
||||
}
|
||||
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
|
||||
>
|
||||
Ja, löschen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirming(false)}
|
||||
className="text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Bild-URLs und ihre Prüfung.
|
||||
*
|
||||
* Bewusst **ohne** `server-only`: Posterkacheln werden auch aus
|
||||
* Client-Komponenten heraus gerendert (etwa in der sortierbaren Liste eines
|
||||
* Universums). Alles, was tatsächlich den Server braucht — Abruf bei TMDB und
|
||||
* der Dateicache — liegt in `images.ts`.
|
||||
*/
|
||||
|
||||
/** Zugelassene Größen — verhindert, dass über den Parameter beliebige URLs entstehen. */
|
||||
export const IMAGE_SIZES = [
|
||||
"w92",
|
||||
"w154",
|
||||
"w185",
|
||||
"w300",
|
||||
"w342",
|
||||
"w500",
|
||||
"w780",
|
||||
"w1280",
|
||||
"original",
|
||||
] as const;
|
||||
|
||||
export type ImageSize = (typeof IMAGE_SIZES)[number];
|
||||
|
||||
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
|
||||
|
||||
export function isValidImagePath(path: string): boolean {
|
||||
return PATH_PATTERN.test(path);
|
||||
}
|
||||
|
||||
export function isValidSize(size: string): size is ImageSize {
|
||||
return (IMAGE_SIZES as readonly string[]).includes(size);
|
||||
}
|
||||
|
||||
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
|
||||
export function posterUrl(
|
||||
path: string | null | undefined,
|
||||
size: ImageSize = "w342",
|
||||
): string | null {
|
||||
if (!path || !isValidImagePath(path)) return null;
|
||||
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
+6
-30
@@ -4,6 +4,7 @@ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { env } from "./env";
|
||||
import { IMAGE_BASE } from "./tmdb";
|
||||
import type { ImageSize } from "./image-url";
|
||||
|
||||
/**
|
||||
* Lokaler Bild-Zwischenspeicher.
|
||||
@@ -12,32 +13,13 @@ import { IMAGE_BASE } from "./tmdb";
|
||||
* eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch
|
||||
* ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf
|
||||
* einen Abruf bei einem Dritten auslöst.
|
||||
*
|
||||
* Die URL-Bildung selbst steht in `image-url.ts` — die brauchen auch
|
||||
* Client-Komponenten, und sie darf deshalb nicht hinter `server-only` liegen.
|
||||
*/
|
||||
|
||||
/** Zugelassene Größen — verhindert, dass jemand über den Parameter beliebige URLs baut. */
|
||||
export const IMAGE_SIZES = [
|
||||
"w92",
|
||||
"w154",
|
||||
"w185",
|
||||
"w300",
|
||||
"w342",
|
||||
"w500",
|
||||
"w780",
|
||||
"w1280",
|
||||
"original",
|
||||
] as const;
|
||||
|
||||
export type ImageSize = (typeof IMAGE_SIZES)[number];
|
||||
|
||||
const PATH_PATTERN = /^\/[A-Za-z0-9]{8,64}\.(jpg|jpeg|png|svg|webp)$/;
|
||||
|
||||
export function isValidImagePath(path: string): boolean {
|
||||
return PATH_PATTERN.test(path);
|
||||
}
|
||||
|
||||
export function isValidSize(size: string): size is ImageSize {
|
||||
return (IMAGE_SIZES as readonly string[]).includes(size);
|
||||
}
|
||||
export { IMAGE_SIZES, isValidImagePath, isValidSize, posterUrl } from "./image-url";
|
||||
export type { ImageSize } from "./image-url";
|
||||
|
||||
function cacheDir() {
|
||||
return join(env.dataDir, "images");
|
||||
@@ -99,9 +81,3 @@ export async function getCachedImage(size: ImageSize, path: string): Promise<Cac
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** URL des eigenen Bild-Endpunkts — überall im UI statt der TMDB-Adresse benutzen. */
|
||||
export function posterUrl(path: string | null | undefined, size: ImageSize = "w342"): string | null {
|
||||
if (!path || !isValidImagePath(path)) return null;
|
||||
return `/api/image?size=${size}&path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import "server-only";
|
||||
import { prisma } from "./db";
|
||||
import { SPECIALS_SEASON } from "./catalog";
|
||||
|
||||
/**
|
||||
* Auswertungen für Übersicht, Kalender und Statistik.
|
||||
*
|
||||
* Alles wird aus den Sichtungen (`Watch`) berechnet — es gibt keine
|
||||
* fortgeschriebenen Zähler, die auseinanderlaufen könnten. Wo es auf Tempo
|
||||
* ankommt, laufen die Abfragen als SQL, weil Prisma für Zeitreihen keine
|
||||
* Gruppierung nach Monat kennt.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Übersicht
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ContinueItem = {
|
||||
entryId: string;
|
||||
titleId: string;
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
watched: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
lastWatchedAt: Date | null;
|
||||
next: {
|
||||
id: string;
|
||||
name: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
airDate: Date | null;
|
||||
runtime: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* „Weiterschauen": laufende Serien, zuletzt angefasste zuerst.
|
||||
* Serien ohne offene Episode fallen heraus — die sind durch.
|
||||
*/
|
||||
export async function getContinueWatching(userId: string, limit = 12): Promise<ContinueItem[]> {
|
||||
const entries = await prisma.libraryEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
status: { in: ["WATCHING", "PAUSED"] },
|
||||
title: { type: "SERIES" },
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: limit * 2,
|
||||
select: {
|
||||
id: true,
|
||||
titleId: true,
|
||||
currentRun: true,
|
||||
updatedAt: true,
|
||||
title: { select: { title: true, posterPath: true, episodeCount: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (entries.length === 0) return [];
|
||||
|
||||
const items: ContinueItem[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const [watched, next, lastWatch] = await Promise.all([
|
||||
prisma.watch.count({
|
||||
where: {
|
||||
userId,
|
||||
entryId: entry.id,
|
||||
run: entry.currentRun,
|
||||
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
|
||||
},
|
||||
}),
|
||||
prisma.episode.findFirst({
|
||||
where: {
|
||||
titleId: entry.titleId,
|
||||
seasonNumber: { gt: SPECIALS_SEASON },
|
||||
watches: { none: { userId, run: entry.currentRun } },
|
||||
},
|
||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
seasonNumber: true,
|
||||
episodeNumber: true,
|
||||
airDate: true,
|
||||
runtime: true,
|
||||
},
|
||||
}),
|
||||
prisma.watch.findFirst({
|
||||
where: { userId, entryId: entry.id, run: entry.currentRun },
|
||||
orderBy: { watchedAt: "desc" },
|
||||
select: { watchedAt: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!next) continue;
|
||||
|
||||
const total = entry.title.episodeCount;
|
||||
items.push({
|
||||
entryId: entry.id,
|
||||
titleId: entry.titleId,
|
||||
title: entry.title.title,
|
||||
posterPath: entry.title.posterPath,
|
||||
watched,
|
||||
total,
|
||||
percent: total > 0 ? Math.round((watched / total) * 100) : 0,
|
||||
lastWatchedAt: lastWatch?.watchedAt ?? null,
|
||||
next,
|
||||
});
|
||||
|
||||
if (items.length >= limit) break;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export type UpcomingEpisode = {
|
||||
episodeId: string;
|
||||
titleId: string;
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
name: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
airDate: Date;
|
||||
runtime: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Kommende Episoden getrackter Serien.
|
||||
*
|
||||
* „Getrackt" heißt: in der Bibliothek und nicht abgebrochen. Abgeschlossene
|
||||
* Serien bleiben bewusst drin — wer bei einer laufenden Serie auf dem Stand
|
||||
* ist, gilt als „abgeschlossen" und will die nächste Folge erst recht im
|
||||
* Kalender sehen. Nur Abgebrochenes fällt heraus.
|
||||
*/
|
||||
export async function getUpcomingEpisodes(
|
||||
userId: string,
|
||||
from: Date,
|
||||
to: Date,
|
||||
): Promise<UpcomingEpisode[]> {
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: {
|
||||
airDate: { gte: from, lte: to },
|
||||
seasonNumber: { gt: SPECIALS_SEASON },
|
||||
title: {
|
||||
entries: {
|
||||
some: { userId, deletedAt: null, status: { not: "DROPPED" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ airDate: "asc" }, { seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
seasonNumber: true,
|
||||
episodeNumber: true,
|
||||
airDate: true,
|
||||
runtime: true,
|
||||
title: { select: { id: true, title: true, posterPath: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return episodes.flatMap((episode) =>
|
||||
episode.airDate
|
||||
? [
|
||||
{
|
||||
episodeId: episode.id,
|
||||
titleId: episode.title.id,
|
||||
title: episode.title.title,
|
||||
posterPath: episode.title.posterPath,
|
||||
name: episode.name,
|
||||
seasonNumber: episode.seasonNumber,
|
||||
episodeNumber: episode.episodeNumber,
|
||||
airDate: episode.airDate,
|
||||
runtime: episode.runtime,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
export type AvailableItem = {
|
||||
titleId: string;
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
providers: { name: string; color: string | null }[];
|
||||
since: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Watchlist-Titel, die seit Kurzem auf einem Dienst verfügbar sind.
|
||||
* Grundlage ist `firstSeenAt` der Verfügbarkeit — das steht seit dem ersten
|
||||
* Abgleich fest und wandert nicht bei jedem Sync weiter.
|
||||
*/
|
||||
export async function getNewlyAvailable(
|
||||
userId: string,
|
||||
region: string,
|
||||
sinceDays = 30,
|
||||
): Promise<AvailableItem[]> {
|
||||
const since = new Date(Date.now() - sinceDays * 86_400_000);
|
||||
|
||||
const rows = await prisma.titleProvider.findMany({
|
||||
where: {
|
||||
region,
|
||||
kind: { in: ["FLATRATE", "FREE", "ADS"] },
|
||||
firstSeenAt: { gte: since },
|
||||
title: { entries: { some: { userId, deletedAt: null, status: "WATCHLIST" } } },
|
||||
},
|
||||
orderBy: { firstSeenAt: "desc" },
|
||||
select: {
|
||||
firstSeenAt: true,
|
||||
provider: { select: { name: true, color: true } },
|
||||
title: { select: { id: true, title: true, posterPath: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byTitle = new Map<string, AvailableItem>();
|
||||
for (const row of rows) {
|
||||
const existing = byTitle.get(row.title.id);
|
||||
if (existing) {
|
||||
if (!existing.providers.some((provider) => provider.name === row.provider.name)) {
|
||||
existing.providers.push(row.provider);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
byTitle.set(row.title.id, {
|
||||
titleId: row.title.id,
|
||||
title: row.title.title,
|
||||
posterPath: row.title.posterPath,
|
||||
providers: [row.provider],
|
||||
since: row.firstSeenAt,
|
||||
});
|
||||
}
|
||||
|
||||
return [...byTitle.values()];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statistik
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Totals = {
|
||||
minutes: number;
|
||||
episodes: number;
|
||||
movies: number;
|
||||
titlesCompleted: number;
|
||||
seriesCount: number;
|
||||
movieCount: number;
|
||||
};
|
||||
|
||||
export async function getTotals(userId: string): Promise<Totals> {
|
||||
const [minutes, episodes, movies, completed, byType] = await Promise.all([
|
||||
prisma.watch.aggregate({ where: { userId }, _sum: { minutes: true } }),
|
||||
prisma.watch.count({ where: { userId, episodeId: { not: null } } }),
|
||||
prisma.watch.count({ where: { userId, episodeId: null } }),
|
||||
prisma.libraryEntry.count({ where: { userId, deletedAt: null, status: "COMPLETED" } }),
|
||||
prisma.libraryEntry.groupBy({
|
||||
by: ["titleId"],
|
||||
where: { userId, deletedAt: null },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const typeCounts = await prisma.title.groupBy({
|
||||
by: ["type"],
|
||||
where: { id: { in: byType.map((row) => row.titleId) } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
return {
|
||||
minutes: minutes._sum.minutes ?? 0,
|
||||
episodes,
|
||||
movies,
|
||||
titlesCompleted: completed,
|
||||
seriesCount: typeCounts.find((row) => row.type === "SERIES")?._count._all ?? 0,
|
||||
movieCount: typeCounts.find((row) => row.type === "MOVIE")?._count._all ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export type MonthlyPoint = {
|
||||
/** Monatsanfang */
|
||||
month: Date;
|
||||
label: string;
|
||||
episodes: number;
|
||||
movies: number;
|
||||
minutes: number;
|
||||
};
|
||||
|
||||
/** Aktivität je Monat, lückenlos — auch Monate ohne Sichtung erscheinen. */
|
||||
export async function getMonthlyActivity(userId: string, months = 12): Promise<MonthlyPoint[]> {
|
||||
const start = new Date();
|
||||
start.setDate(1);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
start.setMonth(start.getMonth() - (months - 1));
|
||||
|
||||
// Der Monatsschlüssel kommt als Text aus der Datenbank („2026-04").
|
||||
// Ein Zeitstempel würde hier schiefgehen: date_trunc liefert Mitternacht in
|
||||
// der Sitzungszeitzone, JS legt beim Vergleich die lokale Zone an — zwei
|
||||
// Stunden Versatz genügen, damit kein einziger Schlüssel mehr passt.
|
||||
const rows = await prisma.$queryRaw<
|
||||
{ month: string; episodes: bigint; movies: bigint; minutes: bigint | null }[]
|
||||
>`
|
||||
SELECT
|
||||
to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month,
|
||||
count(*) FILTER (WHERE "episodeId" IS NOT NULL) AS episodes,
|
||||
count(*) FILTER (WHERE "episodeId" IS NULL) AS movies,
|
||||
sum("minutes") AS minutes
|
||||
FROM "Watch"
|
||||
WHERE "userId" = ${userId} AND "watchedAt" >= ${start}
|
||||
GROUP BY 1
|
||||
ORDER BY 1
|
||||
`;
|
||||
|
||||
const byMonth = new Map(rows.map((row) => [row.month, row]));
|
||||
const points: MonthlyPoint[] = [];
|
||||
|
||||
for (let index = 0; index < months; index += 1) {
|
||||
const month = new Date(start);
|
||||
month.setMonth(start.getMonth() + index);
|
||||
const key = `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, "0")}`;
|
||||
const row = byMonth.get(key);
|
||||
|
||||
points.push({
|
||||
month,
|
||||
label: month.toLocaleDateString("de-DE", { month: "short" }),
|
||||
episodes: Number(row?.episodes ?? 0),
|
||||
movies: Number(row?.movies ?? 0),
|
||||
minutes: Number(row?.minutes ?? 0),
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
}
|
||||
|
||||
export type Slice = { label: string; minutes: number; count: number; color: string | null };
|
||||
|
||||
/**
|
||||
* Verteilung der Schauzeit nach Streamingdienst.
|
||||
* Grundlage ist das Feld „wo schaue ich das" — es sagt, wo tatsächlich
|
||||
* geschaut wurde, nicht bloß, wo es verfügbar wäre.
|
||||
*/
|
||||
export async function getProviderDistribution(userId: string): Promise<Slice[]> {
|
||||
const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>`
|
||||
SELECT
|
||||
coalesce(nullif(e."whereToWatch", ''), 'Ohne Angabe') AS label,
|
||||
sum(w."minutes") AS minutes,
|
||||
count(*) AS count
|
||||
FROM "Watch" w
|
||||
JOIN "LibraryEntry" e ON e."id" = w."entryId"
|
||||
WHERE w."userId" = ${userId}
|
||||
GROUP BY 1
|
||||
ORDER BY minutes DESC
|
||||
`;
|
||||
|
||||
const providers = await prisma.watchProvider.findMany({ select: { name: true, color: true } });
|
||||
const colorByName = new Map(providers.map((provider) => [provider.name, provider.color]));
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
minutes: Number(row.minutes ?? 0),
|
||||
count: Number(row.count),
|
||||
color: colorByName.get(row.label) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Verteilung der Schauzeit nach Genre. Mehrfachnennungen zählen mehrfach. */
|
||||
export async function getGenreDistribution(userId: string): Promise<Slice[]> {
|
||||
const rows = await prisma.$queryRaw<{ label: string; minutes: bigint; count: bigint }[]>`
|
||||
SELECT
|
||||
genre AS label,
|
||||
sum(w."minutes") AS minutes,
|
||||
count(*) AS count
|
||||
FROM "Watch" w
|
||||
JOIN "Title" t ON t."id" = w."titleId"
|
||||
CROSS JOIN LATERAL unnest(t."genres") AS genre
|
||||
WHERE w."userId" = ${userId}
|
||||
GROUP BY 1
|
||||
ORDER BY minutes DESC
|
||||
LIMIT 12
|
||||
`;
|
||||
|
||||
return rows.map((row) => ({
|
||||
label: row.label,
|
||||
minutes: Number(row.minutes ?? 0),
|
||||
count: Number(row.count),
|
||||
color: null,
|
||||
}));
|
||||
}
|
||||
|
||||
export type TopTitle = {
|
||||
titleId: string;
|
||||
title: string;
|
||||
posterPath: string | null;
|
||||
rating: number | null;
|
||||
minutes: number;
|
||||
type: "SERIES" | "MOVIE";
|
||||
};
|
||||
|
||||
/** Bestbewertete Titel. */
|
||||
export async function getTopRated(userId: string, limit = 6): Promise<TopTitle[]> {
|
||||
const entries = await prisma.libraryEntry.findMany({
|
||||
where: { userId, deletedAt: null, rating: { not: null } },
|
||||
orderBy: [{ rating: "desc" }, { updatedAt: "desc" }],
|
||||
take: limit,
|
||||
select: {
|
||||
rating: true,
|
||||
title: { select: { id: true, title: true, posterPath: true, type: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return entries.map((entry) => ({
|
||||
titleId: entry.title.id,
|
||||
title: entry.title.title,
|
||||
posterPath: entry.title.posterPath,
|
||||
rating: entry.rating,
|
||||
minutes: 0,
|
||||
type: entry.title.type,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Titel mit der meisten investierten Zeit. */
|
||||
export async function getMostWatched(userId: string, limit = 6): Promise<TopTitle[]> {
|
||||
const rows = await prisma.watch.groupBy({
|
||||
by: ["titleId"],
|
||||
where: { userId },
|
||||
_sum: { minutes: true },
|
||||
orderBy: { _sum: { minutes: "desc" } },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const titles = await prisma.title.findMany({
|
||||
where: { id: { in: rows.map((row) => row.titleId) } },
|
||||
select: { id: true, title: true, posterPath: true, type: true },
|
||||
});
|
||||
const byId = new Map(titles.map((title) => [title.id, title]));
|
||||
|
||||
return rows.flatMap((row) => {
|
||||
const title = byId.get(row.titleId);
|
||||
return title
|
||||
? [
|
||||
{
|
||||
titleId: title.id,
|
||||
title: title.title,
|
||||
posterPath: title.posterPath,
|
||||
rating: null,
|
||||
minutes: row._sum.minutes ?? 0,
|
||||
type: title.type,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
export type YearReview = {
|
||||
year: number;
|
||||
minutes: number;
|
||||
episodes: number;
|
||||
movies: number;
|
||||
titlesStarted: number;
|
||||
titlesCompleted: number;
|
||||
busiestMonth: { label: string; minutes: number } | null;
|
||||
topTitle: TopTitle | null;
|
||||
};
|
||||
|
||||
/** „Das Jahr in Zahlen". */
|
||||
export async function getYearReview(userId: string, year: number): Promise<YearReview> {
|
||||
const from = new Date(Date.UTC(year, 0, 1));
|
||||
const to = new Date(Date.UTC(year + 1, 0, 1));
|
||||
|
||||
const [aggregate, episodes, movies, started, completed, months, top] = await Promise.all([
|
||||
prisma.watch.aggregate({
|
||||
where: { userId, watchedAt: { gte: from, lt: to } },
|
||||
_sum: { minutes: true },
|
||||
}),
|
||||
prisma.watch.count({
|
||||
where: { userId, watchedAt: { gte: from, lt: to }, episodeId: { not: null } },
|
||||
}),
|
||||
prisma.watch.count({ where: { userId, watchedAt: { gte: from, lt: to }, episodeId: null } }),
|
||||
prisma.libraryEntry.count({ where: { userId, startedAt: { gte: from, lt: to } } }),
|
||||
prisma.libraryEntry.count({ where: { userId, finishedAt: { gte: from, lt: to } } }),
|
||||
prisma.$queryRaw<{ month: string; minutes: bigint }[]>`
|
||||
SELECT to_char(date_trunc('month', "watchedAt"), 'YYYY-MM') AS month, sum("minutes") AS minutes
|
||||
FROM "Watch"
|
||||
WHERE "userId" = ${userId} AND "watchedAt" >= ${from} AND "watchedAt" < ${to}
|
||||
GROUP BY 1
|
||||
ORDER BY minutes DESC
|
||||
LIMIT 1
|
||||
`,
|
||||
prisma.watch.groupBy({
|
||||
by: ["titleId"],
|
||||
where: { userId, watchedAt: { gte: from, lt: to } },
|
||||
_sum: { minutes: true },
|
||||
orderBy: { _sum: { minutes: "desc" } },
|
||||
take: 1,
|
||||
}),
|
||||
]);
|
||||
|
||||
const busiest = months[0];
|
||||
const topTitleRow = top[0];
|
||||
const topTitle = topTitleRow
|
||||
? await prisma.title.findUnique({
|
||||
where: { id: topTitleRow.titleId },
|
||||
select: { id: true, title: true, posterPath: true, type: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
year,
|
||||
minutes: aggregate._sum.minutes ?? 0,
|
||||
episodes,
|
||||
movies,
|
||||
titlesStarted: started,
|
||||
titlesCompleted: completed,
|
||||
busiestMonth: busiest
|
||||
? {
|
||||
label: monthLabel(busiest.month),
|
||||
minutes: Number(busiest.minutes ?? 0),
|
||||
}
|
||||
: null,
|
||||
topTitle:
|
||||
topTitle && topTitleRow
|
||||
? {
|
||||
titleId: topTitle.id,
|
||||
title: topTitle.title,
|
||||
posterPath: topTitle.posterPath,
|
||||
rating: null,
|
||||
minutes: topTitleRow._sum.minutes ?? 0,
|
||||
type: topTitle.type,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** „2026-04" → „April 2026" */
|
||||
function monthLabel(key: string): string {
|
||||
const [year, month] = key.split("-").map(Number);
|
||||
if (!year || !month) return key;
|
||||
return new Date(year, month - 1, 1).toLocaleDateString("de-DE", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Jahre, in denen überhaupt etwas gesehen wurde — für die Jahresauswahl. */
|
||||
export async function getActiveYears(userId: string): Promise<number[]> {
|
||||
const rows = await prisma.$queryRaw<{ year: number }[]>`
|
||||
SELECT DISTINCT extract(year FROM "watchedAt")::int AS year
|
||||
FROM "Watch"
|
||||
WHERE "userId" = ${userId}
|
||||
ORDER BY year DESC
|
||||
`;
|
||||
return rows.map((row) => row.year);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import "server-only";
|
||||
import { prisma } from "./db";
|
||||
import { SPECIALS_SEASON } from "./catalog";
|
||||
import type { OrderKind, SharePermission, TitleType } from "@/generated/prisma/client";
|
||||
|
||||
/**
|
||||
* Universen: eigene Sammlungen aus Filmen **und** Serien.
|
||||
*
|
||||
* Zwei Dinge unterscheiden ein Universum von einer Liste:
|
||||
*
|
||||
* 1. Es hat **mehrere Reihenfolgen**. Die Erscheinungsreihenfolge wird aus den
|
||||
* Daten berechnet und nie gespeichert — sie stimmt damit immer. Alles
|
||||
* andere (Story-Chronologie, Einsteiger-Route) ist frei sortierbar.
|
||||
* 2. Der **Fortschritt** ist der des jeweiligen Betrachters. Ein geteiltes
|
||||
* Universum zeigt jeder Person ihren eigenen Stand.
|
||||
*/
|
||||
|
||||
export class UniverseError extends Error {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zugriff
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type UniverseAccess = { canRead: boolean; canWrite: boolean; isOwner: boolean };
|
||||
|
||||
export async function getAccess(userId: string, universeId: string): Promise<UniverseAccess> {
|
||||
const universe = await prisma.universe.findFirst({
|
||||
where: { id: universeId, deletedAt: null },
|
||||
select: {
|
||||
ownerId: true,
|
||||
members: { where: { userId }, select: { permission: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!universe) return { canRead: false, canWrite: false, isOwner: false };
|
||||
|
||||
const isOwner = universe.ownerId === userId;
|
||||
const membership = universe.members[0];
|
||||
|
||||
return {
|
||||
isOwner,
|
||||
canRead: isOwner || Boolean(membership),
|
||||
canWrite: isOwner || membership?.permission === "WRITE",
|
||||
};
|
||||
}
|
||||
|
||||
async function requireWrite(userId: string, universeId: string) {
|
||||
const access = await getAccess(userId, universeId);
|
||||
if (!access.canRead) throw new UniverseError("Dieses Universum gibt es nicht.");
|
||||
if (!access.canWrite) throw new UniverseError("Du darfst dieses Universum nur ansehen.");
|
||||
return access;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anlegen und pflegen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLocaleLowerCase("de-DE")
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60) || "universum";
|
||||
}
|
||||
|
||||
export async function createUniverse(userId: string, name: string, description?: string) {
|
||||
const base = slugify(name);
|
||||
let slug = base;
|
||||
|
||||
// Der Slug ist je Besitzer eindeutig — bei Dubletten durchzählen.
|
||||
for (let suffix = 2; suffix < 100; suffix += 1) {
|
||||
const taken = await prisma.universe.findUnique({
|
||||
where: { ownerId_slug: { ownerId: userId, slug } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!taken) break;
|
||||
slug = `${base}-${suffix}`;
|
||||
}
|
||||
|
||||
const universe = await prisma.universe.create({
|
||||
data: {
|
||||
ownerId: userId,
|
||||
name: name.trim(),
|
||||
slug,
|
||||
description: description?.trim() || null,
|
||||
orders: {
|
||||
create: [
|
||||
// Die Erscheinungsreihenfolge braucht keine Einträge — sie wird
|
||||
// aus den Erscheinungsdaten berechnet.
|
||||
{ name: "Erscheinungsreihenfolge", kind: "RELEASE", isDefault: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return universe;
|
||||
}
|
||||
|
||||
export async function renameUniverse(
|
||||
userId: string,
|
||||
universeId: string,
|
||||
data: { name?: string; description?: string | null },
|
||||
) {
|
||||
await requireWrite(userId, universeId);
|
||||
return prisma.universe.update({
|
||||
where: { id: universeId },
|
||||
data: {
|
||||
...(data.name ? { name: data.name.trim() } : {}),
|
||||
...(data.description !== undefined ? { description: data.description?.trim() || null } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function trashUniverse(userId: string, universeId: string) {
|
||||
const access = await getAccess(userId, universeId);
|
||||
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es löschen.");
|
||||
return prisma.universe.update({
|
||||
where: { id: universeId },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/** Nimmt einen Titel ins Universum auf und hängt ihn an alle Reihenfolgen an. */
|
||||
export async function addTitle(userId: string, universeId: string, titleId: string) {
|
||||
await requireWrite(userId, universeId);
|
||||
|
||||
const existing = await prisma.universeItem.findUnique({
|
||||
where: { universeId_titleId: { universeId, titleId } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const item = await prisma.universeItem.create({
|
||||
data: { universeId, titleId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await appendToCustomOrders(universeId, item.id);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** Neue Einträge landen ans Ende jeder frei sortierten Reihenfolge. */
|
||||
async function appendToCustomOrders(universeId: string, itemId: string) {
|
||||
const orders = await prisma.universeOrder.findMany({
|
||||
where: { universeId, kind: "CUSTOM" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
for (const order of orders) {
|
||||
const last = await prisma.universeOrderItem.findFirst({
|
||||
where: { orderId: order.id },
|
||||
orderBy: { position: "desc" },
|
||||
select: { position: true },
|
||||
});
|
||||
await prisma.universeOrderItem.create({
|
||||
data: { orderId: order.id, itemId, position: (last?.position ?? -1) + 1 },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeTitle(userId: string, universeId: string, itemId: string) {
|
||||
await requireWrite(userId, universeId);
|
||||
await prisma.universeItem.delete({ where: { id: itemId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt alle Filme einer TMDB-Collection, die bereits im Katalog liegen.
|
||||
* Der eigentliche Abruf bei TMDB passiert vorher in der Katalogschicht.
|
||||
*/
|
||||
export async function addCollection(
|
||||
userId: string,
|
||||
universeId: string,
|
||||
tmdbCollectionId: number,
|
||||
): Promise<number> {
|
||||
await requireWrite(userId, universeId);
|
||||
|
||||
const titles = await prisma.title.findMany({
|
||||
where: { tmdbCollectionId },
|
||||
orderBy: { releaseDate: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let added = 0;
|
||||
for (const title of titles) {
|
||||
const existing = await prisma.universeItem.findUnique({
|
||||
where: { universeId_titleId: { universeId, titleId: title.id } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) continue;
|
||||
const item = await prisma.universeItem.create({
|
||||
data: { universeId, titleId: title.id },
|
||||
select: { id: true },
|
||||
});
|
||||
await appendToCustomOrders(universeId, item.id);
|
||||
added += 1;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reihenfolgen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createOrder(userId: string, universeId: string, name: string) {
|
||||
await requireWrite(userId, universeId);
|
||||
|
||||
const order = await prisma.universeOrder.create({
|
||||
data: { universeId, name: name.trim(), kind: "CUSTOM" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Startbelegung: Erscheinungsreihenfolge — von dort aus umsortiert es sich
|
||||
// leichter als aus einer zufälligen Anordnung.
|
||||
const items = await prisma.universeItem.findMany({
|
||||
where: { universeId },
|
||||
orderBy: [{ title: { releaseDate: "asc" } }, { title: { title: "asc" } }],
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.universeOrderItem.createMany({
|
||||
data: items.map((item, index) => ({ orderId: order.id, itemId: item.id, position: index })),
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
export async function renameOrder(userId: string, orderId: string, name: string) {
|
||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
select: { universeId: true, kind: true },
|
||||
});
|
||||
await requireWrite(userId, order.universeId);
|
||||
if (order.kind === "RELEASE") {
|
||||
throw new UniverseError("Die Erscheinungsreihenfolge lässt sich nicht umbenennen.");
|
||||
}
|
||||
return prisma.universeOrder.update({ where: { id: orderId }, data: { name: name.trim() } });
|
||||
}
|
||||
|
||||
export async function deleteOrder(userId: string, orderId: string) {
|
||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
select: { universeId: true, kind: true },
|
||||
});
|
||||
await requireWrite(userId, order.universeId);
|
||||
if (order.kind === "RELEASE") {
|
||||
throw new UniverseError("Die Erscheinungsreihenfolge gehört fest dazu.");
|
||||
}
|
||||
await prisma.universeOrder.delete({ where: { id: orderId } });
|
||||
}
|
||||
|
||||
/** Schreibt eine neue Anordnung — die Liste enthält alle Einträge in Wunschfolge. */
|
||||
export async function setOrderPositions(userId: string, orderId: string, itemIds: string[]) {
|
||||
const order = await prisma.universeOrder.findUniqueOrThrow({
|
||||
where: { id: orderId },
|
||||
select: { universeId: true, kind: true },
|
||||
});
|
||||
await requireWrite(userId, order.universeId);
|
||||
if (order.kind === "RELEASE") {
|
||||
throw new UniverseError("Die Erscheinungsreihenfolge ergibt sich aus den Daten.");
|
||||
}
|
||||
|
||||
// Erst alle auf negative Positionen schieben: die Eindeutigkeit von
|
||||
// (orderId, itemId) bleibt, aber wir vermeiden Kollisionen beim Umnummerieren.
|
||||
await prisma.$transaction(
|
||||
itemIds.map((itemId, index) =>
|
||||
prisma.universeOrderItem.updateMany({
|
||||
where: { orderId, itemId },
|
||||
data: { position: index },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Teilen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function shareUniverse(
|
||||
userId: string,
|
||||
universeId: string,
|
||||
targetEmail: string,
|
||||
permission: SharePermission,
|
||||
) {
|
||||
const access = await getAccess(userId, universeId);
|
||||
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf es teilen.");
|
||||
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { email: targetEmail.trim().toLowerCase() },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!target) throw new UniverseError("Zu dieser E-Mail-Adresse gibt es auf dieser Instanz kein Konto.");
|
||||
if (target.id === userId) throw new UniverseError("Mit dir selbst musst du nicht teilen.");
|
||||
|
||||
await prisma.universeMember.upsert({
|
||||
where: { universeId_userId: { universeId, userId: target.id } },
|
||||
create: { universeId, userId: target.id, permission },
|
||||
update: { permission },
|
||||
});
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
export async function unshareUniverse(userId: string, universeId: string, memberId: string) {
|
||||
const access = await getAccess(userId, universeId);
|
||||
if (!access.isOwner) throw new UniverseError("Nur wer das Universum angelegt hat, darf das ändern.");
|
||||
await prisma.universeMember.delete({ where: { id: memberId } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lesen und Fortschritt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type UniverseEntry = {
|
||||
itemId: string;
|
||||
titleId: string;
|
||||
title: string;
|
||||
type: TitleType;
|
||||
posterPath: string | null;
|
||||
year: number | null;
|
||||
note: string | null;
|
||||
/** Gesamtlaufzeit des Titels in Minuten. */
|
||||
totalMinutes: number;
|
||||
/** Noch offene Laufzeit für diesen Betrachter. */
|
||||
remainingMinutes: number;
|
||||
watchedMinutes: number;
|
||||
inLibrary: boolean;
|
||||
completed: boolean;
|
||||
status: string | null;
|
||||
watchedEpisodes: number;
|
||||
totalEpisodes: number;
|
||||
};
|
||||
|
||||
export type UniverseView = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isOwner: boolean;
|
||||
canWrite: boolean;
|
||||
ownerName: string;
|
||||
orders: { id: string; name: string; kind: OrderKind; isDefault: boolean }[];
|
||||
activeOrderId: string;
|
||||
entries: UniverseEntry[];
|
||||
progress: {
|
||||
completed: number;
|
||||
total: number;
|
||||
percent: number;
|
||||
watchedMinutes: number;
|
||||
remainingMinutes: number;
|
||||
totalMinutes: number;
|
||||
};
|
||||
/** Der erste noch nicht abgeschlossene Eintrag in der gewählten Reihenfolge. */
|
||||
nextUp: UniverseEntry | null;
|
||||
members: { id: string; name: string; email: string; permission: SharePermission }[];
|
||||
};
|
||||
|
||||
export async function getUniverse(
|
||||
userId: string,
|
||||
universeId: string,
|
||||
orderId?: string,
|
||||
): Promise<UniverseView | null> {
|
||||
const access = await getAccess(userId, universeId);
|
||||
if (!access.canRead) return null;
|
||||
|
||||
const universe = await prisma.universe.findUnique({
|
||||
where: { id: universeId },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
orders: { orderBy: [{ kind: "asc" }, { createdAt: "asc" }] },
|
||||
members: {
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
title: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
posterPath: true,
|
||||
releaseDate: true,
|
||||
runtime: true,
|
||||
episodeCount: true,
|
||||
},
|
||||
},
|
||||
positions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!universe) return null;
|
||||
|
||||
const activeOrder =
|
||||
universe.orders.find((order) => order.id === orderId) ??
|
||||
universe.orders.find((order) => order.isDefault) ??
|
||||
universe.orders[0];
|
||||
if (!activeOrder) return null;
|
||||
|
||||
const titleIds = universe.items.map((item) => item.titleId);
|
||||
|
||||
const [entries, seriesRuntimes, watchedByTitle, watchedEpisodes] = await Promise.all([
|
||||
prisma.libraryEntry.findMany({
|
||||
where: { userId, titleId: { in: titleIds }, deletedAt: null },
|
||||
select: { titleId: true, status: true, currentRun: true },
|
||||
}),
|
||||
// Gesamtlaufzeit je Serie aus den Episoden (ohne Specials).
|
||||
prisma.episode.groupBy({
|
||||
by: ["titleId"],
|
||||
where: { titleId: { in: titleIds }, seasonNumber: { gt: SPECIALS_SEASON } },
|
||||
_sum: { runtime: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
prisma.watch.groupBy({
|
||||
by: ["titleId"],
|
||||
where: { userId, titleId: { in: titleIds } },
|
||||
_sum: { minutes: true },
|
||||
}),
|
||||
// Gesehene reguläre Episoden im jeweils aktuellen Durchlauf.
|
||||
prisma.watch.groupBy({
|
||||
by: ["titleId", "run"],
|
||||
where: {
|
||||
userId,
|
||||
titleId: { in: titleIds },
|
||||
episode: { seasonNumber: { gt: SPECIALS_SEASON } },
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const entryByTitle = new Map(entries.map((entry) => [entry.titleId, entry]));
|
||||
const runtimeByTitle = new Map(
|
||||
seriesRuntimes.map((row) => [row.titleId, { minutes: row._sum.runtime ?? 0, episodes: row._count._all }]),
|
||||
);
|
||||
const minutesByTitle = new Map(watchedByTitle.map((row) => [row.titleId, row._sum.minutes ?? 0]));
|
||||
|
||||
const seenByTitle = new Map<string, number>();
|
||||
for (const row of watchedEpisodes) {
|
||||
const entry = entryByTitle.get(row.titleId);
|
||||
if (!entry || entry.currentRun !== row.run) continue;
|
||||
seenByTitle.set(row.titleId, row._count._all);
|
||||
}
|
||||
|
||||
const built: UniverseEntry[] = universe.items.map((item) => {
|
||||
const title = item.title;
|
||||
const isSeries = title.type === "SERIES";
|
||||
const runtimeInfo = runtimeByTitle.get(title.id);
|
||||
|
||||
const totalEpisodes = isSeries ? (runtimeInfo?.episodes ?? title.episodeCount) : 1;
|
||||
const totalMinutes = isSeries
|
||||
? (runtimeInfo?.minutes ?? (title.runtime ?? 0) * title.episodeCount)
|
||||
: (title.runtime ?? 0);
|
||||
|
||||
const entry = entryByTitle.get(title.id);
|
||||
const watchedEpisodeCount = isSeries
|
||||
? Math.min(seenByTitle.get(title.id) ?? 0, totalEpisodes)
|
||||
: entry?.status === "COMPLETED"
|
||||
? 1
|
||||
: 0;
|
||||
|
||||
const completed =
|
||||
entry?.status === "COMPLETED" ||
|
||||
(totalEpisodes > 0 && watchedEpisodeCount >= totalEpisodes);
|
||||
|
||||
// Verbleibende Zeit: bei Serien anteilig über die offenen Episoden,
|
||||
// bei Filmen alles oder nichts.
|
||||
const remainingMinutes = completed
|
||||
? 0
|
||||
: isSeries && totalEpisodes > 0
|
||||
? Math.round((totalMinutes * (totalEpisodes - watchedEpisodeCount)) / totalEpisodes)
|
||||
: totalMinutes;
|
||||
|
||||
return {
|
||||
itemId: item.id,
|
||||
titleId: title.id,
|
||||
title: title.title,
|
||||
type: title.type,
|
||||
posterPath: title.posterPath,
|
||||
year: title.releaseDate?.getFullYear() ?? null,
|
||||
note: item.note,
|
||||
totalMinutes,
|
||||
remainingMinutes,
|
||||
watchedMinutes: minutesByTitle.get(title.id) ?? 0,
|
||||
inLibrary: Boolean(entry),
|
||||
completed,
|
||||
status: entry?.status ?? null,
|
||||
watchedEpisodes: watchedEpisodeCount,
|
||||
totalEpisodes,
|
||||
};
|
||||
});
|
||||
|
||||
// Sortieren: Erscheinungsreihenfolge rechnerisch, sonst nach den Positionen.
|
||||
const positionByItem = new Map<string, number>();
|
||||
for (const item of universe.items) {
|
||||
const position = item.positions.find((row) => row.orderId === activeOrder.id);
|
||||
if (position) positionByItem.set(item.id, position.position);
|
||||
}
|
||||
|
||||
const releaseYear = new Map(
|
||||
universe.items.map((item) => [item.id, item.title.releaseDate?.getTime() ?? Number.MAX_SAFE_INTEGER]),
|
||||
);
|
||||
|
||||
const sorted = [...built].sort((a, b) => {
|
||||
if (activeOrder.kind === "RELEASE") {
|
||||
const diff = (releaseYear.get(a.itemId) ?? 0) - (releaseYear.get(b.itemId) ?? 0);
|
||||
return diff !== 0 ? diff : a.title.localeCompare(b.title, "de");
|
||||
}
|
||||
const positionA = positionByItem.get(a.itemId) ?? Number.MAX_SAFE_INTEGER;
|
||||
const positionB = positionByItem.get(b.itemId) ?? Number.MAX_SAFE_INTEGER;
|
||||
return positionA !== positionB
|
||||
? positionA - positionB
|
||||
: a.title.localeCompare(b.title, "de");
|
||||
});
|
||||
|
||||
const completed = sorted.filter((entry) => entry.completed).length;
|
||||
const watchedMinutes = sorted.reduce((sum, entry) => sum + entry.watchedMinutes, 0);
|
||||
const remainingMinutes = sorted.reduce((sum, entry) => sum + entry.remainingMinutes, 0);
|
||||
const totalMinutes = sorted.reduce((sum, entry) => sum + entry.totalMinutes, 0);
|
||||
|
||||
return {
|
||||
id: universe.id,
|
||||
name: universe.name,
|
||||
description: universe.description,
|
||||
isOwner: access.isOwner,
|
||||
canWrite: access.canWrite,
|
||||
ownerName: universe.owner.name,
|
||||
orders: universe.orders.map((order) => ({
|
||||
id: order.id,
|
||||
name: order.name,
|
||||
kind: order.kind,
|
||||
isDefault: order.isDefault,
|
||||
})),
|
||||
activeOrderId: activeOrder.id,
|
||||
entries: sorted,
|
||||
progress: {
|
||||
completed,
|
||||
total: sorted.length,
|
||||
percent: sorted.length > 0 ? Math.round((completed / sorted.length) * 100) : 0,
|
||||
watchedMinutes,
|
||||
remainingMinutes,
|
||||
totalMinutes,
|
||||
},
|
||||
nextUp: sorted.find((entry) => !entry.completed) ?? null,
|
||||
members: universe.members.map((member) => ({
|
||||
id: member.id,
|
||||
name: member.user.name,
|
||||
email: member.user.email,
|
||||
permission: member.permission,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export type UniverseSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isOwner: boolean;
|
||||
ownerName: string;
|
||||
itemCount: number;
|
||||
completed: number;
|
||||
percent: number;
|
||||
posters: string[];
|
||||
};
|
||||
|
||||
/** Eigene und mit einem geteilte Universen. */
|
||||
export async function listUniverses(userId: string): Promise<UniverseSummary[]> {
|
||||
const universes = await prisma.universe.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
OR: [{ ownerId: userId }, { members: { some: { userId } } }],
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
items: {
|
||||
include: {
|
||||
title: { select: { id: true, posterPath: true, releaseDate: true, type: true } },
|
||||
},
|
||||
orderBy: { title: { releaseDate: "asc" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (universes.length === 0) return [];
|
||||
|
||||
const allTitleIds = universes.flatMap((universe) => universe.items.map((item) => item.titleId));
|
||||
const completedEntries = await prisma.libraryEntry.findMany({
|
||||
where: { userId, titleId: { in: allTitleIds }, status: "COMPLETED", deletedAt: null },
|
||||
select: { titleId: true },
|
||||
});
|
||||
const completedTitles = new Set(completedEntries.map((entry) => entry.titleId));
|
||||
|
||||
return universes.map((universe) => {
|
||||
const completed = universe.items.filter((item) => completedTitles.has(item.titleId)).length;
|
||||
return {
|
||||
id: universe.id,
|
||||
name: universe.name,
|
||||
description: universe.description,
|
||||
isOwner: universe.ownerId === userId,
|
||||
ownerName: universe.owner.name,
|
||||
itemCount: universe.items.length,
|
||||
completed,
|
||||
percent: universe.items.length > 0 ? Math.round((completed / universe.items.length) * 100) : 0,
|
||||
posters: universe.items
|
||||
.map((item) => item.title.posterPath)
|
||||
.filter((path): path is string => Boolean(path))
|
||||
.slice(0, 5),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** TMDB-Collections, die in der Bibliothek des Nutzers vorkommen. */
|
||||
export async function suggestCollections(userId: string) {
|
||||
const rows = await prisma.title.findMany({
|
||||
where: {
|
||||
tmdbCollectionId: { not: null },
|
||||
entries: { some: { userId, deletedAt: null } },
|
||||
},
|
||||
select: { tmdbCollectionId: true, tmdbCollectionName: true },
|
||||
distinct: ["tmdbCollectionId"],
|
||||
orderBy: { tmdbCollectionName: "asc" },
|
||||
});
|
||||
|
||||
return rows
|
||||
.filter((row) => row.tmdbCollectionId && row.tmdbCollectionName)
|
||||
.map((row) => ({ id: row.tmdbCollectionId!, name: row.tmdbCollectionName! }));
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import "./setup";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after, before } from "node:test";
|
||||
|
||||
import { installTmdbStub, SERIES_ID, MOVIE_ID } from "./fixtures/tmdb";
|
||||
import { prisma } from "../src/lib/db";
|
||||
import { importMovie, importSeries } from "../src/lib/catalog";
|
||||
import { addToLibrary, markEpisodesWatched, markMovieWatched } from "../src/lib/library";
|
||||
import {
|
||||
getContinueWatching,
|
||||
getMonthlyActivity,
|
||||
getTotals,
|
||||
getUpcomingEpisodes,
|
||||
getYearReview,
|
||||
} from "../src/lib/stats";
|
||||
|
||||
/**
|
||||
* Prüft die Auswertungen. Besonderes Augenmerk auf die Monatsgruppierung:
|
||||
* dort führte ein Zeitzonenversatz zwischen Datenbank und JavaScript einmal
|
||||
* dazu, dass das Diagramm leer blieb, obwohl Sichtungen vorlagen.
|
||||
*/
|
||||
|
||||
const stub = installTmdbStub();
|
||||
const TEST_EMAIL = "test-stats@flimmer.invalid";
|
||||
|
||||
let userId: string;
|
||||
let entryId: string;
|
||||
let seriesTitleId: string;
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.user.deleteMany({ where: { email: TEST_EMAIL } });
|
||||
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await cleanup();
|
||||
userId = (
|
||||
await prisma.user.create({ data: { email: TEST_EMAIL, name: "Statistik" }, select: { id: true } })
|
||||
).id;
|
||||
|
||||
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
|
||||
const movieTitleId = (await importMovie(MOVIE_ID, "DE")).id;
|
||||
|
||||
const entry = await addToLibrary(userId, seriesTitleId, "WATCHING");
|
||||
entryId = entry.id;
|
||||
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
|
||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Drei Episoden: heute, vor einem Monat, vor zwei Monaten.
|
||||
for (const [index, episode] of episodes.slice(0, 3).entries()) {
|
||||
const when = new Date();
|
||||
when.setMonth(when.getMonth() - index);
|
||||
when.setHours(20, 30, 0, 0);
|
||||
await markEpisodesWatched(userId, entry.id, [episode.id], when);
|
||||
}
|
||||
|
||||
const movieEntry = await addToLibrary(userId, movieTitleId, "WATCHING");
|
||||
await markMovieWatched(userId, movieEntry.id, new Date());
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await cleanup();
|
||||
stub.restore();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test("Gesamtzahlen zählen Episoden und Filme getrennt", async () => {
|
||||
const totals = await getTotals(userId);
|
||||
|
||||
assert.equal(totals.episodes, 3);
|
||||
assert.equal(totals.movies, 1);
|
||||
// 52 + 47 + 48 aus den Episoden, 118 aus dem Film
|
||||
assert.equal(totals.minutes, 52 + 47 + 48 + 118);
|
||||
assert.equal(totals.seriesCount, 1);
|
||||
assert.equal(totals.movieCount, 1);
|
||||
});
|
||||
|
||||
test("Monatsverlauf ordnet Sichtungen dem richtigen Monat zu", async () => {
|
||||
const points = await getMonthlyActivity(userId, 12);
|
||||
|
||||
assert.equal(points.length, 12);
|
||||
|
||||
const total = points.reduce((sum, point) => sum + point.episodes + point.movies, 0);
|
||||
assert.equal(total, 4, "keine Sichtung fällt beim Gruppieren durchs Raster");
|
||||
|
||||
const current = points[points.length - 1];
|
||||
assert.ok(current);
|
||||
assert.equal(current.episodes, 1, "die Sichtung von heute liegt im laufenden Monat");
|
||||
assert.equal(current.movies, 1);
|
||||
|
||||
const previous = points[points.length - 2];
|
||||
assert.equal(previous?.episodes, 1, "die Sichtung von vor einem Monat liegt im Vormonat");
|
||||
|
||||
// Lückenlos: auch Monate ohne Sichtung sind vertreten.
|
||||
assert.ok(points.some((point) => point.episodes === 0 && point.movies === 0));
|
||||
});
|
||||
|
||||
test("Jahresrückblick fasst das laufende Jahr zusammen", async () => {
|
||||
const review = await getYearReview(userId, new Date().getFullYear());
|
||||
|
||||
assert.ok(review.minutes > 0);
|
||||
assert.ok(review.episodes >= 1);
|
||||
assert.equal(review.movies, 1);
|
||||
assert.ok(review.busiestMonth, "der stärkste Monat wird benannt");
|
||||
assert.match(
|
||||
review.busiestMonth!.label,
|
||||
/\d{4}$/,
|
||||
"die Beschriftung endet auf die Jahreszahl, nicht auf einen rohen Zeitstempel",
|
||||
);
|
||||
assert.ok(review.topTitle);
|
||||
});
|
||||
|
||||
test("Weiterschauen nennt die nächste offene Episode", async () => {
|
||||
const items = await getContinueWatching(userId, 10);
|
||||
const series = items.find((item) => item.titleId === seriesTitleId);
|
||||
|
||||
assert.ok(series, "die laufende Serie steht in der Liste");
|
||||
assert.equal(series.watched, 3);
|
||||
assert.equal(series.total, 5);
|
||||
assert.equal(series.next?.episodeNumber, 1);
|
||||
assert.equal(series.next?.seasonNumber, 2, "nach Staffel 1 kommt Staffel 2");
|
||||
assert.ok(series.lastWatchedAt);
|
||||
});
|
||||
|
||||
test("Weiterschauen lässt durchgeschaute Serien weg", async () => {
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
|
||||
select: { id: true },
|
||||
});
|
||||
await markEpisodesWatched(userId, entryId, episodes.map((episode) => episode.id));
|
||||
|
||||
const items = await getContinueWatching(userId, 10);
|
||||
assert.equal(
|
||||
items.find((item) => item.titleId === seriesTitleId),
|
||||
undefined,
|
||||
"wo nichts mehr offen ist, gibt es auch nichts weiterzuschauen",
|
||||
);
|
||||
});
|
||||
|
||||
test("Kalender liefert nur Episoden im gefragten Zeitraum", async () => {
|
||||
// Die Fixture hat eine Episode am 08.01.2099. Der vorige Test hat die Serie
|
||||
// auf „Abgeschlossen" gebracht — sie muss trotzdem im Kalender stehen, denn
|
||||
// bei einer laufenden Serie heißt „abgeschlossen" nur: auf dem Stand.
|
||||
const episodes = await getUpcomingEpisodes(
|
||||
userId,
|
||||
new Date("2098-12-01T00:00:00.000Z"),
|
||||
new Date("2099-03-01T00:00:00.000Z"),
|
||||
);
|
||||
assert.equal(episodes.length, 1);
|
||||
assert.equal(episodes[0]?.seasonNumber, 2);
|
||||
assert.equal(episodes[0]?.episodeNumber, 2);
|
||||
|
||||
const empty = await getUpcomingEpisodes(
|
||||
userId,
|
||||
new Date("2050-01-01T00:00:00.000Z"),
|
||||
new Date("2050-12-31T00:00:00.000Z"),
|
||||
);
|
||||
assert.equal(empty.length, 0, "außerhalb des Zeitraums kommt nichts");
|
||||
});
|
||||
|
||||
test("Abgebrochenes taucht im Kalender nicht mehr auf", async () => {
|
||||
await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "DROPPED" } });
|
||||
|
||||
const episodes = await getUpcomingEpisodes(
|
||||
userId,
|
||||
new Date("2098-12-01T00:00:00.000Z"),
|
||||
new Date("2099-03-01T00:00:00.000Z"),
|
||||
);
|
||||
assert.equal(episodes.length, 0, "wer abgebrochen hat, braucht keine Erinnerung");
|
||||
|
||||
await prisma.libraryEntry.update({ where: { id: entryId }, data: { status: "COMPLETED" } });
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import "./setup";
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import test, { after, before, beforeEach } from "node:test";
|
||||
|
||||
import { installTmdbStub, SERIES_ID, MOVIE_ID, MOVIE_2_ID, COLLECTION_ID } from "./fixtures/tmdb";
|
||||
import { prisma } from "../src/lib/db";
|
||||
import { importCollection, importSeries } from "../src/lib/catalog";
|
||||
import { addToLibrary, markEpisodesWatched, markMovieWatched } from "../src/lib/library";
|
||||
import {
|
||||
addCollection,
|
||||
addTitle,
|
||||
createOrder,
|
||||
createUniverse,
|
||||
getAccess,
|
||||
getUniverse,
|
||||
listUniverses,
|
||||
setOrderPositions,
|
||||
shareUniverse,
|
||||
trashUniverse,
|
||||
} from "../src/lib/universes";
|
||||
|
||||
/**
|
||||
* Prüft Universen: Reihenfolgen, Fortschritt aus Sicht des Betrachters,
|
||||
* Teilen und die Rechteprüfung.
|
||||
*/
|
||||
|
||||
const stub = installTmdbStub();
|
||||
const OWNER_EMAIL = "test-uni-owner@flimmer.invalid";
|
||||
const GUEST_EMAIL = "test-uni-guest@flimmer.invalid";
|
||||
|
||||
let ownerId: string;
|
||||
let guestId: string;
|
||||
let seriesTitleId: string;
|
||||
let movieTitleId: string;
|
||||
let movie2TitleId: string;
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.user.deleteMany({ where: { email: { in: [OWNER_EMAIL, GUEST_EMAIL] } } });
|
||||
await prisma.title.deleteMany({ where: { tmdbId: { gte: 999000, lt: 1000000 } } });
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await cleanup();
|
||||
ownerId = (
|
||||
await prisma.user.create({ data: { email: OWNER_EMAIL, name: "Besitzerin" }, select: { id: true } })
|
||||
).id;
|
||||
guestId = (
|
||||
await prisma.user.create({ data: { email: GUEST_EMAIL, name: "Gast" }, select: { id: true } })
|
||||
).id;
|
||||
|
||||
seriesTitleId = (await importSeries(SERIES_ID, { region: "DE" })).id;
|
||||
const { titles } = await importCollection(COLLECTION_ID, "DE");
|
||||
movieTitleId = titles.find((title) => title.tmdbId === MOVIE_ID)!.id;
|
||||
movie2TitleId = titles.find((title) => title.tmdbId === MOVIE_2_ID)!.id;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.universe.deleteMany({ where: { ownerId } });
|
||||
await prisma.watch.deleteMany({ where: { userId: { in: [ownerId, guestId] } } });
|
||||
await prisma.libraryEntry.deleteMany({ where: { userId: { in: [ownerId, guestId] } } });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await cleanup();
|
||||
stub.restore();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test("ein neues Universum bringt die Erscheinungsreihenfolge mit", async () => {
|
||||
const universe = await createUniverse(ownerId, "Vorführer-Universum", "Zwei Filme, eine Rolle.");
|
||||
|
||||
const view = await getUniverse(ownerId, universe.id);
|
||||
assert.ok(view);
|
||||
assert.equal(view.orders.length, 1);
|
||||
assert.equal(view.orders[0]?.kind, "RELEASE");
|
||||
assert.equal(view.orders[0]?.isDefault, true);
|
||||
assert.equal(view.entries.length, 0);
|
||||
});
|
||||
|
||||
test("Slugs bleiben je Besitzer eindeutig", async () => {
|
||||
const first = await createUniverse(ownerId, "Sternenpfad");
|
||||
const second = await createUniverse(ownerId, "Sternenpfad");
|
||||
|
||||
assert.equal(first.slug, "sternenpfad");
|
||||
assert.equal(second.slug, "sternenpfad-2", "die Dublette wird durchgezählt");
|
||||
});
|
||||
|
||||
test("Filme und Serien landen gemeinsam in einem Universum", async () => {
|
||||
const universe = await createUniverse(ownerId, "Gemischt");
|
||||
await addTitle(ownerId, universe.id, seriesTitleId);
|
||||
await addTitle(ownerId, universe.id, movieTitleId);
|
||||
|
||||
const view = await getUniverse(ownerId, universe.id);
|
||||
assert.equal(view?.entries.length, 2);
|
||||
assert.deepEqual(
|
||||
view?.entries.map((entry) => entry.type).sort(),
|
||||
["MOVIE", "SERIES"],
|
||||
"Serien und Filme stehen gleichberechtigt nebeneinander",
|
||||
);
|
||||
|
||||
// Zweimal derselbe Titel legt keinen zweiten Eintrag an.
|
||||
await addTitle(ownerId, universe.id, movieTitleId);
|
||||
const again = await getUniverse(ownerId, universe.id);
|
||||
assert.equal(again?.entries.length, 2);
|
||||
});
|
||||
|
||||
test("TMDB-Collection lässt sich als Startpunkt übernehmen", async () => {
|
||||
const universe = await createUniverse(ownerId, "Aus Collection");
|
||||
const added = await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||
|
||||
assert.equal(added, 2);
|
||||
const view = await getUniverse(ownerId, universe.id);
|
||||
assert.deepEqual(
|
||||
view?.entries.map((entry) => entry.title),
|
||||
[
|
||||
"Die Stille zwischen den Bildern",
|
||||
"Die Stille zwischen den Bildern II",
|
||||
],
|
||||
"Erscheinungsreihenfolge ergibt sich aus den Daten",
|
||||
);
|
||||
|
||||
// Erneutes Übernehmen fügt nichts doppelt hinzu.
|
||||
assert.equal(await addCollection(ownerId, universe.id, COLLECTION_ID), 0);
|
||||
});
|
||||
|
||||
test("eigene Reihenfolge lässt sich frei sortieren", async () => {
|
||||
const universe = await createUniverse(ownerId, "Sortierbar");
|
||||
await addCollection(ownerId, universe.id, COLLECTION_ID);
|
||||
await addTitle(ownerId, universe.id, seriesTitleId);
|
||||
|
||||
const order = await createOrder(ownerId, universe.id, "Chronologisch");
|
||||
const before = await getUniverse(ownerId, universe.id, order.id);
|
||||
assert.equal(before?.entries.length, 3);
|
||||
|
||||
// Umdrehen
|
||||
const reversed = [...before!.entries].reverse().map((entry) => entry.itemId);
|
||||
await setOrderPositions(ownerId, order.id, reversed);
|
||||
|
||||
const after = await getUniverse(ownerId, universe.id, order.id);
|
||||
assert.deepEqual(
|
||||
after?.entries.map((entry) => entry.itemId),
|
||||
reversed,
|
||||
"die eigene Reihenfolge steht so, wie sie gesetzt wurde",
|
||||
);
|
||||
|
||||
// Die Erscheinungsreihenfolge bleibt davon unberührt.
|
||||
const release = await getUniverse(ownerId, universe.id, before!.orders.find((o) => o.kind === "RELEASE")!.id);
|
||||
assert.notDeepEqual(release?.entries.map((entry) => entry.itemId), reversed);
|
||||
});
|
||||
|
||||
test("die Erscheinungsreihenfolge lässt sich nicht umsortieren", async () => {
|
||||
const universe = await createUniverse(ownerId, "Fest");
|
||||
await addTitle(ownerId, universe.id, movieTitleId);
|
||||
const view = await getUniverse(ownerId, universe.id);
|
||||
const releaseOrder = view!.orders.find((order) => order.kind === "RELEASE")!;
|
||||
|
||||
await assert.rejects(
|
||||
() => setOrderPositions(ownerId, releaseOrder.id, [view!.entries[0]!.itemId]),
|
||||
/ergibt sich aus den Daten/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Fortschritt und Restlaufzeit rechnen über Filme und Serien", async () => {
|
||||
const universe = await createUniverse(ownerId, "Fortschritt");
|
||||
await addTitle(ownerId, universe.id, movieTitleId);
|
||||
await addTitle(ownerId, universe.id, seriesTitleId);
|
||||
|
||||
const fresh = await getUniverse(ownerId, universe.id);
|
||||
// Film 118 min + Serie 5 Episoden (52+47+48+50+50 = 247 min)
|
||||
assert.equal(fresh?.progress.total, 2);
|
||||
assert.equal(fresh?.progress.completed, 0);
|
||||
assert.equal(fresh?.progress.totalMinutes, 118 + 247);
|
||||
assert.equal(fresh?.progress.remainingMinutes, 118 + 247);
|
||||
// Erscheinungsreihenfolge: die Serie (2023) läuft vor dem Film (2024).
|
||||
assert.equal(fresh?.nextUp?.title, "Nordlicht");
|
||||
|
||||
// Film sehen
|
||||
const movieEntry = await addToLibrary(ownerId, movieTitleId);
|
||||
await markMovieWatched(ownerId, movieEntry.id);
|
||||
|
||||
const afterMovie = await getUniverse(ownerId, universe.id);
|
||||
assert.equal(afterMovie?.progress.completed, 1);
|
||||
assert.equal(afterMovie?.progress.percent, 50);
|
||||
assert.equal(afterMovie?.progress.watchedMinutes, 118);
|
||||
assert.equal(afterMovie?.progress.remainingMinutes, 247);
|
||||
assert.equal(
|
||||
afterMovie?.nextUp?.type,
|
||||
"SERIES",
|
||||
"der Film ist durch, offen bleibt die Serie",
|
||||
);
|
||||
|
||||
// Zwei Episoden der Serie
|
||||
const seriesEntry = await addToLibrary(ownerId, seriesTitleId);
|
||||
const episodes = await prisma.episode.findMany({
|
||||
where: { titleId: seriesTitleId, seasonNumber: { gt: 0 } },
|
||||
orderBy: [{ seasonNumber: "asc" }, { episodeNumber: "asc" }],
|
||||
select: { id: true },
|
||||
});
|
||||
await markEpisodesWatched(ownerId, seriesEntry.id, [episodes[0]!.id, episodes[1]!.id]);
|
||||
|
||||
const afterEpisodes = await getUniverse(ownerId, universe.id);
|
||||
const series = afterEpisodes!.entries.find((entry) => entry.type === "SERIES")!;
|
||||
assert.equal(series.watchedEpisodes, 2);
|
||||
assert.equal(series.totalEpisodes, 5);
|
||||
assert.equal(series.completed, false);
|
||||
assert.ok(
|
||||
series.remainingMinutes < 247 && series.remainingMinutes > 0,
|
||||
"die Restlaufzeit schrumpft anteilig",
|
||||
);
|
||||
});
|
||||
|
||||
test("geteilte Universen zeigen jedem den eigenen Fortschritt", async () => {
|
||||
const universe = await createUniverse(ownerId, "Geteilt");
|
||||
await addTitle(ownerId, universe.id, movieTitleId);
|
||||
|
||||
const entry = await addToLibrary(ownerId, movieTitleId);
|
||||
await markMovieWatched(ownerId, entry.id);
|
||||
|
||||
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "READ");
|
||||
|
||||
const ownerView = await getUniverse(ownerId, universe.id);
|
||||
const guestView = await getUniverse(guestId, universe.id);
|
||||
|
||||
assert.equal(ownerView?.progress.completed, 1);
|
||||
assert.equal(guestView?.progress.completed, 0, "der Gast hat den Film noch nicht gesehen");
|
||||
assert.equal(guestView?.canWrite, false, "Leserecht bleibt Leserecht");
|
||||
assert.equal(guestView?.isOwner, false);
|
||||
});
|
||||
|
||||
test("Schreibrecht lässt sich vergeben, Löschen bleibt beim Besitzer", async () => {
|
||||
const universe = await createUniverse(ownerId, "Gemeinsam");
|
||||
await shareUniverse(ownerId, universe.id, GUEST_EMAIL, "WRITE");
|
||||
|
||||
const access = await getAccess(guestId, universe.id);
|
||||
assert.equal(access.canWrite, true);
|
||||
assert.equal(access.isOwner, false);
|
||||
|
||||
// Mit Schreibrecht darf der Gast Titel aufnehmen …
|
||||
await addTitle(guestId, universe.id, movieTitleId);
|
||||
const view = await getUniverse(guestId, universe.id);
|
||||
assert.equal(view?.entries.length, 1);
|
||||
|
||||
// … aber nicht löschen.
|
||||
await assert.rejects(() => trashUniverse(guestId, universe.id), /Nur wer das Universum angelegt hat/);
|
||||
});
|
||||
|
||||
test("Fremde sehen ein Universum gar nicht", async () => {
|
||||
const universe = await createUniverse(ownerId, "Privat");
|
||||
const access = await getAccess(guestId, universe.id);
|
||||
|
||||
assert.equal(access.canRead, false);
|
||||
assert.equal(await getUniverse(guestId, universe.id), null);
|
||||
await assert.rejects(() => addTitle(guestId, universe.id, movieTitleId), /gibt es nicht/);
|
||||
});
|
||||
|
||||
test("die Übersicht listet eigene und geteilte Universen", async () => {
|
||||
const own = await createUniverse(ownerId, "Meins");
|
||||
await addTitle(ownerId, own.id, movieTitleId);
|
||||
await addTitle(ownerId, own.id, movie2TitleId);
|
||||
await shareUniverse(ownerId, own.id, GUEST_EMAIL, "READ");
|
||||
|
||||
const entry = await addToLibrary(guestId, movieTitleId);
|
||||
await markMovieWatched(guestId, entry.id);
|
||||
|
||||
const guestList = await listUniverses(guestId);
|
||||
assert.equal(guestList.length, 1);
|
||||
assert.equal(guestList[0]?.isOwner, false);
|
||||
assert.equal(guestList[0]?.ownerName, "Besitzerin");
|
||||
assert.equal(guestList[0]?.itemCount, 2);
|
||||
assert.equal(guestList[0]?.completed, 1, "der Gast hat einen der beiden Filme gesehen");
|
||||
assert.equal(guestList[0]?.percent, 50);
|
||||
});
|
||||
|
||||
test("gelöschte Universen verschwinden aus der Übersicht", async () => {
|
||||
const universe = await createUniverse(ownerId, "Weg damit");
|
||||
await trashUniverse(ownerId, universe.id);
|
||||
|
||||
assert.equal(await getUniverse(ownerId, universe.id), null);
|
||||
const list = await listUniverses(ownerId);
|
||||
assert.equal(list.find((item) => item.id === universe.id), undefined);
|
||||
});
|
||||
Reference in New Issue
Block a user