From f613136e8a4d219e4fd497c1c5b4326c88819488 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 1 Sep 2026 19:24:51 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Bedienelemente=20f=C3=BCr=20Bibliothek?= =?UTF-8?q?=20und=20Episodenfortschritt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-Aktionen prüfen die Sitzung jeweils selbst — auf Angaben aus dem Formular ist beim Zugriffsschutz kein Verlass. Häkchen reagieren sofort und werden zurückgedreht, wenn der Server widerspricht: Abhaken soll sich anfühlen wie ein Schalter, nicht wie ein Formular. Jede Sichtungsaktion meldet sich unten kurz zurück und lässt sich von dort zurücknehmen — die Wirkung passiert weit weg vom Klick. --- src/app/actions/library.ts | 285 ++++++++++++++++++ src/app/globals.css | 12 + src/app/layout.tsx | 3 +- src/components/ProgressBar.tsx | 37 +++ src/components/Toasts.tsx | 136 +++++++++ src/components/library/EntryControls.tsx | 232 ++++++++++++++ src/components/library/EntryNotes.tsx | 194 ++++++++++++ src/components/library/LibraryCard.tsx | 71 +++++ src/components/library/LibraryFilters.tsx | 214 +++++++++++++ src/components/library/ManualTitleForm.tsx | 156 ++++++++++ src/components/library/NextEpisode.tsx | 123 ++++++++ src/components/library/SeasonList.tsx | 335 +++++++++++++++++++++ src/components/library/TrashActions.tsx | 69 +++++ 13 files changed, 1866 insertions(+), 1 deletion(-) create mode 100644 src/app/actions/library.ts create mode 100644 src/components/ProgressBar.tsx create mode 100644 src/components/Toasts.tsx create mode 100644 src/components/library/EntryControls.tsx create mode 100644 src/components/library/EntryNotes.tsx create mode 100644 src/components/library/LibraryCard.tsx create mode 100644 src/components/library/LibraryFilters.tsx create mode 100644 src/components/library/ManualTitleForm.tsx create mode 100644 src/components/library/NextEpisode.tsx create mode 100644 src/components/library/SeasonList.tsx create mode 100644 src/components/library/TrashActions.tsx diff --git a/src/app/actions/library.ts b/src/app/actions/library.ts new file mode 100644 index 0000000..2781ca2 --- /dev/null +++ b/src/app/actions/library.ts @@ -0,0 +1,285 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getSessionUser } from "@/lib/auth"; +import { + addToLibrary, + LibraryError, + markEpisodesWatched, + markMovieWatched, + markSeasonWatched, + markUpTo, + moveToTrash, + purgeEntry, + restoreFromTrash, + setStatus, + startRewatch, + undoWatches, + unmarkEpisodes, + unmarkSeason, + updateEntryDetails, + watchNextEpisode, + type WatchResult, +} from "@/lib/library"; +import { prisma } from "@/lib/db"; + +/** + * Server-Aktionen rund um die Bibliothek. + * + * Jede Aktion holt sich die Sitzung selbst — auf Angaben aus dem Formular ist + * beim Zugriffsschutz kein Verlass. Zurück kommt immer dieselbe Hülle, damit + * das UI Erfolg, Fehler und Rückgängig-Möglichkeit einheitlich behandeln kann. + */ + +export type ActionResult = { + ok: boolean; + message?: string; + /** Neu angelegte Sichtungen — Grundlage für „Rückgängig". */ + watchIds?: string[]; +}; + +async function run( + fn: (userId: string) => Promise<{ message?: string; watchIds?: string[] } | void>, + revalidate: string[] = [], +): Promise { + const user = await getSessionUser(); + if (!user) return { ok: false, message: "Bitte melde dich an." }; + + try { + const result = (await fn(user.id)) ?? {}; + for (const path of revalidate) revalidatePath(path, "layout"); + return { ok: true, message: result.message, watchIds: result.watchIds }; + } catch (error) { + if (error instanceof LibraryError) return { ok: false, message: error.message }; + console.error("Bibliotheksaktion fehlgeschlagen:", error); + return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." }; + } +} + +const PATHS = ["/", "/bibliothek", "/titel", "/papierkorb", "/kalender", "/statistik"]; + +const cuid = z.string().min(1).max(64); +const statusSchema = z.enum(["WATCHING", "PAUSED", "COMPLETED", "DROPPED", "WATCHLIST"]); + +// --------------------------------------------------------------------------- +// Aufnehmen und entfernen +// --------------------------------------------------------------------------- + +export async function addTitleAction(titleId: string, status: string = "WATCHLIST") { + return run(async (userId) => { + const parsedStatus = statusSchema.parse(status); + await addToLibrary(userId, cuid.parse(titleId), parsedStatus); + return { + message: + parsedStatus === "WATCHLIST" ? "Auf die Watchlist gesetzt" : "Zur Bibliothek hinzugefügt", + }; + }, PATHS); +} + +export async function trashEntryAction(entryId: string) { + return run(async (userId) => { + await moveToTrash(userId, cuid.parse(entryId)); + return { message: "In den Papierkorb gelegt" }; + }, PATHS); +} + +export async function restoreEntryAction(entryId: string) { + return run(async (userId) => { + await restoreFromTrash(userId, cuid.parse(entryId)); + return { message: "Wiederhergestellt" }; + }, PATHS); +} + +export async function purgeEntryAction(entryId: string) { + return run(async (userId) => { + await purgeEntry(userId, cuid.parse(entryId)); + return { message: "Endgültig gelöscht" }; + }, PATHS); +} + +// --------------------------------------------------------------------------- +// Status und Angaben +// --------------------------------------------------------------------------- + +export async function setStatusAction(entryId: string, status: string) { + return run(async (userId) => { + await setStatus(userId, cuid.parse(entryId), statusSchema.parse(status)); + return { message: "Status geändert" }; + }, PATHS); +} + +const detailsSchema = z.object({ + rating: z.number().int().min(1).max(10).nullable().optional(), + notes: z.string().max(5000).nullable().optional(), + whereToWatch: z.string().max(120).nullable().optional(), + favorite: z.boolean().optional(), +}); + +export async function updateEntryAction(entryId: string, data: unknown) { + return run(async (userId) => { + await updateEntryDetails(userId, cuid.parse(entryId), detailsSchema.parse(data)); + return { message: "Gespeichert" }; + }, PATHS); +} + +export async function startRewatchAction(entryId: string) { + return run(async (userId) => { + const entry = await startRewatch(userId, cuid.parse(entryId)); + return { message: `Durchlauf ${entry.currentRun + 1} gestartet` }; + }, PATHS); +} + +// --------------------------------------------------------------------------- +// Sichtungen +// --------------------------------------------------------------------------- + +function toResult(result: WatchResult) { + return { message: result.label, watchIds: result.watchIds }; +} + +export async function toggleEpisodeAction(entryId: string, episodeId: string, watched: boolean) { + return run(async (userId) => { + if (watched) { + return toResult(await markEpisodesWatched(userId, cuid.parse(entryId), [cuid.parse(episodeId)])); + } + await unmarkEpisodes(userId, cuid.parse(entryId), [cuid.parse(episodeId)]); + return { message: "Markierung entfernt" }; + }, PATHS); +} + +export async function toggleSeasonAction(entryId: string, seasonId: string, watched: boolean) { + return run(async (userId) => { + if (watched) { + return toResult(await markSeasonWatched(userId, cuid.parse(entryId), cuid.parse(seasonId))); + } + await unmarkSeason(userId, cuid.parse(entryId), cuid.parse(seasonId)); + return { message: "Staffel zurückgesetzt" }; + }, PATHS); +} + +export async function markUpToAction(entryId: string, episodeId: string) { + return run( + async (userId) => toResult(await markUpTo(userId, cuid.parse(entryId), cuid.parse(episodeId))), + PATHS, + ); +} + +export async function watchNextAction(entryId: string) { + return run(async (userId) => toResult(await watchNextEpisode(userId, cuid.parse(entryId))), PATHS); +} + +export async function markMovieAction(entryId: string) { + return run(async (userId) => toResult(await markMovieWatched(userId, cuid.parse(entryId))), PATHS); +} + +export async function undoAction(entryId: string, watchIds: string[]) { + return run(async (userId) => { + await undoWatches(userId, cuid.parse(entryId), z.array(cuid).max(500).parse(watchIds)); + return { message: "Rückgängig gemacht" }; + }, PATHS); +} + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +export async function setTagsAction(entryId: string, tagNames: string[]) { + return run(async (userId) => { + const id = cuid.parse(entryId); + const names = z + .array(z.string().trim().min(1).max(40)) + .max(20) + .parse(tagNames) + .map((name) => name.toLocaleLowerCase("de-DE")); + + const entry = await prisma.libraryEntry.findFirst({ + where: { id, userId }, + select: { id: true }, + }); + if (!entry) throw new LibraryError("Dieser Eintrag gehört nicht zu deiner Bibliothek."); + + const tags = await Promise.all( + names.map((name) => + prisma.tag.upsert({ + where: { userId_name: { userId, name } }, + create: { userId, name }, + update: {}, + select: { id: true }, + }), + ), + ); + + await prisma.$transaction([ + prisma.tagOnEntry.deleteMany({ + where: { entryId: id, tagId: { notIn: tags.map((tag) => tag.id) } }, + }), + ...tags.map((tag) => + prisma.tagOnEntry.upsert({ + where: { tagId_entryId: { tagId: tag.id, entryId: id } }, + create: { tagId: tag.id, entryId: id }, + update: {}, + }), + ), + ]); + + return { message: "Tags gespeichert" }; + }, PATHS); +} + +// --------------------------------------------------------------------------- +// Manuell angelegte Titel +// --------------------------------------------------------------------------- + +const manualSchema = z.object({ + type: z.enum(["SERIES", "MOVIE"]), + title: z.string().trim().min(1).max(200), + year: z.number().int().min(1880).max(2200).nullable().optional(), + runtime: z.number().int().min(1).max(2000).nullable().optional(), + overview: z.string().trim().max(4000).optional(), + genres: z.array(z.string().trim().min(1).max(40)).max(10).optional(), + status: statusSchema.default("WATCHLIST"), +}); + +/** + * Legt einen Titel ohne TMDB-Treffer an. + * + * Für alles, was TMDB nicht kennt: Heimvideos, Vorträge, regionale + * Produktionen. Solche Titel haben keine `tmdbId` und werden vom Sync-Lauf + * daher nie angefasst. + */ +export async function createManualTitleAction(data: unknown): Promise { + const user = await getSessionUser(); + if (!user) return { ok: false, message: "Bitte melde dich an." }; + + const parsed = manualSchema.safeParse(data); + if (!parsed.success) { + return { ok: false, message: "Bitte gib mindestens einen Titel an." }; + } + + const { type, title, year, runtime, overview, genres, status } = parsed.data; + + try { + const created = await prisma.title.create({ + data: { + type, + title, + overview: overview || null, + runtime: runtime ?? null, + releaseDate: year ? new Date(Date.UTC(year, 0, 1)) : null, + genres: genres ?? [], + status: "Manuell angelegt", + syncedAt: new Date(), + }, + select: { id: true }, + }); + + await addToLibrary(user.id, created.id, status); + for (const path of PATHS) revalidatePath(path, "layout"); + + return { ok: true, message: `„${title}" angelegt`, titleId: created.id }; + } catch (error) { + console.error("Manuellen Titel anlegen fehlgeschlagen:", error); + return { ok: false, message: "Das hat nicht geklappt. Bitte noch einmal versuchen." }; + } +} diff --git a/src/app/globals.css b/src/app/globals.css index e3c366f..0694cf7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -332,6 +332,18 @@ animation: fl-glimmer 900ms var(--ease-glow); } +/* Rückmeldungen fahren von unten herein — kurz und ohne Sprung. */ +@keyframes fl-toast-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + @media (prefers-reduced-motion: reduce) { *, *::before, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1fe4283..b3069d6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import { Outfit } from "next/font/google"; import { BrandDefs } from "@/components/brand/FlimmerLogo"; +import { ToastProvider } from "@/components/Toasts"; import { env } from "@/lib/env"; import "./globals.css"; @@ -73,7 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) > Zum Inhalt springen - {children} + {children} ); diff --git a/src/components/ProgressBar.tsx b/src/components/ProgressBar.tsx new file mode 100644 index 0000000..3b011df --- /dev/null +++ b/src/components/ProgressBar.tsx @@ -0,0 +1,37 @@ +import { clsx } from "clsx"; + +/** + * Fortschrittsbalken im Signaturverlauf. + * + * Der Wert steht zusätzlich als Text daneben — ein Balken allein ist für + * Screenreader und bei starker Vergrößerung wertlos. + */ +export function ProgressBar({ + percent, + label, + className, + showValue = false, +}: { + percent: number; + label: string; + className?: string; + showValue?: boolean; +}) { + const value = Math.max(0, Math.min(100, Math.round(percent))); + + return ( +
+
+ +
+ {showValue &&

{label}

} +
+ ); +} diff --git a/src/components/Toasts.tsx b/src/components/Toasts.tsx new file mode 100644 index 0000000..d5079b4 --- /dev/null +++ b/src/components/Toasts.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { clsx } from "clsx"; +import type { ActionResult } from "@/app/actions/library"; + +/** + * Kurze Rückmeldungen am unteren Rand — mit optionalem „Rückgängig". + * + * Warum überhaupt: Beim Abhaken einer Episode passiert die eigentliche Wirkung + * weit weg vom Klick (Fortschrittsbalken, Status, „nächste Episode"). Eine + * kurze Bestätigung sagt, was gerade geschehen ist — und lässt es zurücknehmen, + * ohne die Stelle wiederfinden zu müssen. + */ + +type Toast = { + id: number; + message: string; + tone: "info" | "error"; + undo?: () => Promise | void; +}; + +type ToastContext = { + show(message: string, options?: { tone?: Toast["tone"]; undo?: Toast["undo"] }): void; + /** Bequemer Weg für Aktionsergebnisse samt Rückgängig-Möglichkeit. */ + report(result: ActionResult, undo?: (watchIds: string[]) => Promise): void; +}; + +const Context = createContext(null); + +const VISIBLE_MS = 6000; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const nextId = useRef(1); + + const dismiss = useCallback((id: number) => { + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const show = useCallback((message, options) => { + const id = nextId.current++; + setToasts((current) => [ + ...current.slice(-2), + { id, message, tone: options?.tone ?? "info", undo: options?.undo }, + ]); + }, []); + + const report = useCallback( + (result, undo) => { + if (!result.ok) { + show(result.message ?? "Das hat nicht geklappt.", { tone: "error" }); + return; + } + if (!result.message) return; + + const ids = result.watchIds ?? []; + show(result.message, { + undo: undo && ids.length > 0 ? () => void undo(ids) : undefined, + }); + }, + [show], + ); + + return ( + + {children} +
+ {toasts.map((toast) => ( + dismiss(toast.id)} /> + ))} +
+
+ ); +} + +function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) { + useEffect(() => { + const timer = setTimeout(onDismiss, VISIBLE_MS); + return () => clearTimeout(timer); + }, [onDismiss]); + + return ( +
+ {toast.message} + {toast.undo && ( + + )} + +
+ ); +} + +export function useToasts(): ToastContext { + const context = useContext(Context); + if (!context) { + throw new Error("useToasts braucht einen ToastProvider im Elternbaum."); + } + return context; +} diff --git a/src/components/library/EntryControls.tsx b/src/components/library/EntryControls.tsx new file mode 100644 index 0000000..cf26fb7 --- /dev/null +++ b/src/components/library/EntryControls.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { clsx } from "clsx"; +import { + addTitleAction, + setStatusAction, + startRewatchAction, + trashEntryAction, + updateEntryAction, +} from "@/app/actions/library"; +import { useToasts } from "@/components/Toasts"; +import { STATUS_COLOR, STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels"; + +/** Aufnahmeknopf für Titel, die noch nicht in der Bibliothek liegen. */ +export function AddToLibrary({ titleId, isSeries }: { titleId: string; isSeries: boolean }) { + const { report } = useToasts(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + function add(status: StatusKey) { + startTransition(async () => { + report(await addTitleAction(titleId, status)); + router.refresh(); + }); + } + + return ( +
+ + +
+ ); +} + +/** Statusauswahl als Chip-Reihe — schneller zu treffen als ein Auswahlfeld. */ +export function StatusPicker({ entryId, status }: { entryId: string; status: StatusKey }) { + const { report } = useToasts(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [optimistic, setOptimistic] = useState(status); + + function choose(next: StatusKey) { + if (next === optimistic) return; + setOptimistic(next); + startTransition(async () => { + const result = await setStatusAction(entryId, next); + if (!result.ok) setOptimistic(status); + report(result); + router.refresh(); + }); + } + + return ( +
+ {STATUS_ORDER.map((key) => { + const active = optimistic === key; + return ( + + ); + })} +
+ ); +} + +/** Bewertung von 1 bis 10. */ +export function RatingPicker({ entryId, rating }: { entryId: string; rating: number | null }) { + const { report } = useToasts(); + const router = useRouter(); + const [value, setValue] = useState(rating); + const [hover, setHover] = useState(null); + const [, startTransition] = useTransition(); + + function choose(next: number | null) { + setValue(next); + startTransition(async () => { + report(await updateEntryAction(entryId, { rating: next })); + router.refresh(); + }); + } + + const shown = hover ?? value ?? 0; + + return ( +
+
setHover(null)} + > + {Array.from({ length: 10 }, (_, index) => index + 1).map((step) => ( + + ))} +
+ + {value ? `${value}/10` : "—"} + +
+ ); +} + +/** Papierkorb und neuer Durchlauf. */ +export function EntryActions({ + entryId, + currentRun, + canRewatch, +}: { + entryId: string; + currentRun: number; + canRewatch: boolean; +}) { + const { report } = useToasts(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [confirming, setConfirming] = useState(false); + + return ( +
+ {canRewatch && ( + + )} + + {currentRun > 0 && ( + Durchlauf {currentRun + 1} + )} + + + + {confirming ? ( + + In den Papierkorb? + + + + ) : ( + + )} +
+ ); +} diff --git a/src/components/library/EntryNotes.tsx b/src/components/library/EntryNotes.tsx new file mode 100644 index 0000000..f0f59fd --- /dev/null +++ b/src/components/library/EntryNotes.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useEffect, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { setTagsAction, updateEntryAction } from "@/app/actions/library"; +import { useToasts } from "@/components/Toasts"; + +/** + * Notizfeld, „wo schaue ich das" und eigene Tags. + * + * Gespeichert wird beim Verlassen des Feldes, nicht bei jedem Tastendruck — + * das spart Anfragen und erspart das Suchen nach einem Speichern-Knopf. + */ +export function EntryNotes({ + entryId, + notes, + whereToWatch, + tags, + suggestion, +}: { + entryId: string; + notes: string | null; + whereToWatch: string | null; + tags: string[]; + /** Vorschlag aus der Streaming-Verfügbarkeit. */ + suggestion: string | null; +}) { + return ( +
+ + + +
+ ); +} + +function useAutosave(entryId: string) { + const { report } = useToasts(); + const router = useRouter(); + const [, startTransition] = useTransition(); + + return (data: Record) => + startTransition(async () => { + const result = await updateEntryAction(entryId, data); + if (!result.ok) report(result); + router.refresh(); + }); +} + +function WhereToWatch({ + entryId, + value, + suggestion, +}: { + entryId: string; + value: string | null; + suggestion: string | null; +}) { + const save = useAutosave(entryId); + const [text, setText] = useState(value ?? ""); + const initial = useRef(value ?? ""); + + return ( +
+ + setText(event.target.value)} + onBlur={() => { + if (text === initial.current) return; + initial.current = text; + save({ whereToWatch: text }); + }} + placeholder={suggestion ? `Vorschlag: ${suggestion}` : "z. B. Netflix, Blu-ray, Mediathek"} + className="fl-input" + /> + {suggestion && text !== suggestion && ( + + )} +
+ ); +} + +function Notes({ entryId, value }: { entryId: string; value: string | null }) { + const save = useAutosave(entryId); + const [text, setText] = useState(value ?? ""); + const initial = useRef(value ?? ""); + + return ( +
+ +