Files
flimmer/src/components/Toasts.tsx
T
Florian Wilde b560ee9c51
CI / pruefen (pull_request) Canceled after 0s
Errungenschaften: Abzeichen fuer erreichte Meilensteine
Zwanzig feste Abzeichen plus dynamische fuer vollstaendig gesehene Filmreihen
und abgeschlossene Universen. Sie tauchen auf, wenn etwas erreicht ist, und
bleiben — auch wenn die Bedingung spaeter wegfaellt. Eine zurueckgenommene
Sichtung soll kein Abzeichen kosten.

Alle Regeln arbeiten auf *einem* Datensatz, der einmal geholt wird. Sonst
stellte jede Regel ihre eigene Abfrage, und aus zwanzig Abzeichen wuerden
zwanzig Abfragen bei jedem Abhaken. Ausgewertet wird nach Sichtungsaktionen
und einmal taeglich; der erste Lauf eines Kontos ist stumm, weil eine
gewachsene Bibliothek sonst zwei Dutzend Meldungen auf einmal ausloest.

Fuer Filmreihen braucht es keine gepflegte Liste: TMDB gruppiert sie selbst,
und tmdbCollectionId liegt beim Import schon am Titel. Eigennamen wie
"Spider Bro" kommen aus einer kleinen Datendatei obendrauf; fehlt ein Eintrag,
greift der generische Name.

Die kuratierten Eintraege tragen jeweils ein Stueck des erwarteten
TMDB-Namens. Ich habe die Collection-Kennungen aus dem Gedaechtnis
geschrieben und kann sie hier nicht gegen TMDB pruefen — passt der Name
nicht, wird der Eintrag ignoriert und der generische greift. Eine falsch
abgeschriebene Kennung bleibt damit folgenlos statt jemandem das falsche
Abzeichen zu geben.

Die Uhrzeit-Regel liest die Stunde ueber formatToParts statt ueber die
formatierte Zeichenkette: auf Deutsch liefert Intl fuer eine einzelne Stunde
"03 Uhr", und daraus wird beim Umwandeln NaN. Ein Test hat das aufgedeckt.

Beim ersten Oeffnen der Uebersicht wird einmal ausgewertet — sonst bliebe sie
bei einem Bestandskonto leer, bis nachts der Hintergrundlauf kommt.

Zehn Tests, Anleitung mit Screenshot. Symbole als eigene SVG auf einem
gemeinsamen Raster; erreicht und offen unterscheiden sich nicht nur durch
Farbe, sondern auch im Text.

Refs #15
2026-09-02 12:09:51 +02:00

150 lines
4.7 KiB
TypeScript

"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" | "erfolg";
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,
});
// Errungenschaften bekommen eine eigene Einblendung — sie sollen nicht
// in der Meldung über das Abhaken untergehen.
for (const name of result.achievements ?? []) {
show(`Errungenschaft: ${name}`, { tone: "erfolg" });
}
},
[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)"
: toast.tone === "erfolg"
? "color-mix(in oklab, var(--fl-success) 50%, transparent)"
: "var(--fl-border)",
color:
toast.tone === "error"
? "var(--fl-danger)"
: toast.tone === "erfolg"
? "var(--fl-success)"
: "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;
}