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:
Florian Wilde
2026-08-14 23:34:28 +02:00
parent fdf6452b35
commit e296b1e6c2
32 changed files with 1819 additions and 37 deletions
+116
View File
@@ -0,0 +1,116 @@
"use client";
import type { QrFormProps } from "@/components/qr-forms/types";
import { Checkbox, Field, Select, TextArea, TextInput } from "@/components/ui";
import { FEEDBACK_SCALES } from "@/lib/qr/types/feedback";
export function FeedbackForm({ value, onChange, error, disabled }: QrFormProps) {
const title = typeof value.title === "string" ? value.title : "";
const question = typeof value.question === "string" ? value.question : "";
const scale = value.scale === "faces" ? "faces" : "stars";
const allowComment = value.allowComment !== false;
const commentLabel =
typeof value.commentLabel === "string" ? value.commentLabel : "";
const thankYou = typeof value.thankYou === "string" ? value.thankYou : "";
return (
<div className="space-y-5">
<Field
label="Überschrift der Seite"
htmlFor="feedback-title"
error={error}
hint="Wo hängt der Code? Zum Beispiel „Gastraum“ oder „Werkstatt“."
>
<TextInput
id="feedback-title"
value={title}
invalid={Boolean(error)}
disabled={disabled}
maxLength={80}
placeholder="Wie war dein Besuch?"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Frage"
htmlFor="feedback-question"
hint="Steht über den Bewertungssymbolen."
>
<TextInput
id="feedback-question"
value={question}
disabled={disabled}
maxLength={160}
placeholder="Wie zufrieden bist du?"
onChange={(event) =>
onChange({ ...value, question: event.target.value })
}
/>
</Field>
<Field
label="Bewertungsskala"
htmlFor="feedback-scale"
hint="In beiden Fällen wird 1 bis 5 gespeichert."
>
<Select
id="feedback-scale"
value={scale}
disabled={disabled}
onChange={(event) => onChange({ ...value, scale: event.target.value })}
>
{Object.entries(FEEDBACK_SCALES).map(([id, eintrag]) => (
<option key={id} value={id}>
{eintrag.label}
</option>
))}
</Select>
</Field>
<Checkbox
id="feedback-allow-comment"
checked={allowComment}
disabled={disabled}
onChange={(event) =>
onChange({ ...value, allowComment: event.target.checked })
}
label="Zusätzlich einen freien Text zulassen"
hint="Sonst gibt es nur die Note — das senkt die Hemmschwelle."
/>
{allowComment ? (
<Field label="Beschriftung des Textfelds" htmlFor="feedback-comment-label">
<TextInput
id="feedback-comment-label"
value={commentLabel}
disabled={disabled}
maxLength={120}
placeholder="Möchtest du uns noch etwas sagen?"
onChange={(event) =>
onChange({ ...value, commentLabel: event.target.value })
}
/>
</Field>
) : null}
<Field
label="Text nach dem Absenden"
htmlFor="feedback-thanks"
hint="Das Letzte, was jemand von dieser Seite sieht."
>
<TextArea
id="feedback-thanks"
rows={2}
value={thankYou}
disabled={disabled}
maxLength={200}
placeholder="Danke für deine Rückmeldung!"
onChange={(event) =>
onChange({ ...value, thankYou: event.target.value })
}
/>
</Field>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useId, useRef, useState } from "react";
import { Icon } from "@/components/icons";
import { Button } from "@/components/ui";
import { UPLOAD_KINDS, type UploadKind } from "@/lib/uploads.shared";
export type UploadedFile = {
fileId: string;
filename: string;
size: number;
};
/**
* Datei auswählen und sofort hochladen.
*
* Der Upload passiert vor dem Speichern des Codes, weil das Formular die Id
* der Datei braucht. Bleibt der Code ungespeichert, räumt der Server die
* Datei später von selbst weg.
*/
export function FileField({
kind,
onUploaded,
disabled,
label,
}: {
kind: UploadKind;
onUploaded: (file: UploadedFile) => void;
disabled?: boolean;
label: string;
}) {
const inputId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
async function hochladen(file: File) {
setFehler(null);
setLaeuft(true);
try {
const form = new FormData();
form.set("kind", kind);
form.set("file", file);
const response = await fetch("/api/uploads", {
method: "POST",
body: form,
});
const body = await response.json().catch(() => null);
if (!response.ok) {
setFehler(body?.error ?? "Die Datei konnte nicht hochgeladen werden.");
return;
}
onUploaded({
fileId: body.upload.id,
filename: body.upload.filename,
size: body.upload.size,
});
} catch {
setFehler(
"Die Datei konnte nicht hochgeladen werden. Besteht die Verbindung noch?",
);
} finally {
setLaeuft(false);
// Damit dieselbe Datei erneut gewählt werden kann.
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div>
<input
ref={inputRef}
id={inputId}
type="file"
accept={UPLOAD_KINDS[kind].accept}
disabled={disabled || laeuft}
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void hochladen(file);
}}
/>
<Button
type="button"
variant="secondary"
disabled={disabled || laeuft}
onClick={() => inputRef.current?.click()}
>
{laeuft ? (
"Wird hochgeladen …"
) : (
<>
<Icon name="download" className="size-4 rotate-180" />
{label}
</>
)}
</Button>
{fehler ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{fehler}
</p>
) : null}
</div>
);
}
+193
View File
@@ -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>
);
}
+6
View File
@@ -1,7 +1,10 @@
import type { ComponentType } from "react";
import { FeedbackForm } from "@/components/qr-forms/feedback-form";
import { ImageForm } from "@/components/qr-forms/image-form";
import { LinksForm } from "@/components/qr-forms/links-form";
import { PaymentForm } from "@/components/qr-forms/payment-form";
import { PdfForm } from "@/components/qr-forms/pdf-form";
import {
AppForm,
EmailForm,
@@ -30,6 +33,9 @@ export const qrForms: Partial<Record<QrTypeId, ComponentType<QrFormProps>>> = {
app: AppForm,
links: LinksForm,
social: SocialForm,
pdf: PdfForm,
image: ImageForm,
feedback: FeedbackForm,
video: VideoForm,
whatsapp: WhatsappForm,
email: EmailForm,
+110
View File
@@ -0,0 +1,110 @@
"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 { formatBytes } from "@/lib/uploads.shared";
export function PdfForm({ value, onChange, error, disabled }: QrFormProps) {
const fileId = typeof value.fileId === "string" ? value.fileId : "";
const filename = typeof value.filename === "string" ? value.filename : "";
const size = typeof value.size === "number" ? value.size : 0;
const title = typeof value.title === "string" ? value.title : "";
const description =
typeof value.description === "string" ? value.description : "";
return (
<div className="space-y-5">
<div>
<span className="mb-1.5 block text-sm font-medium text-slate-700">
PDF-Datei
</span>
{fileId ? (
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50/70 p-3">
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-red-50 text-red-600">
<Icon name="document" className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{filename || "Dokument"}
</p>
<p className="text-xs text-slate-500">
{size > 0 ? formatBytes(size) : "PDF"}
</p>
</div>
<button
type="button"
disabled={disabled}
onClick={() =>
onChange({ ...value, fileId: "", filename: "", size: 0 })
}
aria-label="Datei 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>
) : (
<FileField
kind="pdf"
label="PDF auswählen"
disabled={disabled}
onUploaded={(file) =>
onChange({
...value,
fileId: file.fileId,
filename: file.filename,
size: file.size,
})
}
/>
)}
{error ? (
<p role="alert" className="mt-2 text-sm text-red-600">
{error}
</p>
) : (
<p className="mt-2 text-sm text-slate-500">
Die Datei liegt anschließend hier und ist über den Code erreichbar.
Sie lässt sich jederzeit austauschen, ohne den Code neu zu drucken.
</p>
)}
</div>
<Field
label="Titel (optional)"
htmlFor="pdf-title"
hint="Steht über dem Dokument. Ohne Angabe gilt der Dateiname."
>
<TextInput
id="pdf-title"
value={title}
disabled={disabled}
maxLength={80}
placeholder="Preisliste 2026"
onChange={(event) => onChange({ ...value, title: event.target.value })}
/>
</Field>
<Field
label="Kurzer Text (optional)"
htmlFor="pdf-description"
hint="Eine Zeile zur Einordnung."
>
<TextArea
id="pdf-description"
rows={2}
value={description}
disabled={disabled}
maxLength={300}
onChange={(event) =>
onChange({ ...value, description: event.target.value })
}
/>
</Field>
</div>
);
}