Files
Wabenhain/src/components/admin-image-list-field.tsx
T
Nick Szaunig be5fb32a4a
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
perf: Admin-Bildvorschauen ueber next/image statt als rohes <img>
Die Vorschaukacheln in admin/inhalte, admin/produkte und admin/ernten
luden das vollstaendig gespeicherte Bild, um es auf 80x80 bzw. in einer
Vierer-Rasterkachel darzustellen. Bei einem Produktformular mit mehreren
Bildern summierte sich das zu mehreren Megabyte allein fuer Miniaturen.

Der urspruengliche Kommentar ("next/image lohnt hier nicht") stimmte, als
Uploads klein waren; gespeichert wird inzwischen bis MAX_IMAGE_DIMENSION
Kantenlaenge. Beide Felder rendern die Vorschau jetzt ueber next/image
mit passendem sizes, der Optimierer liefert damit genau die gebrauchte
Groesse. Layout und Bedienung bleiben unveraendert.
2026-09-13 23:16:35 +02:00

115 lines
4.5 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";
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);
const response = await fetch("/api/admin/upload", { method: "POST", body });
const data: { path?: string; error?: string } = await response.json();
if (!response.ok || !data.path) {
throw new Error(data.error ?? t.adminImageField.uploadError);
}
setImages((prev) => [...prev, data.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>
);
}