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