Drei Zustaende in der Kopfzeile — System, Hell, Dunkel. Drei statt zwei, weil ein reiner Ein/Aus-Schalter nach dem ersten Klick fuer immer eine feste Wahl bedeutet; wer "System" waehlt, bekommt abends dunkel und morgens hell. Umgesetzt ueber `light-dark()` im CSS, nicht ueber `dark:`-Klassen. Die Oberflaeche benutzt an rund 440 Stellen feste Farbklassen; jede davon zu verdoppeln waere eine riesige Fleissarbeit, und jede kuenftige Zeile muesste daran denken. Stattdessen entscheiden die Farbvariablen selbst, welchen Wert sie tragen — gesteuert ueber `color-scheme`. Deshalb genuegt fuer den Umschalter ein Attribut am html-Element, und die Systemwahl funktioniert sogar ohne JavaScript. Das Skript im Kopf setzt nur eine ausdrueckliche Wahl, damit die helle Fassung nicht kurz aufblitzt. Zwei Faelle liessen sich nicht ueber Variablen loesen: `bg-white` muss im Dunkeln eine dunkle Flaeche sein, `text-white` dagegen weiss bleiben — beide teilen sich aber dieselbe Variable. Fuer Flaechen gibt es deshalb jetzt `bg-surface`. Ebenso brauchen Akzent-Links im Dunkeln eine hellere Stufe als Akzent-Knoepfe: dafuer `text-accent-text` und `bg-accent-soft`. Bewusst unveraendert bleiben die QR-Codes selbst — deren Farben legt die Gestaltung fest. Die dunklen Werte sind keine Spiegelung: Reines Schwarz unter weisser Schrift flimmert. Alle Textkontraste sind gemessen und liegen ueber 4,5:1, der niedrigste bei 5,9:1.
178 lines
6.6 KiB
TypeScript
178 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { Icon } from "@/components/icons";
|
|
import type { QrFormProps } from "@/components/qr-forms/types";
|
|
import { Button, Field, TextArea, TextInput } from "@/components/ui";
|
|
import { MAX_LINKS, type LinkEntry } from "@/lib/qr/types/links";
|
|
|
|
/**
|
|
* Liest die Linkliste aus den Formulardaten heraus.
|
|
*
|
|
* Die Daten kommen als `QrFormData` — ein flaches Objekt ohne Garantien.
|
|
* Beim Bearbeiten eines gespeicherten Codes stammen sie aus der Datenbank und
|
|
* können nach einer Schema-Änderung unvollständig sein; deshalb hier prüfen
|
|
* statt annehmen.
|
|
*/
|
|
function readLinks(value: unknown): LinkEntry[] {
|
|
if (!Array.isArray(value)) return [{ label: "", url: "" }];
|
|
const entries = value.map((entry) => {
|
|
const record = (entry ?? {}) as Record<string, unknown>;
|
|
return {
|
|
label: typeof record.label === "string" ? record.label : "",
|
|
url: typeof record.url === "string" ? record.url : "",
|
|
};
|
|
});
|
|
return entries.length > 0 ? entries : [{ label: "", url: "" }];
|
|
}
|
|
|
|
export function LinksForm({ value, onChange, error, disabled }: QrFormProps) {
|
|
const title = typeof value.title === "string" ? value.title : "";
|
|
const description =
|
|
typeof value.description === "string" ? value.description : "";
|
|
const links = readLinks(value.links);
|
|
|
|
const setLinks = (next: LinkEntry[]) => onChange({ ...value, links: next });
|
|
|
|
const updateLink = (index: number, patch: Partial<LinkEntry>) =>
|
|
setLinks(links.map((link, i) => (i === index ? { ...link, ...patch } : link)));
|
|
|
|
const move = (index: number, direction: -1 | 1) => {
|
|
const target = index + direction;
|
|
if (target < 0 || target >= links.length) return;
|
|
const next = [...links];
|
|
[next[index], next[target]] = [next[target], next[index]];
|
|
setLinks(next);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<Field
|
|
label="Überschrift der Seite"
|
|
htmlFor="links-title"
|
|
hint="Steht groß über der Linkliste, z. B. der Projektname."
|
|
>
|
|
<TextInput
|
|
id="links-title"
|
|
value={title}
|
|
disabled={disabled}
|
|
maxLength={80}
|
|
placeholder="Projekt Nordwind"
|
|
onChange={(event) => onChange({ ...value, title: event.target.value })}
|
|
/>
|
|
</Field>
|
|
|
|
<Field
|
|
label="Kurzer Text (optional)"
|
|
htmlFor="links-description"
|
|
hint="Eine Zeile zur Einordnung — was findet man hier?"
|
|
>
|
|
<TextArea
|
|
id="links-description"
|
|
rows={2}
|
|
value={description}
|
|
disabled={disabled}
|
|
maxLength={300}
|
|
placeholder="Alle Unterlagen zum Projekt auf einen Blick."
|
|
onChange={(event) =>
|
|
onChange({ ...value, description: event.target.value })
|
|
}
|
|
/>
|
|
</Field>
|
|
|
|
<div>
|
|
<div className="mb-2 flex items-baseline justify-between gap-3">
|
|
<span className="text-sm font-medium text-slate-700">Links</span>
|
|
<span className="text-xs text-slate-500">
|
|
{links.length} von {MAX_LINKS}
|
|
</span>
|
|
</div>
|
|
|
|
<ul className="space-y-3">
|
|
{links.map((link, index) => (
|
|
<li
|
|
key={index}
|
|
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className="min-w-0 flex-1 space-y-2">
|
|
<TextInput
|
|
aria-label={`Beschriftung von Link ${index + 1}`}
|
|
value={link.label}
|
|
disabled={disabled}
|
|
maxLength={80}
|
|
placeholder="Beschriftung, z. B. Preisliste"
|
|
onChange={(event) =>
|
|
updateLink(index, { label: event.target.value })
|
|
}
|
|
/>
|
|
<TextInput
|
|
aria-label={`Adresse von Link ${index + 1}`}
|
|
inputMode="url"
|
|
spellCheck={false}
|
|
value={link.url}
|
|
disabled={disabled}
|
|
maxLength={2048}
|
|
placeholder="https://example.com/preisliste.pdf"
|
|
onChange={(event) =>
|
|
updateLink(index, { url: event.target.value })
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex shrink-0 flex-col gap-0.5">
|
|
<button
|
|
type="button"
|
|
disabled={disabled || index === 0}
|
|
onClick={() => move(index, -1)}
|
|
aria-label={`Link ${index + 1} nach oben`}
|
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
|
>
|
|
<Icon name="arrowLeft" className="size-4 rotate-90" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={disabled || index === links.length - 1}
|
|
onClick={() => move(index, 1)}
|
|
aria-label={`Link ${index + 1} nach unten`}
|
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
|
>
|
|
<Icon name="arrowLeft" className="size-4 -rotate-90" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={disabled || links.length === 1}
|
|
onClick={() => setLinks(links.filter((_, i) => i !== index))}
|
|
aria-label={`Link ${index + 1} entfernen`}
|
|
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent"
|
|
>
|
|
<Icon name="trash" className="size-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
|
|
{links.length < MAX_LINKS ? (
|
|
<Button
|
|
type="button"
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={disabled}
|
|
onClick={() => setLinks([...links, { label: "", url: "" }])}
|
|
className="mt-3"
|
|
>
|
|
Link hinzufügen
|
|
</Button>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<p role="alert" className="mt-2 text-sm text-red-600">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|