Files
Wabenhain/src/components/admin-image-list-field.tsx
T
Nick Szaunig b11edd7751 fix: "Unexpected token '<'" bei Uploads durch die echte Ursache ersetzen
Alle drei Admin-Upload-Felder riefen response.json() ungeprueft auf. Kam
statt JSON eine HTML-Seite zurueck, warf der Parser

  Unexpected token '<', "<!DOCTYPE "... is not valid JSON

und genau dieser Text landete im Formular. Damit war die eigentliche
Ursache vollstaendig verdeckt: weder der HTTP-Status noch die Schicht,
die abgebrochen hat, waren daraus ablesbar.

Der Punkt dabei: die Upload-Route-Handler antworten ausnahmslos mit JSON,
auch in jedem Fehlerfall — lokal verifiziert bis hinauf zur
Limit-Ueberschreitung (105 MB -> 413 mit JSON-Body). Trifft im Browser
trotzdem HTML ein, stammt die Antwort gar nicht aus der Anwendung,
sondern aus einer Schicht davor: Reverse-Proxy, CDN oder Gateway, das den
Request wegen zu grossem Body abgewiesen oder wegen Zeitueberschreitung
beim Transcoding abgebrochen hat.

readUploadResponse() in lib/upload-client.ts prueft deshalb zuerst den
Content-Type und reicht andernfalls den HTTP-Status nach oben, statt zu
parsen. Die Felder zeigen daraus eine Meldung, die den Status nennt und
die wahrscheinliche Ursache benennt (413 = vom Proxy abgewiesen,
502/503/504/522/524 = Zeitueberschreitung beim Umrechnen, sonst
unerwartet). Ein Verbindungsabbruch, bei dem fetch() selbst wirft, wird
getrennt gemeldet.

Das behebt nicht die Ursache jenseits der Anwendung — es macht sie zum
ersten Mal ablesbar, statt sie hinter einem Parser-Fehler zu verbergen.
2026-09-14 00:36:10 +02:00

126 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider";
import { readUploadResponse } from "@/lib/upload-client";
interface AdminImageListFieldProps {
/** Formularfeld-Name, unter dem JEDES Bild als eigenes verstecktes Feld
* mit demselben Namen mitgeschickt wird — der Server liest sie per
* formData.getAll(name) (siehe strArray() in admin/actions/_shared.ts). */
name: string;
label: string;
initialValues: string[];
}
/**
* Mehrbild-Upload-Feld fuer Produkt-/Erntenfotos (admin/produkte,
* admin/ernten). Laedt jede ausgewaehlte Datei sofort per fetch() an
* app/api/admin/upload hoch und haengt den zurueckgegebenen Pfad ans lokale
* Bilder-Array an; einzelne Bilder lassen sich per Knopfdruck wieder
* entfernen. Das eigentliche Speichern passiert weiterhin ganz normal ueber
* das umgebende <form>, dieses Feld reicht nur die fertigen Upload-Pfade
* als wiederholte Hidden-Inputs hindurch.
*/
export function AdminImageListField({ name, label, initialValues }: AdminImageListFieldProps) {
const { t } = useI18n();
const [images, setImages] = useState<string[]>(initialValues);
const [status, setStatus] = useState<"idle" | "uploading" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
async function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setStatus("uploading");
setErrorMessage("");
try {
const body = new FormData();
body.append("file", file);
let response: Response;
try {
response = await fetch("/api/admin/upload", { method: "POST", body });
} catch {
// fetch() wirft nur, wenn der Request den Server gar nicht erreicht
// oder die Verbindung abbricht — ein HTTP-Fehlerstatus landet NICHT
// hier, sondern wird unten ausgewertet.
throw new Error(t.adminImageField.uploadErrorNetwork);
}
const result = await readUploadResponse(response);
if (!result.ok) {
throw new Error(
result.kind === "message" ? result.message : t.adminImageField.uploadErrorStatus(result.status)
);
}
setImages((prev) => [...prev, result.path]);
setStatus("idle");
} catch (error: unknown) {
setStatus("error");
setErrorMessage(error instanceof Error ? error.message : t.adminImageField.uploadError);
} finally {
if (inputRef.current) inputRef.current.value = "";
}
}
function removeAt(index: number) {
setImages((prev) => prev.filter((_, i) => i !== index));
}
return (
<div>
<span className="text-sm text-ink/70">{label}</span>
{images.map((src, i) => (
// key bewusst ueber Index+Wert: Reihenfolge/Inhalt aendert sich nur
// durch Hinzufuegen (ans Ende) oder Entfernen, beides hier stabil.
<input key={`${i}-${src}`} type="hidden" name={name} value={src} />
))}
{images.length > 0 ? (
<div className="mt-2 grid grid-cols-4 gap-3">
{images.map((src, i) => (
<div key={`${i}-${src}`} className="relative">
{/* Ueber next/image statt als rohes <img> — siehe Begruendung in
admin-image-field.tsx. Hier wiegt es schwerer, weil ein
Produkt mehrere Bilder tragen kann und sonst JEDES davon in
voller Groesse fuer eine Miniatur geladen wuerde. */}
<div className="relative aspect-square w-full overflow-hidden border border-ink/10">
<Image src={src} alt="" fill sizes="160px" className="object-cover" />
</div>
<button
type="button"
onClick={() => removeAt(i)}
aria-label={t.adminImageField.removeImageAt(i + 1)}
className="absolute top-1 right-1 flex h-5 w-5 items-center justify-center bg-ink/70 text-xs text-cream"
>
×
</button>
</div>
))}
</div>
) : null}
<div className="mt-2">
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
onChange={handleFileChange}
disabled={status === "uploading"}
className="hidden"
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={status === "uploading"}
className="border border-ink/20 bg-background px-3 py-2 text-xs hover:border-forest disabled:opacity-50"
>
{t.adminImageField.chooseFile}
</button>
{status === "uploading" ? (
<p className="mt-1 text-xs text-ink/70">{t.adminImageField.uploading}</p>
) : null}
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
</div>
</div>
);
}