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