Phase C: die letzten sechs Typen — PDF, Bild, Feedback und Datei-Upload
Damit sind alle 15 QR-Typen gebaut. PDF und Bild brauchen eine hochgeladene Datei. Die liegt in der Datenbank, nicht auf der Platte: damit bleibt es bei einem Volumen, das gesichert werden muss — ein zweites wird beim Sichern zu leicht vergessen. Geprueft wird der Dateiinhalt, nicht die Endung; eine Textdatei namens harmlos.pdf kommt nicht durch. SVG ist bewusst nicht erlaubt, weil es Skripte enthalten darf und von derselben Adresse wie die Anwendung ausgeliefert wuerde. Ausgeliefert wird mit nosniff und einer CSP-Sandbox, damit auch ein PDF mit Skripten nichts anrichtet. Dateien haengen am Code: wird er geloescht, geht sie mit. Eine ausgetauschte oder nie gespeicherte Datei wird nach 24 Stunden weggeraeumt. Eine fremde Datei laesst sich nicht an den eigenen Code haengen — sonst koennte man sie einem anderen Code entziehen. Feedback sammelt Note und Kommentar, ohne Name, IP oder Geraetekennung. Die Auswertung mit Durchschnitt, Verteilung und Kommentaren ist aus der Codeliste erreichbar — ein Feedback-Code ohne Auswertung waere nur die halbe Sache.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
"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-white 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-white 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-white 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-white 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user