Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed6011384a | ||
|
|
5cf4fc48a6 | ||
|
|
5ee6946fd5 | ||
|
|
90497ddc3b | ||
|
|
190d7b3796 |
+38
-1
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|
||||||
|
|||||||
@@ -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,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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,4 +1,4 @@
|
|||||||
import { posterUrl } from "@/lib/images";
|
import { posterUrl } from "@/lib/image-url";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streamingdienste als Badges.
|
* Streamingdienste als Badges.
|
||||||
|
|||||||
@@ -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 { 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)}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,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