feat: Bedienelemente für Bibliothek und Episodenfortschritt

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.
This commit is contained in:
Florian Wilde
2026-09-01 19:24:51 +02:00
parent 4c3c878980
commit f613136e8a
13 changed files with 1866 additions and 1 deletions
+285
View File
@@ -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<ActionResult> {
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<ActionResult & { titleId?: string }> {
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." };
}
}
+12
View File
@@ -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,
+2 -1
View File
@@ -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
</a>
{children}
<ToastProvider>{children}</ToastProvider>
</body>
</html>
);
+37
View File
@@ -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 (
<div className={clsx("space-y-1", className)}>
<div
className="fl-progress"
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<span style={{ width: `${value}%` }} />
</div>
{showValue && <p className="text-xs text-[var(--fl-faint)]">{label}</p>}
</div>
);
}
+136
View File
@@ -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> | 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<unknown>): void;
};
const Context = createContext<ToastContext | null>(null);
const VISIBLE_MS = 6000;
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextId = useRef(1);
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const show = useCallback<ToastContext["show"]>((message, options) => {
const id = nextId.current++;
setToasts((current) => [
...current.slice(-2),
{ id, message, tone: options?.tone ?? "info", undo: options?.undo },
]);
}, []);
const report = useCallback<ToastContext["report"]>(
(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 (
<Context.Provider value={{ show, report }}>
{children}
<div
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4"
>
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onDismiss={() => dismiss(toast.id)} />
))}
</div>
</Context.Provider>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
useEffect(() => {
const timer = setTimeout(onDismiss, VISIBLE_MS);
return () => clearTimeout(timer);
}, [onDismiss]);
return (
<div
role={toast.tone === "error" ? "alert" : "status"}
className={clsx(
"pointer-events-auto flex max-w-md items-center gap-3 rounded-xl border px-4 py-2.5 text-sm shadow-[var(--fl-shadow)] backdrop-blur-xl",
"motion-safe:animate-[fl-toast-in_240ms_var(--ease-glow)]",
)}
style={{
backgroundColor: "var(--fl-surface-solid)",
borderColor:
toast.tone === "error"
? "color-mix(in oklab, var(--fl-danger) 50%, transparent)"
: "var(--fl-border)",
color: toast.tone === "error" ? "var(--fl-danger)" : "var(--fl-text)",
}}
>
<span className="flex-1">{toast.message}</span>
{toast.undo && (
<button
type="button"
onClick={() => {
void toast.undo?.();
onDismiss();
}}
className="shrink-0 rounded-lg px-2 py-1 text-sm font-semibold text-[var(--fl-accent)] underline-offset-2 hover:underline"
>
Rückgängig
</button>
)}
<button
type="button"
onClick={onDismiss}
aria-label="Meldung schließen"
className="shrink-0 text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
</div>
);
}
export function useToasts(): ToastContext {
const context = useContext(Context);
if (!context) {
throw new Error("useToasts braucht einen ToastProvider im Elternbaum.");
}
return context;
}
+232
View File
@@ -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 (
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={pending}
onClick={() => add("WATCHING")}
className="fl-btn fl-btn-primary"
>
{isSeries ? "Ich schaue das gerade" : "In die Bibliothek"}
</button>
<button
type="button"
disabled={pending}
onClick={() => add("WATCHLIST")}
className="fl-btn fl-btn-ghost"
>
Auf die Watchlist
</button>
</div>
);
}
/** 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 (
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Status">
{STATUS_ORDER.map((key) => {
const active = optimistic === key;
return (
<button
key={key}
type="button"
disabled={pending}
onClick={() => choose(key)}
aria-pressed={active}
className={clsx(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "text-[var(--fl-text)]"
: "border-[var(--fl-border)] text-[var(--fl-muted)] hover:border-[var(--fl-border-strong)] hover:text-[var(--fl-text)]",
)}
style={
active
? {
borderColor: `color-mix(in oklab, ${STATUS_COLOR[key]} 60%, transparent)`,
backgroundColor: `color-mix(in oklab, ${STATUS_COLOR[key]} 16%, transparent)`,
}
: undefined
}
>
{STATUS_LABEL[key]}
</button>
);
})}
</div>
);
}
/** 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<number | null>(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 (
<div className="flex items-center gap-2">
<div
className="flex gap-0.5"
role="group"
aria-label="Bewertung von 1 bis 10"
onMouseLeave={() => setHover(null)}
>
{Array.from({ length: 10 }, (_, index) => index + 1).map((step) => (
<button
key={step}
type="button"
aria-label={`${step} von 10`}
aria-pressed={value === step}
onMouseEnter={() => setHover(step)}
onFocus={() => setHover(step)}
onBlur={() => setHover(null)}
onClick={() => choose(value === step ? null : step)}
className="group p-0.5"
>
<span
className={clsx(
"block h-5 w-2.5 rounded-full transition-colors",
step <= shown ? "" : "bg-[var(--fl-surface-2)]",
)}
style={step <= shown ? { backgroundImage: "var(--fl-gradient)" } : undefined}
/>
</button>
))}
</div>
<span className="w-14 text-sm tabular-nums text-[var(--fl-muted)]">
{value ? `${value}/10` : "—"}
</span>
</div>
);
}
/** 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 (
<div className="flex flex-wrap items-center gap-2">
{canRewatch && (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await startRewatchAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost text-sm"
>
Noch einmal schauen
</button>
)}
{currentRun > 0 && (
<span className="text-xs text-[var(--fl-faint)]">Durchlauf {currentRun + 1}</span>
)}
<span className="flex-1" />
{confirming ? (
<span className="flex items-center gap-2 text-sm">
<span className="text-[var(--fl-muted)]">In den Papierkorb?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await trashEntryAction(entryId));
router.push("/bibliothek");
})
}
className="fl-btn fl-btn-ghost px-2.5 py-1 text-sm text-[var(--fl-danger)]"
>
Ja, weg damit
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="fl-btn fl-btn-ghost px-2.5 py-1 text-sm"
>
Abbrechen
</button>
</span>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Aus der Bibliothek entfernen
</button>
)}
</div>
);
}
+194
View File
@@ -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 (
<div className="space-y-4">
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} />
</div>
);
}
function useAutosave(entryId: string) {
const { report } = useToasts();
const router = useRouter();
const [, startTransition] = useTransition();
return (data: Record<string, unknown>) =>
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 (
<div className="space-y-1.5">
<label htmlFor="whereToWatch" className="block text-sm font-medium text-[var(--fl-muted)]">
Wo schaue ich das?
</label>
<input
id="whereToWatch"
value={text}
onChange={(event) => 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 && (
<button
type="button"
onClick={() => {
setText(suggestion);
initial.current = suggestion;
save({ whereToWatch: suggestion });
}}
className="text-xs text-[var(--fl-accent)] underline-offset-2 hover:underline"
>
{suggestion} übernehmen
</button>
)}
</div>
);
}
function Notes({ entryId, value }: { entryId: string; value: string | null }) {
const save = useAutosave(entryId);
const [text, setText] = useState(value ?? "");
const initial = useRef(value ?? "");
return (
<div className="space-y-1.5">
<label htmlFor="notes" className="block text-sm font-medium text-[var(--fl-muted)]">
Notiz
</label>
<textarea
id="notes"
value={text}
rows={4}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ notes: text });
}}
placeholder="Was du dir merken willst — Lieblingsfolge, mit wem du das schaust, wo du aufgehört hast."
className="fl-input resize-y"
/>
</div>
);
}
function TagEditor({ entryId, tags }: { entryId: string; tags: string[] }) {
const { report } = useToasts();
const router = useRouter();
const [current, setCurrent] = useState(tags);
const [draft, setDraft] = useState("");
const [, startTransition] = useTransition();
function commit(next: string[]) {
setCurrent(next);
startTransition(async () => {
const result = await setTagsAction(entryId, next);
if (!result.ok) {
report(result);
setCurrent(tags);
}
router.refresh();
});
}
function add() {
const name = draft.trim().toLocaleLowerCase("de-DE");
setDraft("");
if (!name || current.includes(name)) return;
commit([...current, name]);
}
return (
<div className="space-y-1.5">
<label htmlFor="tag-input" className="block text-sm font-medium text-[var(--fl-muted)]">
Tags
</label>
{current.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{current.map((tag) => (
<li key={tag}>
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--fl-border)] py-1 pl-3 pr-1.5 text-xs">
{tag}
<button
type="button"
onClick={() => commit(current.filter((entry) => entry !== tag))}
aria-label={`Tag ${tag} entfernen`}
className="rounded-full p-0.5 text-[var(--fl-faint)] hover:text-[var(--fl-danger)]"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
</span>
</li>
))}
</ul>
)}
<input
id="tag-input"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
add();
}
}}
onBlur={add}
placeholder="Tag eingeben und Enter drücken"
className="fl-input"
/>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import Link from "next/link";
import { Poster } from "@/components/Poster";
import { ProgressBar } from "@/components/ProgressBar";
import { STATUS_COLOR, STATUS_LABEL, type StatusKey } from "@/lib/labels";
import type { LibraryItem } from "@/lib/queries";
/**
* Posterkachel der Bibliothek.
*
* Alles Wesentliche steht ohne Klick da: Status, Fortschritt, Bewertung.
* Die Karte ist ein Link — der ganze Bereich ist anklickbar, nicht nur der Titel.
*/
export function LibraryCard({ item }: { item: LibraryItem }) {
const status = item.status as StatusKey;
const showProgress = item.type === "SERIES" && item.total > 0 && item.status !== "WATCHLIST";
return (
<Link href={`/titel/${item.titleId}`} className="fl-poster-card group block focus:outline-none">
<div className="relative">
<Poster path={item.posterPath} alt={item.title} size="w342" />
<span
className="absolute left-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold backdrop-blur-md"
style={{
color: STATUS_COLOR[status],
backgroundColor: `color-mix(in oklab, var(--fl-bg) 72%, transparent)`,
boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${STATUS_COLOR[status]} 45%, transparent)`,
}}
>
{STATUS_LABEL[status]}
</span>
{item.rating != null && (
<span
className="absolute right-2 top-2 rounded-full px-2 py-0.5 text-[0.65rem] font-semibold text-[var(--fl-on-accent)]"
style={{ backgroundImage: "var(--fl-gradient)" }}
>
{item.rating}
</span>
)}
{item.currentRun > 0 && (
<span className="absolute bottom-2 right-2 rounded-full bg-[color-mix(in_oklab,var(--fl-bg)_78%,transparent)] px-2 py-0.5 text-[0.65rem] font-medium text-[var(--fl-muted)] backdrop-blur-md">
{item.currentRun + 1}. Durchlauf
</span>
)}
</div>
<div className="mt-2 space-y-1">
<p className="line-clamp-2 text-sm font-medium leading-snug">{item.title}</p>
<p className="text-xs text-[var(--fl-faint)]">
{item.type === "SERIES" ? "Serie" : "Film"}
{item.year ? ` · ${item.year}` : ""}
{item.whereToWatch ? ` · ${item.whereToWatch}` : ""}
</p>
{showProgress && (
<div className="pt-0.5">
<ProgressBar
percent={item.percent}
label={`${item.watched} von ${item.total} Episoden gesehen`}
/>
<p className="mt-1 text-xs text-[var(--fl-faint)]">
{item.watched}/{item.total} Episoden
</p>
</div>
)}
</div>
</Link>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { clsx } from "clsx";
import { STATUS_LABEL, STATUS_ORDER, type StatusKey } from "@/lib/labels";
import type { LibraryFacets } from "@/lib/queries";
/**
* Filterleiste der Bibliothek.
*
* Der gesamte Zustand steht in der URL: Ansichten sind teilbar, der
* Zurück-Knopf tut das Erwartbare, und die Liste selbst bleibt eine
* Server-Komponente ohne eigenen Zustand.
*/
const SORTS = [
{ key: "aktualisiert", label: "Zuletzt aktualisiert" },
{ key: "hinzugefuegt", label: "Zuletzt hinzugefügt" },
{ key: "titel", label: "Titel" },
{ key: "bewertung", label: "Bewertung" },
{ key: "jahr", label: "Erscheinungsjahr" },
] as const;
export function LibraryFilters({ facets }: { facets: LibraryFacets }) {
const router = useRouter();
const pathname = usePathname();
const params = useSearchParams();
const [, startTransition] = useTransition();
const [query, setQuery] = useState(params.get("q") ?? "");
const first = useRef(true);
function apply(changes: Record<string, string | null>) {
const next = new URLSearchParams(params.toString());
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key);
else next.set(key, value);
}
startTransition(() => {
router.replace(next.size ? `${pathname}?${next}` : pathname, { scroll: false });
});
}
// Volltextsuche gesammelt anwenden, nicht bei jedem Tastendruck.
useEffect(() => {
if (first.current) {
first.current = false;
return;
}
const timer = setTimeout(() => apply({ q: query.trim() || null }), 300);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const status = params.get("status");
const type = params.get("typ");
const sort = params.get("sortierung") ?? "aktualisiert";
const activeCount = ["genre", "tag", "dienst", "typ"].filter((key) => params.get(key)).length;
return (
<div className="space-y-3">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="In der Bibliothek suchen …"
aria-label="Bibliothek durchsuchen"
className="fl-input"
/>
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Nach Status filtern">
<Chip active={!status} onClick={() => apply({ status: null })}>
Alle <Count value={facets.counts.ALLE} />
</Chip>
{STATUS_ORDER.map((key) => (
<Chip key={key} active={status === key} onClick={() => apply({ status: key })}>
{STATUS_LABEL[key as StatusKey]} <Count value={facets.counts[key]} />
</Chip>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<Select
label="Typ"
value={type ?? ""}
onChange={(value) => apply({ typ: value || null })}
options={[
{ value: "", label: "Serien und Filme" },
{ value: "SERIES", label: "Nur Serien" },
{ value: "MOVIE", label: "Nur Filme" },
]}
/>
{facets.genres.length > 0 && (
<Select
label="Genre"
value={params.get("genre") ?? ""}
onChange={(value) => apply({ genre: value || null })}
options={[
{ value: "", label: "Alle Genres" },
...facets.genres.map((genre) => ({ value: genre, label: genre })),
]}
/>
)}
{facets.providers.length > 0 && (
<Select
label="Dienst"
value={params.get("dienst") ?? ""}
onChange={(value) => apply({ dienst: value || null })}
options={[
{ value: "", label: "Alle Dienste" },
...facets.providers.map((name) => ({ value: name, label: name })),
]}
/>
)}
{facets.tags.length > 0 && (
<Select
label="Tag"
value={params.get("tag") ?? ""}
onChange={(value) => apply({ tag: value || null })}
options={[
{ value: "", label: "Alle Tags" },
...facets.tags.map((tag) => ({ value: tag, label: tag })),
]}
/>
)}
<Select
label="Sortierung"
value={sort}
onChange={(value) => apply({ sortierung: value === "aktualisiert" ? null : value })}
options={SORTS.map((entry) => ({ value: entry.key, label: entry.label }))}
/>
{(activeCount > 0 || status || query) && (
<button
type="button"
onClick={() => {
setQuery("");
startTransition(() => router.replace(pathname, { scroll: false }));
}}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-text)] hover:underline"
>
Filter zurücksetzen
</button>
)}
</div>
</div>
);
}
function Chip({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={active}
className={clsx(
"rounded-full border px-3 py-1.5 text-sm font-medium transition-colors",
active
? "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={active ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{children}
</button>
);
}
function Count({ value }: { value: number }) {
return <span className="ml-1 text-xs tabular-nums opacity-70">{value}</span>;
}
function Select({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<label className="inline-flex items-center gap-1.5 text-sm">
<span className="sr-only">{label}</span>
<select
value={value}
onChange={(event) => onChange(event.target.value)}
aria-label={label}
className="rounded-lg border border-[var(--fl-border)] bg-[var(--fl-surface-2)] px-2.5 py-1.5 text-sm text-[var(--fl-text)] transition-colors hover:border-[var(--fl-border-strong)]"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { createManualTitleAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { STATUS_LABEL, STATUS_ORDER } from "@/lib/labels";
/**
* Titel von Hand anlegen — für alles, was TMDB nicht kennt.
*
* Pflicht ist nur der Titel. Alles Weitere ist optional, weil man bei einem
* Heimvideo oder einem Vortragsmitschnitt selten die Laufzeit zur Hand hat.
*/
export function ManualTitleForm() {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [type, setType] = useState<"SERIES" | "MOVIE">("MOVIE");
const [error, setError] = useState<string | null>(null);
function submit(formData: FormData) {
const title = String(formData.get("title") ?? "").trim();
if (!title) {
setError("Ohne Titel geht es nicht.");
return;
}
setError(null);
const year = Number.parseInt(String(formData.get("year") ?? ""), 10);
const runtime = Number.parseInt(String(formData.get("runtime") ?? ""), 10);
const genres = String(formData.get("genres") ?? "")
.split(",")
.map((genre) => genre.trim())
.filter(Boolean);
startTransition(async () => {
const result = await createManualTitleAction({
type,
title,
year: Number.isFinite(year) ? year : null,
runtime: Number.isFinite(runtime) ? runtime : null,
overview: String(formData.get("overview") ?? ""),
genres,
status: String(formData.get("status") ?? "WATCHLIST"),
});
report(result);
if (result.ok && result.titleId) router.push(`/titel/${result.titleId}`);
});
}
return (
<form action={submit} className="space-y-4">
<fieldset>
<legend className="mb-2 block text-sm font-medium text-[var(--fl-muted)]">Was ist es?</legend>
<div className="flex gap-2">
{(["MOVIE", "SERIES"] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setType(option)}
aria-pressed={type === option}
className="rounded-full border px-4 py-2 text-sm font-medium transition-colors"
style={
type === option
? { backgroundImage: "var(--fl-gradient)", color: "var(--fl-on-accent)", borderColor: "transparent" }
: { borderColor: "var(--fl-border)", color: "var(--fl-muted)" }
}
>
{option === "MOVIE" ? "Film" : "Serie"}
</button>
))}
</div>
</fieldset>
<Field id="title" label="Titel" required autoFocus />
<div className="grid gap-4 sm:grid-cols-2">
<Field id="year" label="Erscheinungsjahr" type="number" placeholder="2026" />
<Field
id="runtime"
label={type === "SERIES" ? "Minuten je Episode" : "Laufzeit in Minuten"}
type="number"
placeholder="95"
/>
</div>
<Field id="genres" label="Genres" placeholder="Drama, Komödie — durch Komma getrennt" />
<div className="space-y-1.5">
<label htmlFor="overview" className="block text-sm font-medium text-[var(--fl-muted)]">
Beschreibung
</label>
<textarea id="overview" name="overview" rows={3} className="fl-input resize-y" />
</div>
<div className="space-y-1.5">
<label htmlFor="status" className="block text-sm font-medium text-[var(--fl-muted)]">
Status
</label>
<select id="status" name="status" defaultValue="WATCHLIST" className="fl-input">
{STATUS_ORDER.map((key) => (
<option key={key} value={key}>
{STATUS_LABEL[key]}
</option>
))}
</select>
</div>
{error && (
<p role="alert" className="text-sm text-[var(--fl-danger)]">
{error}
</p>
)}
<button type="submit" disabled={pending} className="fl-btn fl-btn-primary">
{pending ? "Einen Moment …" : "Anlegen und aufnehmen"}
</button>
</form>
);
}
function Field({
id,
label,
type = "text",
placeholder,
required = false,
autoFocus = false,
}: {
id: string;
label: string;
type?: string;
placeholder?: string;
required?: boolean;
autoFocus?: boolean;
}) {
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block text-sm font-medium text-[var(--fl-muted)]">
{label}
{!required && <span className="ml-1 text-xs text-[var(--fl-faint)]">(optional)</span>}
</label>
<input
id={id}
name={id}
type={type}
placeholder={placeholder}
required={required}
autoFocus={autoFocus}
className="fl-input"
/>
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
/**
* „Als Nächstes dran" mit dem Schnellknopf.
*
* Das ist der meistbenutzte Knopf der ganzen Anwendung — deshalb steht er
* oben, ist groß, und sagt vorher, was er abhaken wird.
*/
export function NextEpisodeCard({
entryId,
next,
}: {
entryId: string;
next: {
id: string;
name: string;
seasonNumber: number;
episodeNumber: number;
airDate: string | null;
runtime: number | null;
};
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const upcoming = isUpcoming(next.airDate);
const code = episodeCode(next.seasonNumber, next.episodeNumber);
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
return (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<p className="mt-1 truncate font-medium">
<span className="fl-gradient-text font-mono font-semibold">{code}</span>{" "}
<span className="text-[var(--fl-text)]">{next.name}</span>
</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{upcoming
? `Erscheint erst am ${formatDate(next.airDate)}`
: [formatDate(next.airDate), next.runtime ? `${next.runtime} min` : null]
.filter(Boolean)
.join(" · ") || "Kein Ausstrahlungsdatum hinterlegt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await watchNextAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
title={`${code} als gesehen markieren`}
>
+1 Episode
</button>
</div>
);
}
/** Sichtungsknopf für Filme. */
export function WatchMovieButton({
entryId,
watched,
}: {
entryId: string;
watched: boolean;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
if (watched) {
return (
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Gesehen
</p>
);
}
return (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await markMovieAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
>
Als gesehen markieren
</button>
);
}
+335
View File
@@ -0,0 +1,335 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { clsx } from "clsx";
import {
markUpToAction,
toggleEpisodeAction,
toggleSeasonAction,
undoAction,
} from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
/**
* Staffel- und Episodenliste mit Fortschritt.
*
* Liegt der Titel in der Bibliothek (`entryId` gesetzt), lässt sich jede
* Episode abhaken, eine ganze Staffel auf einmal, oder „bis hierhin" alles
* Vorherige. Ohne Eintrag ist die Liste eine reine Übersicht.
*
* Die Häkchen reagieren sofort (optimistisch) und werden zurückgedreht, wenn
* der Server widerspricht — Abhaken soll sich anfühlen wie ein Schalter, nicht
* wie ein Formular.
*/
export type EpisodeData = {
id: string;
episodeNumber: number;
name: string;
overview: string | null;
airDate: string | null;
runtime: number | null;
};
export type SeasonData = {
id: string;
seasonNumber: number;
name: string;
overview: string | null;
airDate: string | null;
episodes: EpisodeData[];
};
export function SeasonList({
seasons,
entryId,
watchedIds,
}: {
seasons: SeasonData[];
entryId?: string;
watchedIds?: string[];
}) {
const router = useRouter();
const { report } = useToasts();
const [watched, setWatched] = useState(() => new Set(watchedIds ?? []));
const [, startTransition] = useTransition();
// Nach einem Serverabgleich (router.refresh) den Stand übernehmen.
useEffect(() => {
setWatched(new Set(watchedIds ?? []));
}, [watchedIds]);
const firstOpen = seasons.find((season) => season.seasonNumber > 0) ?? seasons[0];
const [open, setOpen] = useState<string[]>(firstOpen ? [firstOpen.id] : []);
async function undo(watchIds: string[]) {
if (!entryId) return;
await undoAction(entryId, watchIds);
router.refresh();
}
function toggleEpisode(episodeId: string, next: boolean) {
if (!entryId) return;
setWatched((current) => {
const copy = new Set(current);
if (next) copy.add(episodeId);
else copy.delete(episodeId);
return copy;
});
startTransition(async () => {
const result = await toggleEpisodeAction(entryId, episodeId, next);
if (!result.ok) {
setWatched((current) => {
const copy = new Set(current);
if (next) copy.delete(episodeId);
else copy.add(episodeId);
return copy;
});
}
report(result, next ? (ids) => undo(ids) : undefined);
router.refresh();
});
}
function toggleSeason(season: SeasonData, next: boolean) {
if (!entryId) return;
const ids = season.episodes.map((episode) => episode.id);
setWatched((current) => {
const copy = new Set(current);
for (const id of ids) {
if (next) copy.add(id);
else copy.delete(id);
}
return copy;
});
startTransition(async () => {
const result = await toggleSeasonAction(entryId, season.id, next);
report(result, next ? (watchIds) => undo(watchIds) : undefined);
router.refresh();
});
}
function markUpTo(episodeId: string) {
if (!entryId) return;
startTransition(async () => {
const result = await markUpToAction(entryId, episodeId);
report(result, (watchIds) => undo(watchIds));
router.refresh();
});
}
if (seasons.length === 0) {
return (
<p className="text-sm text-[var(--fl-faint)]">Zu dieser Serie liegen keine Staffeln vor.</p>
);
}
return (
<div className="space-y-2">
{seasons.map((season) => {
const isOpen = open.includes(season.id);
const seen = season.episodes.filter((episode) => watched.has(episode.id)).length;
const complete = season.episodes.length > 0 && seen === season.episodes.length;
return (
<section key={season.id} className="fl-card overflow-hidden">
<div className="flex items-center gap-2 px-2 py-1.5 sm:px-3">
<button
type="button"
onClick={() =>
setOpen((current) =>
current.includes(season.id)
? current.filter((id) => id !== season.id)
: [...current, season.id],
)
}
aria-expanded={isOpen}
className="flex flex-1 items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-[var(--fl-surface-2)]"
>
<Chevron open={isOpen} />
<span className="min-w-0 flex-1">
<span className="font-medium">{season.name}</span>
<span className="ml-2 text-sm text-[var(--fl-faint)]">
{entryId
? `${seen} von ${season.episodes.length} gesehen`
: `${season.episodes.length} ${season.episodes.length === 1 ? "Episode" : "Episoden"}`}
{season.airDate ? ` · ab ${formatDate(season.airDate)}` : ""}
</span>
</span>
</button>
{entryId && season.episodes.length > 0 && (
<button
type="button"
onClick={() => toggleSeason(season, !complete)}
className="shrink-0 rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--fl-muted)] transition-colors hover:bg-[var(--fl-surface-2)] hover:text-[var(--fl-text)]"
>
{complete ? "Staffel zurücksetzen" : "Staffel abhaken"}
</button>
)}
</div>
{isOpen && (
<ol className="border-t border-[var(--fl-border)]">
{season.episodes.map((episode) => (
<EpisodeRow
key={episode.id}
episode={episode}
seasonNumber={season.seasonNumber}
watched={watched.has(episode.id)}
interactive={Boolean(entryId)}
onToggle={(next) => toggleEpisode(episode.id, next)}
onMarkUpTo={() => markUpTo(episode.id)}
/>
))}
{season.episodes.length === 0 && (
<li className="px-4 py-3 text-sm text-[var(--fl-faint)]">
Für diese Staffel liegen noch keine Episoden vor.
</li>
)}
</ol>
)}
</section>
);
})}
</div>
);
}
function EpisodeRow({
episode,
seasonNumber,
watched,
interactive,
onToggle,
onMarkUpTo,
}: {
episode: EpisodeData;
seasonNumber: number;
watched: boolean;
interactive: boolean;
onToggle: (next: boolean) => void;
onMarkUpTo: () => void;
}) {
const [glimmer, setGlimmer] = useState(false);
const previous = useRef(watched);
// Beim Abhaken glimmt die Zeile einmal warm auf.
useEffect(() => {
if (watched && !previous.current) {
setGlimmer(true);
const timer = setTimeout(() => setGlimmer(false), 900);
previous.current = watched;
return () => clearTimeout(timer);
}
previous.current = watched;
}, [watched]);
const upcoming = isUpcoming(episode.airDate);
return (
<li
className={clsx(
"group flex items-start gap-3 border-b border-[var(--fl-border)] px-3 py-2.5 last:border-b-0 sm:px-4",
glimmer && "fl-glimmer",
)}
>
{interactive ? (
<label className="flex cursor-pointer items-center gap-3 pt-0.5">
<input
type="checkbox"
checked={watched}
onChange={(event) => onToggle(event.target.checked)}
className="sr-only"
/>
<Checkbox checked={watched} />
<span className="sr-only">
{episodeCode(seasonNumber, episode.episodeNumber)} als gesehen markieren
</span>
</label>
) : (
<span className="w-5" />
)}
<span className="w-14 shrink-0 pt-1 font-mono text-xs text-[var(--fl-faint)]">
{episodeCode(seasonNumber, episode.episodeNumber)}
</span>
<span className="min-w-0 flex-1">
<span
className={clsx(
"block text-sm font-medium",
watched && "text-[var(--fl-muted)]",
)}
>
{episode.name}
{upcoming && (
<span className="ml-2 rounded-full border border-[var(--fl-border)] px-2 py-0.5 align-middle text-[0.65rem] font-normal text-[var(--fl-faint)]">
noch nicht ausgestrahlt
</span>
)}
</span>
{episode.overview && (
<span className="mt-0.5 line-clamp-2 block text-xs text-[var(--fl-faint)]">
{episode.overview}
</span>
)}
{interactive && !watched && (
<button
type="button"
onClick={onMarkUpTo}
className="mt-1 text-xs text-[var(--fl-accent)] opacity-0 transition-opacity underline-offset-2 hover:underline focus:opacity-100 group-hover:opacity-100"
>
Bis hierhin gesehen
</button>
)}
</span>
<span className="shrink-0 pt-1 text-right text-xs text-[var(--fl-faint)]">
{formatDate(episode.airDate) ?? "—"}
{episode.runtime ? <span className="block">{episode.runtime} min</span> : null}
</span>
</li>
);
}
function Checkbox({ checked }: { checked: boolean }) {
return (
<span
aria-hidden
className={clsx(
"grid h-5 w-5 shrink-0 place-items-center rounded-md border transition-all duration-200",
checked ? "border-transparent" : "border-[var(--fl-border-strong)]",
)}
style={checked ? { backgroundImage: "var(--fl-gradient)" } : undefined}
>
{checked && (
<svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="var(--fl-on-accent)" strokeWidth="3.5">
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)}
</span>
);
}
function Chevron({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 24 24"
className={clsx(
"h-4 w-4 shrink-0 text-[var(--fl-muted)] transition-transform duration-200",
open && "rotate-90",
)}
fill="none"
stroke="currentColor"
strokeWidth="2"
aria-hidden
>
<path d="m9 6 6 6-6 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { purgeEntryAction, restoreEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* Wiederherstellen oder endgültig löschen.
* Das endgültige Löschen fragt nach — es lässt sich nicht zurücknehmen.
*/
export function TrashActions({ entryId, title }: { entryId: string; title: string }) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const [confirming, setConfirming] = useState(false);
return (
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await restoreEntryAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm"
>
Wiederherstellen
</button>
{confirming ? (
<>
<span className="text-sm text-[var(--fl-muted)]">Endgültig löschen?</span>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
report(await purgeEntryAction(entryId));
router.refresh();
})
}
className="fl-btn fl-btn-ghost px-3 py-1.5 text-sm text-[var(--fl-danger)]"
>
Ja, {title} löschen
</button>
<button
type="button"
onClick={() => setConfirming(false)}
className="text-sm text-[var(--fl-faint)] hover:text-[var(--fl-text)]"
>
Abbrechen
</button>
</>
) : (
<button
type="button"
onClick={() => setConfirming(true)}
className="text-sm text-[var(--fl-faint)] underline-offset-4 hover:text-[var(--fl-danger)] hover:underline"
>
Endgültig löschen
</button>
)}
</div>
);
}