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:
@@ -524,6 +524,18 @@ export function CodesList() {
|
||||
|
||||
<td className="py-2.5 pr-4 pl-3">
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{/* Ein Feedback-Code ohne Weg zu den Antworten waere
|
||||
nur die halbe Sache. */}
|
||||
{code.type === "feedback" ? (
|
||||
<Link
|
||||
href={`/meine-codes/${code.id}/feedback`}
|
||||
title="Rückmeldungen ansehen"
|
||||
aria-label={`Rückmeldungen zu ${displayName(code)} ansehen`}
|
||||
className="rounded-md p-1.5 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900"
|
||||
>
|
||||
<Icon name="star" className="size-4" />
|
||||
</Link>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(code)}
|
||||
@@ -655,6 +667,15 @@ export function CodesList() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{code.type === "feedback" ? (
|
||||
<Link
|
||||
href={`/meine-codes/${code.id}/feedback`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="star" className="size-3.5" />
|
||||
Rückmeldungen
|
||||
</Link>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { LandingShell } from "@/components/landing/shell";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { FEEDBACK_SCALES, feedbackDataSchema } from "@/lib/qr/types/feedback";
|
||||
|
||||
export function FeedbackLanding({ data, codeId }: LandingProps) {
|
||||
const { title, question, scale, allowComment, commentLabel, thankYou } =
|
||||
feedbackDataSchema.parse(data);
|
||||
|
||||
const [note, setNote] = useState<number | null>(null);
|
||||
const [kommentar, setKommentar] = useState("");
|
||||
const [laeuft, setLaeuft] = useState(false);
|
||||
const [fertig, setFertig] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
|
||||
const symbole = FEEDBACK_SCALES[scale].symbols;
|
||||
|
||||
async function absenden() {
|
||||
if (note === null) return;
|
||||
setLaeuft(true);
|
||||
setFehler(null);
|
||||
try {
|
||||
const response = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ codeId, rating: note, comment: kommentar }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null);
|
||||
setFehler(body?.error ?? "Die Rückmeldung konnte nicht gesendet werden.");
|
||||
return;
|
||||
}
|
||||
setFertig(true);
|
||||
} catch {
|
||||
setFehler(
|
||||
"Die Rückmeldung konnte nicht gesendet werden. Besteht die Verbindung noch?",
|
||||
);
|
||||
} finally {
|
||||
setLaeuft(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (fertig) {
|
||||
return (
|
||||
<LandingShell title={title}>
|
||||
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-6 py-10 text-center">
|
||||
<p className="text-4xl" aria-hidden="true">
|
||||
✓
|
||||
</p>
|
||||
<p className="mt-3 text-lg font-medium text-emerald-900">
|
||||
{thankYou}
|
||||
</p>
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LandingShell title={title}>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
|
||||
<fieldset>
|
||||
<legend className="text-center text-base font-medium text-slate-900">
|
||||
{question}
|
||||
</legend>
|
||||
|
||||
<div className="mt-5 flex justify-center gap-1.5 sm:gap-2">
|
||||
{symbole.map((symbol, index) => {
|
||||
const wert = index + 1;
|
||||
const gewaehlt = note !== null && note >= wert;
|
||||
return (
|
||||
<button
|
||||
key={wert}
|
||||
type="button"
|
||||
aria-label={`${wert} von 5`}
|
||||
aria-pressed={note === wert}
|
||||
disabled={laeuft}
|
||||
onClick={() => setNote(wert)}
|
||||
className={
|
||||
scale === "stars"
|
||||
? `rounded-lg px-1 text-4xl transition-transform hover:scale-110 sm:text-5xl ${
|
||||
gewaehlt ? "text-amber-400" : "text-slate-200"
|
||||
}`
|
||||
: `rounded-lg px-1 text-3xl transition-transform hover:scale-110 sm:text-4xl ${
|
||||
note === wert
|
||||
? "scale-110"
|
||||
: "opacity-40 grayscale"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{symbol}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{note !== null ? (
|
||||
<p className="mt-2 text-center text-sm text-slate-500">
|
||||
{note} von 5
|
||||
</p>
|
||||
) : null}
|
||||
</fieldset>
|
||||
|
||||
{allowComment ? (
|
||||
<div className="mt-6">
|
||||
<label
|
||||
htmlFor="feedback-comment"
|
||||
className="mb-1.5 block text-sm font-medium text-slate-700"
|
||||
>
|
||||
{commentLabel}
|
||||
</label>
|
||||
<textarea
|
||||
id="feedback-comment"
|
||||
rows={3}
|
||||
maxLength={1000}
|
||||
value={kommentar}
|
||||
disabled={laeuft}
|
||||
onChange={(event) => setKommentar(event.target.value)}
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm text-slate-900 shadow-sm placeholder:text-slate-400"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fehler ? (
|
||||
<p role="alert" className="mt-4 text-sm text-red-600">
|
||||
{fehler}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={note === null || laeuft}
|
||||
onClick={() => void absenden()}
|
||||
className="mt-6 w-full rounded-lg bg-indigo-600 px-4 py-3 font-medium text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{laeuft ? "Wird gesendet …" : "Absenden"}
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-center text-xs text-slate-400">
|
||||
Deine Rückmeldung wird ohne Namen und ohne IP-Adresse gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { LandingShell } from "@/components/landing/shell";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { imageDataSchema } from "@/lib/qr/types/image";
|
||||
|
||||
export function ImageLanding({ data }: LandingProps) {
|
||||
const { title, description, images } = imageDataSchema.parse(data);
|
||||
|
||||
return (
|
||||
<LandingShell title={title || "Bilder"} subtitle={description || null}>
|
||||
<div className="space-y-4">
|
||||
{images.map((image, index) => (
|
||||
<figure
|
||||
key={image.fileId}
|
||||
className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- eigene
|
||||
Auslieferung ohne bekannte Maße; der Optimierer von Next
|
||||
brächte hier nichts. */}
|
||||
<img
|
||||
src={`/api/uploads/${image.fileId}`}
|
||||
alt={image.caption || `Bild ${index + 1}`}
|
||||
// Das erste Bild sieht man sofort, die übrigen erst beim Scrollen.
|
||||
loading={index === 0 ? "eager" : "lazy"}
|
||||
className="block w-full"
|
||||
/>
|
||||
{image.caption ? (
|
||||
<figcaption className="border-t border-slate-100 px-4 py-3 text-sm text-slate-600">
|
||||
{image.caption}
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import { FeedbackLanding } from "@/components/landing/feedback-landing";
|
||||
import { ImageLanding } from "@/components/landing/image-landing";
|
||||
import { LinksLanding } from "@/components/landing/links-landing";
|
||||
import { PdfLanding } from "@/components/landing/pdf-landing";
|
||||
import { SocialLanding } from "@/components/landing/social-landing";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { VideoLanding } from "@/components/landing/video-landing";
|
||||
@@ -17,6 +20,9 @@ export const qrLandings: Partial<Record<QrTypeId, ComponentType<LandingProps>>>
|
||||
{
|
||||
links: LinksLanding,
|
||||
social: SocialLanding,
|
||||
pdf: PdfLanding,
|
||||
image: ImageLanding,
|
||||
feedback: FeedbackLanding,
|
||||
video: VideoLanding,
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Icon } from "@/components/icons";
|
||||
import { LandingShell } from "@/components/landing/shell";
|
||||
import type { LandingProps } from "@/components/landing/types";
|
||||
import { pdfDataSchema } from "@/lib/qr/types/pdf";
|
||||
import { formatBytes } from "@/lib/uploads";
|
||||
|
||||
export function PdfLanding({ data }: LandingProps) {
|
||||
const { fileId, filename, size, title, description } =
|
||||
pdfDataSchema.parse(data);
|
||||
const href = `/api/uploads/${fileId}`;
|
||||
|
||||
return (
|
||||
<LandingShell
|
||||
title={title || filename || "PDF-Dokument"}
|
||||
subtitle={description || null}
|
||||
>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="flex size-12 shrink-0 items-center justify-center rounded-xl bg-red-50 text-red-600">
|
||||
<Icon name="document" className="size-6" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-slate-900">{filename}</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
PDF{size > 0 ? ` · ${formatBytes(size)}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-2 sm:grid-cols-2">
|
||||
<a
|
||||
href={href}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-indigo-700"
|
||||
>
|
||||
<Icon name="document" className="size-4" />
|
||||
Dokument öffnen
|
||||
</a>
|
||||
<a
|
||||
href={`${href}?download`}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<Icon name="download" className="size-4" />
|
||||
Herunterladen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</LandingShell>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export function LandingShell({
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-col bg-slate-50">
|
||||
<div className="flex flex-1 flex-col bg-slate-50">
|
||||
<main className="mx-auto w-full max-w-lg flex-1 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<header className="mb-7 text-center">
|
||||
<h1 className="text-2xl font-bold tracking-tight text-balance text-slate-900 sm:text-3xl">
|
||||
@@ -52,7 +52,7 @@ export function LandingNotice({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-h-full place-items-center bg-slate-50 px-4 py-16">
|
||||
<div className="grid flex-1 place-items-center bg-slate-50 px-4 py-16">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-xl font-bold text-slate-900 sm:text-2xl">
|
||||
{title}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user