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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user