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
+194
View File
@@ -0,0 +1,194 @@
"use client";
import { useEffect, useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { setTagsAction, updateEntryAction } from "@/app/actions/library";
import { useToasts } from "@/components/Toasts";
/**
* Notizfeld, „wo schaue ich das" und eigene Tags.
*
* Gespeichert wird beim Verlassen des Feldes, nicht bei jedem Tastendruck —
* das spart Anfragen und erspart das Suchen nach einem Speichern-Knopf.
*/
export function EntryNotes({
entryId,
notes,
whereToWatch,
tags,
suggestion,
}: {
entryId: string;
notes: string | null;
whereToWatch: string | null;
tags: string[];
/** Vorschlag aus der Streaming-Verfügbarkeit. */
suggestion: string | null;
}) {
return (
<div className="space-y-4">
<WhereToWatch entryId={entryId} value={whereToWatch} suggestion={suggestion} />
<TagEditor entryId={entryId} tags={tags} />
<Notes entryId={entryId} value={notes} />
</div>
);
}
function useAutosave(entryId: string) {
const { report } = useToasts();
const router = useRouter();
const [, startTransition] = useTransition();
return (data: Record<string, unknown>) =>
startTransition(async () => {
const result = await updateEntryAction(entryId, data);
if (!result.ok) report(result);
router.refresh();
});
}
function WhereToWatch({
entryId,
value,
suggestion,
}: {
entryId: string;
value: string | null;
suggestion: string | null;
}) {
const save = useAutosave(entryId);
const [text, setText] = useState(value ?? "");
const initial = useRef(value ?? "");
return (
<div className="space-y-1.5">
<label htmlFor="whereToWatch" className="block text-sm font-medium text-[var(--fl-muted)]">
Wo schaue ich das?
</label>
<input
id="whereToWatch"
value={text}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ whereToWatch: text });
}}
placeholder={suggestion ? `Vorschlag: ${suggestion}` : "z. B. Netflix, Blu-ray, Mediathek"}
className="fl-input"
/>
{suggestion && text !== suggestion && (
<button
type="button"
onClick={() => {
setText(suggestion);
initial.current = suggestion;
save({ whereToWatch: suggestion });
}}
className="text-xs text-[var(--fl-accent)] underline-offset-2 hover:underline"
>
{suggestion} übernehmen
</button>
)}
</div>
);
}
function Notes({ entryId, value }: { entryId: string; value: string | null }) {
const save = useAutosave(entryId);
const [text, setText] = useState(value ?? "");
const initial = useRef(value ?? "");
return (
<div className="space-y-1.5">
<label htmlFor="notes" className="block text-sm font-medium text-[var(--fl-muted)]">
Notiz
</label>
<textarea
id="notes"
value={text}
rows={4}
onChange={(event) => setText(event.target.value)}
onBlur={() => {
if (text === initial.current) return;
initial.current = text;
save({ notes: text });
}}
placeholder="Was du dir merken willst — Lieblingsfolge, mit wem du das schaust, wo du aufgehört hast."
className="fl-input resize-y"
/>
</div>
);
}
function TagEditor({ entryId, tags }: { entryId: string; tags: string[] }) {
const { report } = useToasts();
const router = useRouter();
const [current, setCurrent] = useState(tags);
const [draft, setDraft] = useState("");
const [, startTransition] = useTransition();
function commit(next: string[]) {
setCurrent(next);
startTransition(async () => {
const result = await setTagsAction(entryId, next);
if (!result.ok) {
report(result);
setCurrent(tags);
}
router.refresh();
});
}
function add() {
const name = draft.trim().toLocaleLowerCase("de-DE");
setDraft("");
if (!name || current.includes(name)) return;
commit([...current, name]);
}
return (
<div className="space-y-1.5">
<label htmlFor="tag-input" className="block text-sm font-medium text-[var(--fl-muted)]">
Tags
</label>
{current.length > 0 && (
<ul className="flex flex-wrap gap-1.5">
{current.map((tag) => (
<li key={tag}>
<span className="inline-flex items-center gap-1 rounded-full border border-[var(--fl-border)] py-1 pl-3 pr-1.5 text-xs">
{tag}
<button
type="button"
onClick={() => commit(current.filter((entry) => entry !== tag))}
aria-label={`Tag ${tag} entfernen`}
className="rounded-full p-0.5 text-[var(--fl-faint)] hover:text-[var(--fl-danger)]"
>
<svg viewBox="0 0 24 24" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2.5" aria-hidden>
<path d="m6 6 12 12M18 6 6 18" strokeLinecap="round" />
</svg>
</button>
</span>
</li>
))}
</ul>
)}
<input
id="tag-input"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === ",") {
event.preventDefault();
add();
}
}}
onBlur={add}
placeholder="Tag eingeben und Enter drücken"
className="fl-input"
/>
</div>
);
}