feat: Bedienelemente für Bibliothek und Episodenfortschritt

Server-Aktionen prüfen die Sitzung jeweils selbst — auf Angaben aus dem
Formular ist beim Zugriffsschutz kein Verlass.

Häkchen reagieren sofort und werden zurückgedreht, wenn der Server
widerspricht: Abhaken soll sich anfühlen wie ein Schalter, nicht wie ein
Formular. Jede Sichtungsaktion meldet sich unten kurz zurück und lässt sich
von dort zurücknehmen — die Wirkung passiert weit weg vom Klick.
This commit is contained in:
Florian Wilde
2026-09-01 19:24:51 +02:00
parent 4c3c878980
commit f613136e8a
13 changed files with 1866 additions and 1 deletions
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { markMovieAction, undoAction, watchNextAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
import { episodeCode, formatDate, isUpcoming } from "@/lib/labels";
/**
* „Als Nächstes dran" mit dem Schnellknopf.
*
* Das ist der meistbenutzte Knopf der ganzen Anwendung — deshalb steht er
* oben, ist groß, und sagt vorher, was er abhaken wird.
*/
export function NextEpisodeCard({
entryId,
next,
}: {
entryId: string;
next: {
id: string;
name: string;
seasonNumber: number;
episodeNumber: number;
airDate: string | null;
runtime: number | null;
};
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
const upcoming = isUpcoming(next.airDate);
const code = episodeCode(next.seasonNumber, next.episodeNumber);
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
return (
<div className="flex flex-wrap items-center gap-4 rounded-xl border border-[var(--fl-border)] bg-[var(--fl-surface-2)] p-4">
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wide text-[var(--fl-faint)]">
Als Nächstes dran
</p>
<p className="mt-1 truncate font-medium">
<span className="fl-gradient-text font-mono font-semibold">{code}</span>{" "}
<span className="text-[var(--fl-text)]">{next.name}</span>
</p>
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
{upcoming
? `Erscheint erst am ${formatDate(next.airDate)}`
: [formatDate(next.airDate), next.runtime ? `${next.runtime} min` : null]
.filter(Boolean)
.join(" · ") || "Kein Ausstrahlungsdatum hinterlegt"}
</p>
</div>
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await watchNextAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
title={`${code} als gesehen markieren`}
>
+1 Episode
</button>
</div>
);
}
/** Sichtungsknopf für Filme. */
export function WatchMovieButton({
entryId,
watched,
}: {
entryId: string;
watched: boolean;
}) {
const router = useRouter();
const { report } = useToasts();
const [pending, startTransition] = useTransition();
async function undo(watchIds: string[]) {
await undoAction(entryId, watchIds);
router.refresh();
}
if (watched) {
return (
<p className="inline-flex items-center gap-2 rounded-xl border border-[color-mix(in_oklab,var(--fl-success)_45%,transparent)] bg-[color-mix(in_oklab,var(--fl-success)_12%,transparent)] px-3 py-2 text-sm text-[var(--fl-success)]">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="3" aria-hidden>
<path d="m5 13 4.5 4.5L19 7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Gesehen
</p>
);
}
return (
<button
type="button"
disabled={pending}
onClick={() =>
startTransition(async () => {
const result = await markMovieAction(entryId);
report(result, (ids) => undo(ids));
router.refresh();
})
}
className="fl-btn fl-btn-primary"
>
Als gesehen markieren
</button>
);
}