feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s

Echter Datei-Upload statt CSS-Platzhaltergrafiken, gespeichert unter
UPLOAD_DIR (Laufzeit-Verzeichnis, weiss nichts von seiner tatsaechlichen
Speicher-Anbindung — in Produktion ein von der Betreiberin selbst per
Kubernetes eingebundenes Volume, z.B. SMB; lokal ein simples ./uploads).

- lib/uploads.ts: Kernlogik. Bildformat wird ausschliesslich aus den
  tatsaechlichen Bilddaten erkannt (Magic-Bytes via sharp), nie aus
  Client-Dateiname/MIME-Type. Zufaellige, nicht erratbare Dateinamen
  (Path-Traversal-/Overwrite-Schutz). Re-Encoding validiert Pixel-Daten
  vollstaendig und entfernt eingebettete Metadaten (EXIF/ICC/XMP) als
  Nebeneffekt. Content-Type beim Ausliefern kommt ausschliesslich aus
  einer festen Endung->MIME-Zuordnung, nie aus Dateiinhalt/-namen.
- app/api/admin/upload/route.ts: Admin-only Upload-Endpunkt (CSRF- +
  Auth- + Rate-Limit-Pruefung, max. 8 MB, JPEG/PNG/WebP/AVIF).
- app/uploads/[...path]/route.ts: liefert hochgeladene Bilder aus
  (public/ wird zur Build-Zeit fest ins Image gebacken, kann also keine
  zur Laufzeit hinzugefuegten Dateien bedienen) — mit
  Path-Traversal-Schutz und nosniff-Header.
- content-registry.ts: neuer "image"-Feldtyp fuer Hero-/Startseite-
  Grafiken, admin/inhalte hat dafuer ein Upload-Widget.
- Produkt-/Ernten-Admin-Formulare: Mehrfach-Bild-Upload fuer
  Product.images/Harvest.images (beide Felder existierten im Schema,
  hatten aber bislang keine Admin-Oberflaeche). Oeffentliche Seiten
  (Shop, Ernten, Chargen-QR-Seite) zeigen jetzt echte Fotos statt
  Platzhalter, sobald vorhanden.
- Kubernetes: nur ein einfacher volumeMount in 05-app-deployment.yaml
  (Platzhalter-emptyDir, klar als CHANGE-ME markiert) — die eigentliche
  Volume-Quelle (SMB o.ae.) bindet die Betreiberin selbst ein, wie
  gewuenscht keine SMB/CSI-spezifischen Manifeste hinzugefuegt.

