Files
qrcode-generator/src/components/qr-forms/image-form.tsx
T
Florian Wilde b828f38ca5 Dark Mode: folgt dem System, mit Umschalter
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.
2026-08-16 09:09:10 +02:00

194 lines
7.1 KiB
TypeScript

"use client";
import { Icon } from "@/components/icons";
import { FileField } from "@/components/qr-forms/file-field";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Field, TextArea, TextInput } from "@/components/ui";
import { MAX_IMAGES, type ImageEntry } from "@/lib/qr/types/image";
import { formatBytes } from "@/lib/uploads.shared";
function readImages(value: unknown): ImageEntry[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
const record = (entry ?? {}) as Record<string, unknown>;
if (typeof record.fileId !== "string" || record.fileId.length === 0) {
return [];
}
return [
{
fileId: record.fileId,
filename: typeof record.filename === "string" ? record.filename : "bild",
size: typeof record.size === "number" ? record.size : 0,
caption: typeof record.caption === "string" ? record.caption : "",
},
];
});
}
export function ImageForm({ value, onChange, error, disabled }: QrFormProps) {
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
const images = readImages(value.images);
const setImages = (next: ImageEntry[]) => onChange({ ...value, images: next });
const move = (index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= images.length) return;
const next = [...images];
[next[index], next[target]] = [next[target], next[index]];
setImages(next);
};
return (
<div className="space-y-5">
<div>
<div className="mb-2 flex items-baseline justify-between gap-3">
<span className="text-sm font-medium text-slate-700">Bilder</span>
<span className="text-xs text-slate-500">
{images.length} von {MAX_IMAGES}
</span>
</div>
{images.length > 0 ? (
<ul className="mb-3 space-y-3">
{images.map((image, index) => (
<li
key={image.fileId}
className="rounded-xl border border-slate-200 bg-slate-50/70 p-3"
>
<div className="flex items-start gap-3">
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
Auslieferung, unbekannte Maße */}
<img
src={`/api/uploads/${image.fileId}`}
alt=""
className="size-14 shrink-0 rounded-lg border border-slate-200 bg-surface object-cover"
/>
<div className="min-w-0 flex-1 space-y-2">
<p className="truncate text-sm text-slate-600">
{image.filename}
{image.size > 0 ? ` · ${formatBytes(image.size)}` : ""}
</p>
<TextInput
aria-label={`Bildunterschrift von Bild ${index + 1}`}
value={image.caption}
disabled={disabled}
maxLength={120}
placeholder="Bildunterschrift (optional)"
onChange={(event) =>
setImages(
images.map((entry, i) =>
i === index
? { ...entry, caption: event.target.value }
: entry,
),
)
}
/>
</div>
<div className="flex shrink-0 flex-col gap-0.5">
<button
type="button"
disabled={disabled || index === 0}
onClick={() => move(index, -1)}
aria-label={`Bild ${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 === images.length - 1}
onClick={() => move(index, 1)}
aria-label={`Bild ${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}
onClick={() =>
setImages(images.filter((_, i) => i !== index))
}
aria-label={`Bild ${index + 1} entfernen`}
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-surface hover:text-red-600 disabled:opacity-40"
>
<Icon name="trash" className="size-4" />
</button>
</div>
</div>
</li>
))}
</ul>
) : null}
{images.length < MAX_IMAGES ? (
<FileField
kind="image"
label={images.length === 0 ? "Bild auswählen" : "Weiteres Bild"}
disabled={disabled}
onUploaded={(file) =>
setImages([
...images,
{
fileId: file.fileId,
filename: file.filename,
size: file.size,
caption: "",
},
])
}
/>
) : null}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : (
<p className="mt-2 text-sm text-slate-500">
PNG, JPEG, WebP, GIF oder AVIF. Mehrere Bilder erscheinen
untereinander als Galerie.
</p>
)}
</div>
<Field
label="Titel (optional)"
htmlFor="image-title"
hint="Steht über den Bildern."
>
<TextInput
id="image-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Impressionen vom Hoffest"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="image-description"
hint="Eine Zeile zur Einordnung."
>
<TextArea
id="image-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
</div>
);
}