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,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." };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -332,6 +332,18 @@
|
|||||||
animation: fl-glimmer 900ms var(--ease-glow);
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata, Viewport } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Outfit } from "next/font/google";
|
import { Outfit } from "next/font/google";
|
||||||
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
import { BrandDefs } from "@/components/brand/FlimmerLogo";
|
||||||
|
import { ToastProvider } from "@/components/Toasts";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
>
|
>
|
||||||
Zum Inhalt springen
|
Zum Inhalt springen
|
||||||
</a>
|
</a>
|
||||||
{children}
|
<ToastProvider>{children}</ToastProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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