Verifiziert: tsc --noEmit clean, 139/139 Tests gruen (21 neu), pnpm
build clean. Upload-/Serving-Pipeline live gegen einen laufenden
Server getestet (Auth-/CSRF-Gates, Path-Traversal-Ablehnung,
Format-Ablehnung) — ein vollstaendiger authentifizierter Upload
end-to-end (braucht echten Admin-Login inkl. 2FA) wurde nicht live
durchgespielt, nur per Code-Review verifiziert.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 19:59:42 +02:00
1 parent 3de9f2906e
commit 54919d82ed
35 files changed
+1014 -44

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
"use client";
import { useRef, useState, type ChangeEvent } from "react";
import { useI18n } from "@/i18n/i18n-provider";
interface AdminImageFieldProps {
/** Formularfeld-Name, unter dem der aktuelle Bildpfad beim Speichern des
* umgebenden <form> mitgeschickt wird (siehe admin/actions/content.ts,
* das jeden im FormData vorhandenen Registry-Key generisch verarbeitet). */
name: string;
label: string;
initialValue: string;
}
/**
* Einzelnes Bild-Upload-Feld fuer admin/inhalte (Content-Registry-Feldtyp
* "image"). Laedt die Datei sofort per fetch() an app/api/admin/upload
* hoch und haelt den zurueckgegebenen Pfad in einem versteckten
* Formularfeld — das eigentliche Speichern passiert weiterhin ganz normal
* ueber das umgebende <form action={updateSiteContentAction}>, dieses
* Feld reicht nur den fertigen Upload-Pfad hindurch.
*/
export function AdminImageField({ name, label, initialValue }: AdminImageFieldProps) {
const { t } = useI18n();
const [value, setValue] = useState(initialValue);
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);
}
setValue(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 = "";
}
}
return (
<div>
<span className="text-sm text-ink/70">{label}</span>
<input type="hidden" name={name} value={value} />
<div className="mt-2 flex items-center gap-4">
{value ? (
// eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht.
<img src={value} alt="" className="h-20 w-20 object-cover border border-ink/10" />
) : (
<div className="h-20 w-20 border border-dashed border-ink/20 flex items-center justify-center text-center text-[10px] text-ink/40 px-1">
{t.adminImageField.none}
</div>
)}
<div>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp,image/avif"
onChange={handleFileChange}
disabled={status === "uploading"}
className="block text-xs"
/>
{status === "uploading" ? (
<p className="mt-1 text-xs text-ink/50">{t.adminImageField.uploading}</p>
) : null}
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
{value ? (
<button
type="button"
onClick={() => setValue("")}
className="mt-1 text-xs text-ink/50 underline"
>
{t.adminImageField.remove}
</button>
) : null}
</div>
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
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">
{/* eslint-disable-next-line @next/next/no-img-element -- kleine Admin-Vorschau, next/image lohnt hier nicht. */}
<img src={src} alt="" className="aspect-square w-full object-cover border border-ink/10" />
<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="block text-xs"
/>
{status === "uploading" ? (
<p className="mt-1 text-xs text-ink/50">{t.adminImageField.uploading}</p>
) : null}
{status === "error" ? <p className="mt-1 text-xs text-red-700">{errorMessage}</p> : null}
</div>
</div>
);
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { PlaceholderImage } from "@/components/placeholder-image";
/**
* Zeigt das echte Produktbild, sobald CartItem.image gesetzt ist (aktuell nie
* der Fall, siehe README — echte Produktfotografie fehlt noch), ansonsten
* Zeigt das echte Produktbild, sobald CartItem.image gesetzt ist (kommt aus
* Product.images[0], siehe toCartItem() in product-card.tsx), ansonsten
* denselben Honig-Platzhalter wie ProductCard fuer Produkte ohne Foto.
*/
export function CartItemThumbnail({ image, className }: { image: string | null; className: string }) {
+41
View File
@@ -0,0 +1,41 @@
import Image from "next/image";
import { PlaceholderImage } from "@/components/placeholder-image";
interface ContentImageProps {
/** Hochgeladener Bildpfad (z.B. "/uploads/<id>.webp") oder null/leer -> Platzhalter. */
src: string | null | undefined;
alt: string;
/** Groessen-/Seitenverhaeltnis-Klassen (z.B. "aspect-[4/5] w-full") — gilt fuer Platzhalter UND Bild-Wrapper gleichermassen. */
className: string;
tone?: "forest" | "cream" | "honey";
label?: string;
sizes?: string;
}
/**
* Zeigt ein hochgeladenes Bild an, sobald eines gepflegt ist, sonst denselben
* PlaceholderImage-Platzhalter wie zuvor an dieser Stelle — nie ein kaputtes
* <img>. Uploads liegen same-origin unter /uploads/... (siehe
* app/uploads/[...path]/route.ts) und brauchen deshalb KEINEN
* next.config.ts-remotePatterns-Eintrag: remotePatterns/domains werden nur
* fuer externe (cross-origin) Bildquellen benoetigt, next/image behandelt
* einen mit "/" beginnenden src immer als lokal und ruft dafuer intern die
* eigene Optimierungsroute auf.
*/
export function ContentImage({
src,
alt,
className,
tone = "forest",
label,
sizes = "100vw",
}: ContentImageProps) {
if (!src) {
return <PlaceholderImage tone={tone} className={className} label={label} />;
}
return (
<div className={`relative overflow-hidden ${className}`}>
<Image src={src} alt={alt} fill sizes={sizes} className="object-cover" />
</div>
);
}
+6 -2
View File
@@ -2,7 +2,7 @@
import { motion, useReducedMotion, type Variants } from "motion/react";
import { Container, LinkButton } from "@/components/ui";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ContentImage } from "@/components/content-image";
import { useI18n } from "@/i18n/i18n-provider";
const sectionVariants: Variants = {
@@ -28,10 +28,12 @@ export function Hero({
intro,
footnote,
location,
image,
}: {
intro: string;
footnote: string;
location: string;
image: string | null;
}) {
const { t } = useI18n();
const shouldReduceMotion = useReducedMotion();
@@ -43,7 +45,9 @@ export function Hero({
animate="visible"
variants={sectionVariants}
>
<PlaceholderImage
<ContentImage
src={image}
alt=""
tone="forest"
className="h-[78vh] min-h-[520px] w-full"
label="Bienenstand im Morgenlicht · Altenmünster"
+13 -4
View File
@@ -1,6 +1,6 @@
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ContentImage } from "@/components/content-image";
import { ProductCard, TasteScale, AddToCartButton } from "@/components/product-card";
import { formatPrice, formatDate } from "@/lib/format";
import { getLocale } from "@/i18n/locale";
@@ -44,7 +44,12 @@ export async function OriginStory() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(
["home.originStory.paragraph1", "home.originStory.paragraph2", "home.originStory.facts"],
[
"home.originStory.paragraph1",
"home.originStory.paragraph2",
"home.originStory.facts",
"home.originStory.image",
],
locale
);
const facts = parseListSimple(content["home.originStory.facts"]);
@@ -52,7 +57,9 @@ export async function OriginStory() {
<section className="py-24 bg-cream/60">
<Container className="grid md:grid-cols-2 gap-12 items-center">
<ScrollReveal>
<PlaceholderImage
<ContentImage
src={content["home.originStory.image"] || null}
alt=""
tone="forest"
className="aspect-[4/5] w-full"
label={t.home.originStory.eyebrow}
@@ -126,7 +133,9 @@ export async function ProductSpotlight({ product }: { product: HoneySpotlightPro
<section className="py-24 bg-forest text-cream">
<Container className="grid md:grid-cols-2 gap-14 items-center">
<ScrollReveal>
<PlaceholderImage
<ContentImage
src={product.images[0] ?? null}
alt={product.name}
tone="honey"
className="aspect-square w-full"
label={product.name}
+20 -5
View File
@@ -1,6 +1,6 @@
import { Container, SectionHeading, LinkButton } from "@/components/ui";
import { ScrollReveal } from "@/components/scroll-reveal";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ContentImage } from "@/components/content-image";
import { StarRating } from "@/components/star-rating";
import { fetchGoogleReviews } from "@/lib/google-reviews";
import { getLocale } from "@/i18n/locale";
@@ -47,7 +47,11 @@ export async function RegionalSection() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(
["home.regionalSection.paragraph1", "home.regionalSection.paragraph2"],
[
"home.regionalSection.paragraph1",
"home.regionalSection.paragraph2",
"home.regionalSection.image",
],
locale
);
return (
@@ -70,7 +74,13 @@ export async function RegionalSection() {
</div>
</ScrollReveal>
<ScrollReveal className="order-1 md:order-2" delay={0.1}>
<PlaceholderImage tone="forest" className="aspect-[4/5] w-full" label={t.home.regionalSection.eyebrow} />
<ContentImage
src={content["home.regionalSection.image"] || null}
alt=""
tone="forest"
className="aspect-[4/5] w-full"
label={t.home.regionalSection.eyebrow}
/>
</ScrollReveal>
</Container>
</section>
@@ -111,12 +121,17 @@ export async function Sustainability() {
export async function BusinessTeaser() {
const locale = await getLocale();
const t = getDictionary(locale);
const content = await getContentValues(["home.businessTeaser.paragraph"], locale);
const content = await getContentValues(
["home.businessTeaser.paragraph", "home.businessTeaser.image"],
locale
);
return (
<section className="py-24">
<Container className="grid md:grid-cols-2 gap-12 items-center">
<ScrollReveal>
<PlaceholderImage
<ContentImage
src={content["home.businessTeaser.image"] || null}
alt=""
tone="honey"
className="aspect-[4/5] w-full"
label={t.home.businessTeaser.eyebrow}
+14 -4
View File
@@ -3,7 +3,7 @@
import Link from "next/link";
import { useState } from "react";
import { motion } from "motion/react";
import { PlaceholderImage } from "@/components/placeholder-image";
import { ContentImage } from "@/components/content-image";
import { QuantityStepper } from "@/components/quantity-stepper";
import { StarRating } from "@/components/star-rating";
import { Button } from "@/components/ui";
@@ -27,14 +27,22 @@ const categoryTone: Record<ProductCategory, "forest" | "cream" | "honey"> = {
* Ernte) — nur Bild, Name und Link, ohne Preis/Bestand/Warenkorb-Funktion.
* Diese Seiten geht es um Nachweis/Herkunft, nicht ums Verkaufen.
*/
export function ProductTeaserLink({ slug, name }: { slug: string; name: string }) {
export function ProductTeaserLink({
slug,
name,
image,
}: {
slug: string;
name: string;
image?: string | null;
}) {
const { t } = useI18n();
return (
<Link
href={`/honig/${slug}`}
className="group flex items-center gap-4 border border-ink/10 p-4 transition-colors hover:border-forest"
>
<PlaceholderImage tone="honey" className="w-16 h-16 flex-shrink-0" />
<ContentImage src={image ?? null} alt={name} tone="honey" className="w-16 h-16 flex-shrink-0" sizes="64px" />
<span>
<span className="block text-forest">{name}</span>
<span className="block mt-1 text-sm text-ink/60 group-hover:text-forest transition-colors">
@@ -180,7 +188,9 @@ export function ProductCard({
href={`/honig/${product.slug}`}
className="relative block overflow-hidden transition-shadow duration-500 group-hover:shadow-[0_24px_48px_-20px_rgba(36,53,43,0.4)]"
>
<PlaceholderImage
<ContentImage
src={product.images[0] ?? null}
alt={product.name}
tone={categoryTone[product.category]}
className="aspect-[4/5] w-full transition-transform duration-500 group-hover:scale-[1.03]"
label={
+4 -4
View File
@@ -9,10 +9,10 @@ function isImageSlide(slide: Slide): slide is { src: string } {
return "src" in slide;
}
// Solange keine echte Produktfotografie hinterlegt ist (product.images ist
// aktuell bei allen Produkten leer, siehe README), zeigt die Galerie diese
// vier Platzhalter-Kacheln an — sobald echte Bild-URLs gepflegt werden,
// ersetzen sie diese Kacheln automatisch (siehe Verzweigung unten).
// Solange fuer ein Produkt keine echte Produktfotografie hinterlegt ist
// (admin/produkte -> Bilder, siehe AdminImageListField), zeigt die Galerie
// diese vier Platzhalter-Kacheln an — sobald echte Bild-URLs gepflegt
// werden, ersetzen sie diese Kacheln automatisch (siehe Verzweigung unten).
const PLACEHOLDER_SLIDES: Slide[] = [
{ tone: "honey", label: "Glas frontal" },
{ tone: "cream", label: "Glas seitlich" },