Errungenschaften: Abzeichen fuer erreichte Meilensteine
CI / pruefen (pull_request) Canceled after 0s
CI / pruefen (pull_request) Canceled after 0s
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
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
import { clsx } from "clsx";
|
||||
import type { SymbolName } from "@/lib/achievements";
|
||||
|
||||
/**
|
||||
* Abzeichen mit Symbol.
|
||||
*
|
||||
* Die Symbole sind eigene SVG statt Emoji: Emoji sehen auf jedem System
|
||||
* anders aus, lassen sich nicht einfärben und passen nicht zur Formsprache
|
||||
* des Logos. Alle liegen auf demselben 24er-Raster, zeichnen nur mit
|
||||
* `currentColor` und tragen keine Farbe für sich — die kommt vom Zustand.
|
||||
*
|
||||
* Erreicht und offen unterscheiden sich **nicht nur durch Farbe**: das offene
|
||||
* Abzeichen ist blass *und* trägt den Hinweis „noch offen" im Text, das
|
||||
* erreichte nennt sein Datum. Farbe allein wäre für Screenreader und bei
|
||||
* Farbsehschwäche nichts wert.
|
||||
*/
|
||||
|
||||
const PFADE: Record<SymbolName, React.ReactNode> = {
|
||||
spross: (
|
||||
<>
|
||||
<path d="M12 21v-8" />
|
||||
<path d="M12 13c0-3 2-5 5-5 0 3-2 5-5 5Z" />
|
||||
<path d="M12 16c0-2.5-1.8-4.5-4.5-4.5 0 2.5 1.8 4.5 4.5 4.5Z" />
|
||||
</>
|
||||
),
|
||||
haken: <path d="m4 12.5 5 5L20 6.5" />,
|
||||
schluessel: (
|
||||
<>
|
||||
<circle cx="8" cy="12" r="3.5" />
|
||||
<path d="M11.5 12H21m-3 0v3.5m-3-3.5v2.5" />
|
||||
</>
|
||||
),
|
||||
haus: <path d="M3.5 11 12 4l8.5 7M6 9.5V20h12V9.5M10 20v-5.5h4V20" />,
|
||||
klappe: (
|
||||
<>
|
||||
<rect x="3" y="9" width="18" height="11" rx="2" />
|
||||
<path d="m3.5 9 3.5-4.5 3.5 4.5m0 0L14 4.5 17.5 9" />
|
||||
</>
|
||||
),
|
||||
planet: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<path d="M3.5 14.5c5 2.5 12 2.5 17 0" />
|
||||
</>
|
||||
),
|
||||
kompass: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="m15.5 8.5-2 5.5-5.5 2 2-5.5Z" />
|
||||
</>
|
||||
),
|
||||
bildschirm: (
|
||||
<>
|
||||
<rect x="3" y="5" width="18" height="12" rx="2" />
|
||||
<path d="M8 21h8m-4-4v4" />
|
||||
</>
|
||||
),
|
||||
filmrolle: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<circle cx="12" cy="8.5" r="1.6" />
|
||||
<circle cx="15.4" cy="13.8" r="1.6" />
|
||||
<circle cx="8.6" cy="13.8" r="1.6" />
|
||||
</>
|
||||
),
|
||||
sonne: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4" />
|
||||
</>
|
||||
),
|
||||
kalender: (
|
||||
<>
|
||||
<rect x="3.5" y="5" width="17" height="15" rx="2" />
|
||||
<path d="M3.5 10h17M8 3v4m8-4v4" />
|
||||
</>
|
||||
),
|
||||
laeufer: (
|
||||
<>
|
||||
<circle cx="14" cy="5" r="2" />
|
||||
<path d="m8 21 3-6 3 2 1 5M11 15l-2-4 4-3 3 3 3 .5" />
|
||||
</>
|
||||
),
|
||||
berg: <path d="m2.5 19 6-11 4 6.5 2.5-4 6.5 8.5Z" />,
|
||||
herz: <path d="M12 20s-7.5-4.6-7.5-9.4A4.1 4.1 0 0 1 12 8a4.1 4.1 0 0 1 7.5 2.6C19.5 15.4 12 20 12 20Z" />,
|
||||
kreis: (
|
||||
<>
|
||||
<path d="M20 12a8 8 0 1 1-3.2-6.4" />
|
||||
<path d="M20 4v5h-5" />
|
||||
</>
|
||||
),
|
||||
maske: (
|
||||
<>
|
||||
<path d="M4 6h7v6a3.5 3.5 0 0 1-7 0Z" />
|
||||
<path d="M13 6h7v6a3.5 3.5 0 0 1-7 0Z" />
|
||||
<path d="M6.5 9h2m7 0h2" />
|
||||
</>
|
||||
),
|
||||
sanduhr: (
|
||||
<>
|
||||
<path d="M7 3h10M7 21h10" />
|
||||
<path d="M8 3v3.5L12 12l4-5.5V3M8 21v-3.5L12 12l4 5.5V21" />
|
||||
</>
|
||||
),
|
||||
stern: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9-5.2-2.8-5.2 2.8 1-5.9L3.5 9.7l5.9-.8Z" />,
|
||||
salz: (
|
||||
<>
|
||||
<path d="M8.5 9h7l1 11h-9Z" />
|
||||
<path d="M9.5 9V5.5h5V9M11 3h2" />
|
||||
</>
|
||||
),
|
||||
scheibe: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<circle cx="12" cy="12" r="2.5" />
|
||||
</>
|
||||
),
|
||||
besen: (
|
||||
<>
|
||||
<path d="M14 3 8.5 12.5" />
|
||||
<path d="m5 14 6-3.5 4 6.5-6 3.5Z" />
|
||||
<path d="m9 20.5 2.5 0.5m2-2 2.5.5m-.5-3.5 2.5.5" />
|
||||
</>
|
||||
),
|
||||
mond: <path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />,
|
||||
spinne: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 9V5m0 10v4m-3-7H5m14 0h-4M9.5 9.5 6.5 6.5m8 3 3-3m-8 8-3 3m8-3 3 3" />
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
export function AchievementSymbol({
|
||||
symbol,
|
||||
className,
|
||||
}: {
|
||||
symbol: SymbolName;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className={clsx("h-6 w-6", className)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
{PFADE[symbol]}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AchievementBadge({
|
||||
name,
|
||||
beschreibung,
|
||||
symbol,
|
||||
unlockedAt,
|
||||
}: {
|
||||
name: string;
|
||||
beschreibung: string;
|
||||
symbol: SymbolName;
|
||||
unlockedAt: string | null;
|
||||
}) {
|
||||
const erreicht = unlockedAt !== null;
|
||||
|
||||
return (
|
||||
<li
|
||||
className={clsx(
|
||||
"flex items-start gap-3 rounded-xl border p-3 transition-colors",
|
||||
erreicht
|
||||
? "border-[color-mix(in_oklab,var(--fl-accent)_35%,transparent)] bg-[color-mix(in_oklab,var(--fl-accent)_7%,transparent)]"
|
||||
: "border-[var(--fl-border)]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={clsx("mt-0.5 shrink-0", erreicht ? "text-[var(--fl-accent)]" : "text-[var(--fl-faint)]")}
|
||||
style={erreicht ? { filter: "drop-shadow(0 0 6px color-mix(in oklab, var(--fl-accent) 40%, transparent))" } : undefined}
|
||||
>
|
||||
<AchievementSymbol symbol={symbol} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0">
|
||||
<p className={clsx("font-medium", !erreicht && "text-[var(--fl-muted)]")}>{name}</p>
|
||||
<p className="text-xs text-[var(--fl-faint)]">{beschreibung}</p>
|
||||
<p className="mt-0.5 text-xs text-[var(--fl-faint)]">
|
||||
{erreicht ? `erreicht am ${formatiere(unlockedAt)}` : "noch offen"}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function formatiere(wert: string): string {
|
||||
return new Date(wert).toLocaleDateString("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import type { ActionResult } from "@/app/actions/library";
|
||||
type Toast = {
|
||||
id: number;
|
||||
message: string;
|
||||
tone: "info" | "error";
|
||||
tone: "info" | "error" | "erfolg";
|
||||
undo?: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -58,6 +58,12 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
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],
|
||||
);
|
||||
@@ -96,8 +102,15 @@ function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }
|
||||
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)",
|
||||
: 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>
|
||||
|
||||
@@ -100,6 +100,7 @@ export function ProfileForm({
|
||||
email: string;
|
||||
region: string;
|
||||
publicProfile: boolean;
|
||||
showAchievements: boolean;
|
||||
profileSlug: string | null;
|
||||
notifyNewEpisode: boolean;
|
||||
notifyRelease: boolean;
|
||||
@@ -121,6 +122,7 @@ export function ProfileForm({
|
||||
name: state.name,
|
||||
region: state.region,
|
||||
publicProfile: state.publicProfile,
|
||||
showAchievements: state.showAchievements,
|
||||
profileSlug: state.profileSlug || null,
|
||||
notifyNewEpisode: state.notifyNewEpisode,
|
||||
notifyRelease: state.notifyRelease,
|
||||
@@ -190,14 +192,22 @@ export function ProfileForm({
|
||||
hint="Eine schlichte Seite mit deinen Zahlen und zuletzt gesehenen Titeln — ohne Notizen."
|
||||
/>
|
||||
{state.publicProfile && (
|
||||
<Field
|
||||
id="profileSlug"
|
||||
label="Adresse"
|
||||
value={state.profileSlug ?? ""}
|
||||
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
|
||||
placeholder="dein-name"
|
||||
hint="Erreichbar unter /u/<adresse>"
|
||||
/>
|
||||
<>
|
||||
<Field
|
||||
id="profileSlug"
|
||||
label="Adresse"
|
||||
value={state.profileSlug ?? ""}
|
||||
onChange={(event) => setState({ ...state, profileSlug: event.target.value })}
|
||||
placeholder="dein-name"
|
||||
hint="Erreichbar unter /u/<adresse>"
|
||||
/>
|
||||
<Toggle
|
||||
checked={state.showAchievements}
|
||||
onChange={(value) => setState({ ...state, showAchievements: value })}
|
||||
label="Errungenschaften zeigen"
|
||||
hint="Die erreichten Abzeichen erscheinen auf der Profilseite."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user