feat: Bild-Upload fuer Inhalte, Produkte und Ernten
Docker Image bauen und veroeffentlichen / build-and-push (push) Successful in 2m34s
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:
1 parent
3de9f2906e
commit
54919d82ed
35 files changed
+1014
-44
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in new issue
Block a user