diff --git a/src/app/actions/universes.ts b/src/app/actions/universes.ts new file mode 100644 index 0000000..14e5613 --- /dev/null +++ b/src/app/actions/universes.ts @@ -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 { + 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" }; + }); +} diff --git a/src/app/universen/[id]/page.tsx b/src/app/universen/[id]/page.tsx new file mode 100644 index 0000000..c26c05e --- /dev/null +++ b/src/app/universen/[id]/page.tsx @@ -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 { + 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 ( + +
+

+ Universum + {!universe.isOwner && ` · geteilt von ${universe.ownerName}`} + {!universe.canWrite && " · nur ansehen"} +

+

{universe.name}

+ {universe.description && ( +

{universe.description}

+ )} +
+ +
+
+
+

+ {universe.progress.completed}{" "} + + von {universe.progress.total} abgeschlossen + +

+

+ {formatMinutes(universe.progress.watchedMinutes)} gesehen + {universe.progress.remainingMinutes > 0 && ( + <> + {" · noch "} + + {formatMinutes(universe.progress.remainingMinutes)} + + {" bis zum Ende des Universums"} + + )} +

+
+ + {universe.nextUp && ( +
+

+ Als Nächstes dran +

+ + {universe.nextUp.title} + +
+ )} +
+ +
+ +
+
+ +
+ + {activeOrder?.kind === "RELEASE" ? ( +

+ Diese Reihenfolge ergibt sich aus den Erscheinungsdaten und bleibt damit immer aktuell. +

+ ) : universe.canWrite ? ( +

+ Ziehen oder die Pfeile benutzen, um umzusortieren — gespeichert wird sofort. +

+ ) : null} +
+ +
+
+ +
+ + +
+ +

+ + ← Alle Universen + +

+
+ ); +} diff --git a/src/app/universen/neu/page.tsx b/src/app/universen/neu/page.tsx new file mode 100644 index 0000000..f7b29ed --- /dev/null +++ b/src/app/universen/neu/page.tsx @@ -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 ( + +
+
+

Neues Universum

+

+ Ein Universum bündelt Filme und Serien, die zusammengehören. Die + Erscheinungsreihenfolge ist von Anfang an dabei; weitere Reihenfolgen legst du + danach an. +

+
+ +
+ +
+ +

+ + ← Zurück zur Übersicht + +

+
+
+ ); +} diff --git a/src/app/universen/page.tsx b/src/app/universen/page.tsx new file mode 100644 index 0000000..33cd93b --- /dev/null +++ b/src/app/universen/page.tsx @@ -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 ( + +
+
+

Universen

+

+ Filme und Serien, die zusammengehören — mit mehreren Reihenfolgen und einem + Gesamtfortschritt. +

+
+ + Neues Universum + +
+ + {universes.length === 0 ? ( +
+

+ 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. +

+ + Erstes Universum anlegen + +
+ ) : ( +
    + {universes.map((universe) => ( +
  • + +
    + {universe.posters.length > 0 ? ( + universe.posters.map((path, index) => ( +
    + +
    + )) + ) : ( +
    + +
    + )} +
    + +

    {universe.name}

    + {universe.description && ( +

    + {universe.description} +

    + )} + +

    + {universe.itemCount} {universe.itemCount === 1 ? "Titel" : "Titel"} + {!universe.isOwner ? ` · geteilt von ${universe.ownerName}` : ""} +

    + +
    + +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/universes/OrderedList.tsx b/src/components/universes/OrderedList.tsx new file mode 100644 index 0000000..18204e2 --- /dev/null +++ b/src/components/universes/OrderedList.tsx @@ -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(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 ( +
+

+ Dieses Universum ist noch leer. Nimm Filme und Serien aus deiner Bibliothek auf. +

+
+ ); + } + + return ( +
    + {items.map((entry, index) => ( +
  1. 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 + } + > + + {entry.completed ? "✓" : index + 1} + + + + + + +
    +
    + + {entry.title} + + {entry.itemId === nextUpItemId && ( + + Als Nächstes dran + + )} + {entry.status && ( + + {STATUS_LABEL[entry.status as StatusKey]} + + )} +
    + +

    + {entry.type === "SERIES" ? "Serie" : "Film"} + {entry.year ? ` · ${entry.year}` : ""} + {entry.type === "SERIES" + ? ` · ${entry.watchedEpisodes}/${entry.totalEpisodes} Episoden` + : ""} + {entry.remainingMinutes > 0 ? ` · noch ${formatMinutes(entry.remainingMinutes)}` : ""} +

    + + {entry.type === "SERIES" && entry.totalEpisodes > 0 && ( +
    + +
    + )} +
    + + {canWrite && ( +
    + {interactive && ( + <> + move(index, -1)} + > + + + move(index, 1)} + > + + + + )} + +
    + )} +
  2. + ))} +
+ ); +} + +function IconButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled?: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function RemoveButton({ + universeId, + itemId, + title, +}: { + universeId: string; + itemId: string; + title: string; +}) { + const router = useRouter(); + const { report } = useToasts(); + const [, startTransition] = useTransition(); + + return ( + + startTransition(async () => { + report(await removeFromUniverseAction(universeId, itemId)); + router.refresh(); + }) + } + > + + + ); +} diff --git a/src/components/universes/UniverseTools.tsx b/src/components/universes/UniverseTools.tsx new file mode 100644 index 0000000..2438537 --- /dev/null +++ b/src/components/universes/UniverseTools.tsx @@ -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 ( +
{ + 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" + > +
+ + +
+ +
+ +