5 Commits
Author SHA1 Message Date
Florian Wilde ed6011384a feat: Beispieluniversum im Seed und Notizen fuer 0.4.0 2026-09-01 19:39:35 +02:00
Florian Wilde 5cf4fc48a6 fix: server-only-Kette aus dem Browser-Bundle lösen
Die Posterkachel zog über lib/images.ts die serverseitige Cache-Logik in den
Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert wurde
— der Build brach ab, sobald eine solche Seite dazukam.

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

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

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

Ein geteiltes Universum zeigt jeder Person ihren eigenen Stand; bei Serien
schrumpft die Restlaufzeit anteilig mit den gesehenen Episoden.
2026-09-01 19:39:31 +02:00
18 changed files with 2298 additions and 35 deletions
+38 -1
View File
@@ -7,6 +7,42 @@ die Versionierung [Semantic Versioning](https://semver.org/lang/de/).
## [Unveröffentlicht] ## [Unveröffentlicht]
## [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 ## [0.3.0] — 2026-09-01
Das Kernstück: Bibliothek, Status und Episodenfortschritt. Das Kernstück: Bibliothek, Status und Episodenfortschritt.
@@ -132,7 +168,8 @@ Erste lauffähige Etappe: Gerüst, Marke, Datenmodell und Anmeldung.
dass weitere Sprachen als zusätzliche Datei dazukommen können. dass weitere Sprachen als zusätzliche Datei dazukommen können.
- **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation. - **Seed-Skript** mit Demo-Konten sowie README und Design-Dokumentation.
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.3.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/flimmer/compare/v0.4.0...HEAD
[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.3.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.3.0
[0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0 [0.2.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.2.0
[0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0 [0.1.0]: https://git.wilde-server.de/florian.wilde/flimmer/releases/tag/v0.1.0
+15 -1
View File
@@ -49,6 +49,19 @@ 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)
---
## Gesamtübersicht ## Gesamtübersicht
| Etappe | Version | Dauer | | Etappe | Version | Dauer |
@@ -56,5 +69,6 @@ Alle Zeiten per `date`-Befehl erfasst (Europe/Berlin), nicht geschätzt.
| 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min | | 1 — Gerüst, Branding, Docker, Schema, Auth | 0.1.0 | 38 min |
| 2 — TMDB-Anbindung | 0.2.0 | 14 min | | 2 — TMDB-Anbindung | 0.2.0 | 14 min |
| 3 — Kern-Tracking | 0.3.0 | 24 min | | 3 — Kern-Tracking | 0.3.0 | 24 min |
| 4 — Universen | 0.4.0 | 14 min |
**Bisher gesamt: 76 min** **Bisher gesamt: 90 min**
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flimmer", "name": "flimmer",
"version": "0.3.0", "version": "0.4.0",
"private": true, "private": true,
"description": "Flimmer — selbst hostbarer Serien- und Film-Tracker", "description": "Flimmer — selbst hostbarer Serien- und Film-Tracker",
"license": "MIT", "license": "MIT",
+76
View File
@@ -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() { async function main() {
@@ -509,6 +576,15 @@ async function main() {
console.log("Seed: Bibliothek und Sichtungen ..."); console.log("Seed: Bibliothek und Sichtungen ...");
await seedLibrary(admin.id, titleIds); 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 watches = await prisma.watch.count({ where: { userId: admin.id } });
const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } }); const entries = await prisma.libraryEntry.count({ where: { userId: admin.id } });
+35
View File
@@ -0,0 +1,35 @@
# Flimmer 0.4.0
Universen: zusammengehörige Titel, mehrere Reihenfolgen, ein Gesamtfortschritt.
### Hinzugefügt
- **Universen** aus beliebigen Filmen **und** Serien — MCU, Star Wars,
Wizarding World oder was auch immer zusammengehört.
- **Mehrere Reihenfolgen je Universum**. Die Erscheinungsreihenfolge wird aus
den Erscheinungsdaten berechnet und nie gespeichert — sie stimmt damit immer.
Weitere Reihenfolgen (Story-Chronologie, Einsteiger-Route) sind frei
sortierbar, per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop wäre mit
Tastatur nicht bedienbar und auf dem Handy fummelig.
- **TMDB-Collections als Startpunkt**: Reihen, die in der eigenen Bibliothek
vorkommen, lassen sich mit einem Klick übernehmen.
- **Universums-Detailseite** mit nummerierter Liste, Gesamtfortschritt
(„4 von 7 abgeschlossen, 21 h 21 min gesehen"), hervorgehobenem
„Als Nächstes dran" und der Restlaufzeit bis zum Ende des Universums.
Bei Serien schrumpft die Restzeit anteilig mit den gesehenen Episoden.
- **Teilen** mit anderen Konten der Instanz, wahlweise nur zum Ansehen oder
zum gemeinsamen Pflegen. Der Fortschritt ist dabei immer der des
jeweiligen Betrachters — ein geteiltes Universum zeigt jeder Person ihren
eigenen Stand. Löschen bleibt dem Besitzer vorbehalten.
- **12 weitere Tests** für Reihenfolgen, Fortschrittsberechnung über Filme und
Serien hinweg, Freigaben und die Rechteprüfung.
- Beispieluniversum im Seed: die Sternenpfad-Reihe mit fünf Filmen, zwei
Serien und einer abweichenden Story-Chronologie.
### Behoben
- Die Posterkachel zog über `lib/images.ts` die serverseitige Cache-Logik in
den Browser-Bundle, sobald sie aus einer Client-Komponente heraus gerendert
wurde — der Build brach ab, sobald eine solche Seite dazukam. Die reine
URL-Bildung liegt jetzt in `lib/image-url.ts`, der Dateicache bleibt
serverseitig.
+20
View File
@@ -0,0 +1,20 @@
import { chromium } from "playwright";
const [,, SP, ...specs] = process.argv;
const b = await chromium.launch();
const c = await b.newContext({ viewport: { width: 1280, height: 950 }, deviceScaleFactor: 2, colorScheme: "dark" });
const p = await c.newPage();
const errs = [];
p.on("pageerror", e => errs.push(String(e).slice(0,200)));
p.on("console", m => { if (m.type()==="error") errs.push("console: "+m.text().slice(0,160)); });
await p.goto("http://localhost:3011/anmelden");
await p.fill("#email", "demo@flimmer.local");
await p.fill("#password", "flimmer-demo-2026");
await Promise.all([p.waitForURL("http://localhost:3011/"), p.click("button[type=submit]")]);
for (const spec of specs) {
const [name, url, full] = spec.split("|");
await p.goto(`http://localhost:3011${url}`, { waitUntil: "networkidle" });
await p.waitForTimeout(900);
await p.screenshot({ path: `${SP}/${name}.png`, fullPage: full === "full" });
}
console.log("Fehler:", errs.length ? errs.slice(0,4) : "keine");
await b.close();
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getSessionUser } from "@/lib/auth";
import {
addCollection,
addTitle,
createOrder,
createUniverse,
deleteOrder,
removeTitle,
renameOrder,
renameUniverse,
setOrderPositions,
shareUniverse,
trashUniverse,
unshareUniverse,
UniverseError,
} from "@/lib/universes";
/** Server-Aktionen für Universen. Zugriffsrechte prüft die Domänenschicht. */
export type UniverseActionResult = { ok: boolean; message?: string; id?: string };
async function run(
fn: (userId: string) => Promise<{ message?: string; id?: string } | void>,
): Promise<UniverseActionResult> {
const user = await getSessionUser();
if (!user) return { ok: false, message: "Bitte melde dich an." };
try {
const result = (await fn(user.id)) ?? {};
revalidatePath("/universen", "layout");
revalidatePath("/", "layout");
return { ok: true, message: result.message, id: result.id };
} catch (error) {
if (error instanceof UniverseError) return { ok: false, message: error.message };
console.error("Universums-Aktion fehlgeschlagen:", error);
return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." };
}
}
const id = z.string().min(1).max(64);
const name = z.string().trim().min(1).max(120);
export async function createUniverseAction(rawName: string, description?: string) {
return run(async (userId) => {
const universe = await createUniverse(userId, name.parse(rawName), description);
return { message: `„${universe.name}" angelegt`, id: universe.id };
});
}
export async function renameUniverseAction(
universeId: string,
data: { name?: string; description?: string | null },
) {
return run(async (userId) => {
await renameUniverse(userId, id.parse(universeId), {
...(data.name !== undefined ? { name: name.parse(data.name) } : {}),
...(data.description !== undefined
? { description: z.string().max(2000).nullable().parse(data.description) }
: {}),
});
return { message: "Gespeichert" };
});
}
export async function trashUniverseAction(universeId: string) {
return run(async (userId) => {
await trashUniverse(userId, id.parse(universeId));
return { message: "Universum gelöscht" };
});
}
export async function addTitleToUniverseAction(universeId: string, titleId: string) {
return run(async (userId) => {
await addTitle(userId, id.parse(universeId), id.parse(titleId));
return { message: "Aufgenommen" };
});
}
export async function removeFromUniverseAction(universeId: string, itemId: string) {
return run(async (userId) => {
await removeTitle(userId, id.parse(universeId), id.parse(itemId));
return { message: "Aus dem Universum entfernt" };
});
}
export async function addCollectionAction(universeId: string, tmdbCollectionId: number) {
return run(async (userId) => {
const added = await addCollection(
userId,
id.parse(universeId),
z.number().int().parse(tmdbCollectionId),
);
return {
message:
added === 0
? "Alles daraus war schon drin"
: `${added} ${added === 1 ? "Titel" : "Titel"} übernommen`,
};
});
}
export async function createOrderAction(universeId: string, rawName: string) {
return run(async (userId) => {
const order = await createOrder(userId, id.parse(universeId), name.parse(rawName));
return { message: "Reihenfolge angelegt", id: order.id };
});
}
export async function renameOrderAction(orderId: string, rawName: string) {
return run(async (userId) => {
await renameOrder(userId, id.parse(orderId), name.parse(rawName));
return { message: "Umbenannt" };
});
}
export async function deleteOrderAction(orderId: string) {
return run(async (userId) => {
await deleteOrder(userId, id.parse(orderId));
return { message: "Reihenfolge gelöscht" };
});
}
export async function reorderAction(orderId: string, itemIds: string[]) {
return run(async (userId) => {
await setOrderPositions(userId, id.parse(orderId), z.array(id).max(1000).parse(itemIds));
return { message: "Reihenfolge gespeichert" };
});
}
export async function shareUniverseAction(
universeId: string,
email: string,
permission: "READ" | "WRITE",
) {
return run(async (userId) => {
const target = await shareUniverse(
userId,
id.parse(universeId),
z.string().trim().email().parse(email),
z.enum(["READ", "WRITE"]).parse(permission),
);
return { message: `Mit ${target.name} geteilt` };
});
}
export async function unshareUniverseAction(universeId: string, memberId: string) {
return run(async (userId) => {
await unshareUniverse(userId, id.parse(universeId), id.parse(memberId));
return { message: "Freigabe aufgehoben" };
});
}
+193
View File
@@ -0,0 +1,193 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { prisma } from "@/lib/db";
import { getUniverse, suggestCollections } from "@/lib/universes";
import { formatMinutes } from "@/lib/labels";
import { AppShell } from "@/components/AppShell";
import { ProgressBar } from "@/components/ProgressBar";
import { OrderedList } from "@/components/universes/OrderedList";
import {
AddTitles,
DeleteUniverse,
OrderTabs,
SharePanel,
} from "@/components/universes/UniverseTools";
type Params = Promise<{ id: string }>;
type Search = Promise<{ reihenfolge?: string }>;
export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { id } = await params;
const universe = await prisma.universe.findUnique({ where: { id }, select: { name: true } });
return { title: universe?.name ?? "Universum" };
}
export default async function UniversePage({
params,
searchParams,
}: {
params: Params;
searchParams: Search;
}) {
const user = await requireUser();
const settings = await getSettings();
const { id } = await params;
const { reihenfolge } = await searchParams;
const universe = await getUniverse(user.id, id, reihenfolge);
if (!universe) notFound();
const activeOrder = universe.orders.find((order) => order.id === universe.activeOrderId);
const inUniverse = new Set(universe.entries.map((entry) => entry.titleId));
// Vorschläge zum Aufnehmen: alles aus der eigenen Bibliothek, was noch fehlt.
const [libraryTitles, collections] = universe.canWrite
? await Promise.all([
prisma.libraryEntry.findMany({
where: { userId: user.id, deletedAt: null },
orderBy: { title: { title: "asc" } },
select: {
title: { select: { id: true, title: true, type: true, releaseDate: true } },
},
}),
suggestCollections(user.id),
])
: [[], []];
const candidates = libraryTitles
.map((entry) => entry.title)
.filter((title) => !inUniverse.has(title.id))
.map((title) => ({
id: title.id,
title: title.title,
type: title.type,
year: title.releaseDate?.getFullYear() ?? null,
}));
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-5">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Universum
{!universe.isOwner && ` · geteilt von ${universe.ownerName}`}
{!universe.canWrite && " · nur ansehen"}
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{universe.name}</h1>
{universe.description && (
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">{universe.description}</p>
)}
</header>
<section className="fl-card mb-5 p-5" aria-label="Gesamtfortschritt">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<p className="text-2xl font-semibold">
{universe.progress.completed}{" "}
<span className="text-base font-normal text-[var(--fl-muted)]">
von {universe.progress.total} abgeschlossen
</span>
</p>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
{formatMinutes(universe.progress.watchedMinutes)} gesehen
{universe.progress.remainingMinutes > 0 && (
<>
{" · noch "}
<span className="text-[var(--fl-text)]">
{formatMinutes(universe.progress.remainingMinutes)}
</span>
{" bis zum Ende des Universums"}
</>
)}
</p>
</div>
{universe.nextUp && (
<div className="text-right">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<Link
href={`/titel/${universe.nextUp.titleId}`}
className="fl-gradient-text text-lg font-semibold"
>
{universe.nextUp.title}
</Link>
</div>
)}
</div>
<div className="mt-4">
<ProgressBar
percent={universe.progress.percent}
label={`${universe.progress.percent} Prozent des Universums abgeschlossen`}
/>
</div>
</section>
<div className="mb-4">
<OrderTabs universe={universe} canWrite={universe.canWrite} />
{activeOrder?.kind === "RELEASE" ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell.
</p>
) : universe.canWrite ? (
<p className="mt-2 text-xs text-[var(--fl-faint)]">
Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort.
</p>
) : null}
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="order-2 lg:order-1">
<OrderedList
universeId={universe.id}
orderId={universe.activeOrderId}
entries={universe.entries}
sortable={activeOrder?.kind === "CUSTOM"}
canWrite={universe.canWrite}
nextUpItemId={universe.nextUp?.itemId ?? null}
/>
</div>
<aside className="order-1 space-y-5 lg:order-2">
{universe.canWrite && (
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Titel aufnehmen
</h2>
<AddTitles
universeId={universe.id}
candidates={candidates}
collections={collections}
/>
</div>
)}
{universe.isOwner && (
<>
<div className="fl-card p-4">
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Teilen
</h2>
<SharePanel universe={universe} />
</div>
<div className="fl-card p-4">
<DeleteUniverse universeId={universe.id} name={universe.name} />
</div>
</>
)}
</aside>
</div>
<p className="mt-8 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Alle Universen
</Link>
</p>
</AppShell>
);
}
+38
View File
@@ -0,0 +1,38 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { AppShell } from "@/components/AppShell";
import { NewUniverseForm } from "@/components/universes/UniverseTools";
export const metadata: Metadata = { title: "Neues Universum" };
export default async function NewUniversePage() {
const user = await requireUser("/universen/neu");
const settings = await getSettings();
return (
<AppShell user={user} instanceName={settings.instanceName}>
<div className="mx-auto max-w-xl">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Neues Universum</h1>
<p className="mt-1 text-sm text-[var(--fl-muted)]">
Ein Universum bündelt Filme und Serien, die zusammengehören. Die
Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du
danach an.
</p>
</header>
<div className="fl-card p-5 sm:p-6">
<NewUniverseForm />
</div>
<p className="mt-6 text-sm">
<Link href="/universen" className="text-[var(--fl-faint)] hover:text-[var(--fl-muted)]">
← Zurück zur Übersicht
</Link>
</p>
</div>
</AppShell>
);
}
+90
View File
@@ -0,0 +1,90 @@
import Link from "next/link";
import type { Metadata } from "next";
import { requireUser } from "@/lib/auth";
import { getSettings } from "@/lib/settings";
import { listUniverses } from "@/lib/universes";
import { AppShell } from "@/components/AppShell";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
export const metadata: Metadata = { title: "Universen" };
export default async function UniversesPage() {
const user = await requireUser("/universen");
const settings = await getSettings();
const universes = await listUniverses(user.id);
return (
<AppShell user={user} instanceName={settings.instanceName}>
<header className="mb-6 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight sm:text-3xl">Universen</h1>
<p className="mt-1 max-w-2xl text-sm text-[var(--fl-muted)]">
Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem
Gesamtfortschritt.
</p>
</div>
<Link href="/universen/neu" className="fl-btn fl-btn-primary">
Neues Universum
</Link>
</header>
{universes.length === 0 ? (
<div className="fl-card p-8 text-center">
<p className="mx-auto max-w-md text-[var(--fl-muted)]">
Noch kein Universum angelegt. Ein Universum bündelt zusammengehörige Titel — etwa alle
MCU-Filme samt Serien — und zeigt dir, was als Nächstes dran ist.
</p>
<Link href="/universen/neu" className="fl-btn fl-btn-primary mt-4">
Erstes Universum anlegen
</Link>
</div>
) : (
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{universes.map((universe) => (
<li key={universe.id}>
<Link
href={`/universen/${universe.id}`}
className="fl-card fl-poster-card block overflow-hidden p-4"
>
<div className="mb-3 flex gap-1.5">
{universe.posters.length > 0 ? (
universe.posters.map((path, index) => (
<div key={`${path}-${index}`} className="w-1/5">
<Poster path={path} alt="" size="w154" />
</div>
))
) : (
<div className="w-1/5">
<Poster path={null} alt={universe.name} size="w154" />
</div>
)}
</div>
<h2 className="font-semibold">{universe.name}</h2>
{universe.description && (
<p className="mt-1 line-clamp-2 text-sm text-[var(--fl-muted)]">
{universe.description}
</p>
)}
<p className="mt-2 text-xs text-[var(--fl-faint)]">
{universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"}
{!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""}
</p>
<div className="mt-2">
<ProgressBar
percent={universe.percent}
label={`${universe.completed} von ${universe.itemCount} abgeschlossen`}
showValue
/>
</div>
</Link>
</li>
))}
</ul>
)}
</AppShell>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { clsx } from "clsx"; 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"; import { FlimmerMark } from "@/components/brand/FlimmerLogo";
/** /**
+1 -1
View File
@@ -1,4 +1,4 @@
import { posterUrl } from "@/lib/images"; import { posterUrl } from "@/lib/image-url";
/** /**
* Streamingdienste als Badges. * Streamingdienste als Badges.
+272
View File
@@ -0,0 +1,272 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { clsx } from "clsx";
import { reorderAction, removeFromUniverseAction } from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { formatMinutes, STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import type { UniverseEntry } from "@/lib/universes";
/**
* Nummerierte Liste eines Universums in der gewählten Reihenfolge.
*
* Umsortieren geht per Ziehen **und** über Pfeilknöpfe. Reines Drag & Drop
* wäre mit Tastatur nicht bedienbar und auf dem Handy fummelig; die Knöpfe
* sind der verlässliche Weg, das Ziehen der bequeme.
*
* Die Erscheinungsreihenfolge (`sortable = false`) ergibt sich aus den Daten
* und lässt sich deshalb gar nicht erst anfassen.
*/
export function OrderedList({
universeId,
orderId,
entries,
sortable,
canWrite,
nextUpItemId,
}: {
universeId: string;
orderId: string;
entries: UniverseEntry[];
sortable: boolean;
canWrite: boolean;
nextUpItemId: string | null;
}) {
const router = useRouter();
const { report } = useToasts();
const [items, setItems] = useState(entries);
const [dragging, setDragging] = useState<string | null>(null);
const [, startTransition] = useTransition();
useEffect(() => setItems(entries), [entries]);
const interactive = sortable && canWrite;
function persist(next: UniverseEntry[]) {
setItems(next);
startTransition(async () => {
const result = await reorderAction(
orderId,
next.map((entry) => entry.itemId),
);
if (!result.ok) {
report(result);
setItems(entries);
}
router.refresh();
});
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= items.length) return;
const next = [...items];
const [moved] = next.splice(index, 1);
if (moved) next.splice(target, 0, moved);
persist(next);
}
function dropOn(targetItemId: string) {
if (!dragging || dragging === targetItemId) return;
const from = items.findIndex((entry) => entry.itemId === dragging);
const to = items.findIndex((entry) => entry.itemId === targetItemId);
if (from < 0 || to < 0) return;
const next = [...items];
const [moved] = next.splice(from, 1);
if (moved) next.splice(to, 0, moved);
setDragging(null);
persist(next);
}
if (items.length === 0) {
return (
<div className="fl-card p-8 text-center">
<p className="text-[var(--fl-muted)]">
Dieses Universum ist noch leer. Nimm Filme und Serien aus deiner Bibliothek auf.
</p>
</div>
);
}
return (
<ol className="space-y-2">
{items.map((entry, index) => (
<li
key={entry.itemId}
draggable={interactive}
onDragStart={() => setDragging(entry.itemId)}
onDragEnd={() => setDragging(null)}
onDragOver={(event) => {
if (interactive) event.preventDefault();
}}
onDrop={() => dropOn(entry.itemId)}
className={clsx(
"fl-card flex items-center gap-3 p-2.5 transition-opacity sm:gap-4 sm:p-3",
dragging === entry.itemId && "opacity-40",
entry.itemId === nextUpItemId && "ring-1",
)}
style={
entry.itemId === nextUpItemId
? {
boxShadow: "var(--fl-glow)",
borderColor: "color-mix(in oklab, var(--fl-magenta) 45%, transparent)",
}
: undefined
}
>
<span
aria-hidden
className={clsx(
"w-7 shrink-0 text-center font-mono text-sm",
entry.completed ? "text-[var(--fl-success)]" : "text-[var(--fl-faint)]",
)}
>
{entry.completed ? "✓" : index + 1}
</span>
<Link href={`/titel/${entry.titleId}`} className="w-12 shrink-0 sm:w-14">
<Poster path={entry.posterPath} alt={entry.title} size="w154" />
</Link>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/titel/${entry.titleId}`}
className="font-medium hover:text-[var(--fl-accent)]"
>
{entry.title}
</Link>
{entry.itemId === nextUpItemId && (
<span
className="rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
Als Nächstes dran
</span>
)}
{entry.status && (
<span
className="text-[0.65rem] font-medium"
style={{ color: STATUS_COLOR[entry.status as StatusKey] }}
>
{STATUS_LABEL[entry.status as StatusKey]}
</span>
)}
</div>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{entry.type === "SERIES" ? "Serie" : "Film"}
{entry.year ? ` · ${entry.year}` : ""}
{entry.type === "SERIES"
? ` · ${entry.watchedEpisodes}/${entry.totalEpisodes} Episoden`
: ""}
{entry.remainingMinutes > 0 ? ` · noch ${formatMinutes(entry.remainingMinutes)}` : ""}
</p>
{entry.type === "SERIES" && entry.totalEpisodes > 0 && (
<div className="mt-1.5 max-w-xs">
<ProgressBar
percent={(entry.watchedEpisodes / entry.totalEpisodes) * 100}
label={`${entry.watchedEpisodes} von ${entry.totalEpisodes} Episoden gesehen`}
/>
</div>
)}
</div>
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
{interactive && (
<>
<IconButton
label={`${entry.title} nach oben`}
disabled={index === 0}
onClick={() => move(index, -1)}
>
<path d="m6 15 6-6 6 6" />
</IconButton>
<IconButton
label={`${entry.title} nach unten`}
disabled={index === items.length - 1}
onClick={() => move(index, 1)}
>
<path d="m6 9 6 6 6-6" />
</IconButton>
</>
)}
<RemoveButton universeId={universeId} itemId={entry.itemId} title={entry.title} />
</div>
)}
</li>
))}
</ol>
);
}
function IconButton({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className="rounded-lg p-1.5 text-[var(--fl-faint)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)] disabled:opacity-30 disabled:hover:bg-transparent"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{children}
</svg>
</button>
);
}
function RemoveButton({
universeId,
itemId,
title,
}: {
universeId: string;
itemId: string;
title: string;
}) {
const router = useRouter();
const { report } = useToasts();
const [, startTransition] = useTransition();
return (
<IconButton
label={`${title} aus dem Universum entfernen`}
onClick={() =>
startTransition(async () => {
report(await removeFromUniverseAction(universeId, itemId));
router.refresh();
})
}
>
<path d="m6 6 12 12M18 6 6 18" />
</IconButton>
);
}
+401
View File
@@ -0,0 +1,401 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import {
addCollectionAction,
addTitleToUniverseAction,
createOrderAction,
createUniverseAction,
deleteOrderAction,
shareUniverseAction,
trashUniverseAction,
unshareUniverseAction,
} from "@/app/actions/universes";
import { useToasts } from "@/components/Toasts";
import type { UniverseView } from "@/lib/universes";
/** Formular für ein neues Universum. */
export function NewUniverseForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
return (
<form
action={(formData) => {
const name = String(formData.get("name") ?? "").trim();
if (!name) return;
startTransition(async () => {
const result = await createUniverseAction(
name,
String(formData.get("description") ?? ""),
);
report(result);
if (result.ok && result.id) router.push(`/universen/${result.id}`);
});
}}
className="space-y-4"
>
<div className="space-y-1.5">
<label htmlFor="name" className="block text-sm font-medium text-[var(--fl-muted)]">
Name
</label>
<input
id="name"
name="name"
required
autoFocus
placeholder="MCU, Star Wars, Wizarding World …"
className="fl-input"
/>
</div>
<div className="space-y-1.5">
<label htmlFor="description" className="block text-sm font-medium text-[var(--fl-muted)]">
Beschreibung <span className="text-xs text-[var(--fl-faint)]">(optional)</span>
</label>
<textarea id="description" name="description" rows={3} className="fl-input resize-y" />
</div>
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Universum anlegen"}
</button>
</form>
);
}
/** Umschalter zwischen den Reihenfolgen eines Universums. */
export function OrderTabs({
universe,
canWrite,
}: {
universe: UniverseView;
canWrite: boolean;
}) {
const router = useRouter();
const params = useSearchParams();
const { report } = useToasts();
const [adding, setAdding] = useState(false);
const [, startTransition] = useTransition();
function select(orderId: string) {
const next = new URLSearchParams(params.toString());
next.set("reihenfolge", orderId);
router.replace(`/universen/${universe.id}?${next}`, { scroll: false });
}
const active = universe.orders.find((order) => order.id === universe.activeOrderId);
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Reihenfolge">
{universe.orders.map((order) => {
const isActive = order.id === universe.activeOrderId;
return (
<button
key={order.id}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => select(order.id)}
className={clsx(
"rounded-full border px-3.5 py-1.5 text-sm font-medium transition-colors",
isActive
? "border-transparent text-[var(--fl-on-accent)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={isActive ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{order.name}
</button>
);
})}
</div>
{canWrite && (
<>
{adding ? (
<form
action={(formData) => {
const name = String(formData.get("orderName") ?? "").trim();
if (!name) return;
setAdding(false);
startTransition(async () => {
const result = await createOrderAction(universe.id, name);
report(result);
if (result.ok && result.id) select(result.id);
router.refresh();
});
}}
className="flex items-center gap-1.5"
>
<input
name="orderName"
autoFocus
placeholder="z. B. Chronologisch"
className="fl-input w-48 py-1.5 text-sm"
/>
<button type="submit" className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Anlegen
</button>
<button
type="button"
onClick={() => setAdding(false)}
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</form>
) : (
<button
type="button"
onClick={() => setAdding(true)}
className="rounded-full border border-dashed border-[var(--fl-border-strong)] px-3.5 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:text-[var(--fl-text)]"
>
+ Reihenfolge
</button>
)}
{active && active.kind === "CUSTOM" && (
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await deleteOrderAction(active.id));
router.replace(`/universen/${universe.id}`);
router.refresh();
})
}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
„{active.name}" löschen
</button>
)}
</>
)}
</div>
);
}
/** Titel aus der eigenen Bibliothek aufnehmen — mit Tippfilter. */
export function AddTitles({
universeId,
candidates,
collections,
}: {
universeId: string;
candidates: { id: string; title: string; type: string; year: number | null }[];
collections: { id: number; name: string }[];
}) {
const router = useRouter();
const { report } = useToasts();
const [query, setQuery] = useState("");
const [pending, startTransition] = useTransition();
const filtered = query.trim()
? candidates
.filter((item) => item.title.toLocaleLowerCase("de-DE").includes(query.toLocaleLowerCase("de-DE")))
.slice(0, 8)
: candidates.slice(0, 8);
return (
<div className="space-y-4">
{collections.length > 0 && (
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus einer TMDB-Reihe übernehmen
</h3>
<div className="flex flex-wrap gap-1.5">
{collections.map((collection) => (
<button
key={collection.id}
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addCollectionAction(universeId, collection.id));
router.refresh();
})
}
className="rounded-full border border-[var(--fl-border)] px-3 py-1.5 text-sm text-[var(--fl-muted)] transition-colors hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]"
>
{collection.name}
</button>
))}
</div>
</div>
)}
<div>
<h3 className="mb-2 text-sm font-semibold text-[var(--fl-muted)]">
Aus der Bibliothek aufnehmen
</h3>
{candidates.length === 0 ? (
<p className="text-sm text-[var(--fl-faint)]">
Alles aus deiner Bibliothek ist schon in diesem Universum.
</p>
) : (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Titel filtern …"
aria-label="Titel filtern"
className="fl-input mb-2"
/>
<ul className="space-y-1">
{filtered.map((item) => (
<li key={item.id}>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await addTitleToUniverseAction(universeId, item.id));
router.refresh();
})
}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-[var(--fl-surface-2)]"
>
<span className="min-w-0 truncate">{item.title}</span>
<span className="ml-3 shrink-0 text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
</span>
</button>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-[var(--fl-faint)]">Kein Treffer.</li>
)}
</ul>
</>
)}
</div>
</div>
);
}
/** Freigabe an andere Konten der Instanz. */
export function SharePanel({ universe }: { universe: UniverseView }) {
const router = useRouter();
const { report } = useToasts();
const [permission, setPermission] = useState<"READ" | "WRITE">("READ");
const [pending, startTransition] = useTransition();
return (
<div className="space-y-3">
<form
action={(formData) => {
const email = String(formData.get("email") ?? "").trim();
if (!email) return;
startTransition(async () => {
report(await shareUniverseAction(universe.id, email, permission));
router.refresh();
});
}}
className="space-y-2"
>
<label htmlFor="share-email" className="block text-sm font-medium text-[var(--fl-muted)]">
Mit jemandem auf dieser Instanz teilen
</label>
<input
id="share-email"
name="email"
type="email"
placeholder="person@beispiel.de"
className="fl-input"
/>
<div className="flex flex-wrap items-center gap-2">
<select
value={permission}
onChange={(event) => setPermission(event.target.value as "READ" | "WRITE")}
aria-label="Recht"
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm"
>
<option value="READ">Nur ansehen</option>
<option value="WRITE">Gemeinsam pflegen</option>
</select>
<button type="submit" disabled={pending} className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm">
Teilen
</button>
</div>
</form>
{universe.members.length > 0 && (
<ul className="space-y-1 border-t border-[var(--fl-border)] pt-3">
{universe.members.map((member) => (
<li key={member.id} className="flex items-center gap-2 text-sm">
<span className="min-w-0 flex-1 truncate">
{member.name}
<span className="ml-1.5 text-xs text-[var(--fl-faint)]">
{member.permission === "WRITE" ? "pflegt mit" : "liest mit"}
</span>
</span>
<button
type="button"
onClick={() =>
startTransition(async () => {
report(await unshareUniverseAction(universe.id, member.id));
router.refresh();
})
}
className="text-xs text-[var(--fl-faint)] underline-offset-2 hover:text-[var(--fl-danger)] hover:underline"
>
entfernen
</button>
</li>
))}
</ul>
)}
</div>
);
}
/** Universum löschen — mit Rückfrage. */
export function DeleteUniverse({ universeId, name }: { universeId: string; name: string }) {
const router = useRouter();
const { report } = useToasts();
const [confirming, setConfirming] = useState(false);
const [pending, startTransition] = useTransition();
if (!confirming) {
return (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Universum löschen
</button>
);
}
return (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-[var(--fl-muted)]">„{name}" wirklich löschen?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await trashUniverseAction(universeId));
router.push("/universen");
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
>
Ja, löschen
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</div>
);
}
+42
View File
@@ -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
View File
@@ -4,6 +4,7 @@ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { env } from "./env"; import { env } from "./env";
import { IMAGE_BASE } from "./tmdb"; import { IMAGE_BASE } from "./tmdb";
import type { ImageSize } from "./image-url";
/** /**
* Lokaler Bild-Zwischenspeicher. * Lokaler Bild-Zwischenspeicher.
@@ -12,32 +13,13 @@ import { IMAGE_BASE } from "./tmdb";
* eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch * eigenen Server ausgeliefert. Das spart Anfragen, macht die Oberfläche auch
* ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf * ohne Internetverbindung nutzbar und verhindert, dass jeder Seitenaufruf
* einen Abruf bei einem Dritten auslöst. * 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 { IMAGE_SIZES, isValidImagePath, isValidSize, posterUrl } from "./image-url";
export const IMAGE_SIZES = [ export type { ImageSize } from "./image-url";
"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);
}
function cacheDir() { function cacheDir() {
return join(env.dataDir, "images"); return join(env.dataDir, "images");
@@ -99,9 +81,3 @@ export async function getCachedImage(size: ImageSize, path: string): Promise<Cac
fromCache: false, 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)}`;
}
+632
View File
@@ -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! }));
}
+282
View File
@@ -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);
});