Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m24s
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.
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|