Rollen-Farben statt reiner Markenfarben, damit hell und dunkel aus einem Satz Klassen entstehen: - heading (Ueberschriften, Links, Rahmen): hell Waldgruen, dunkel Creme - surface (abgesetzte Flaeche): hell Creme, dunkel angehobenes Dunkelgruen - forest bleibt die dunkle Markenflaeche (Hero, Baender, Knoepfe, Admin-Leiste), cream der helle Text darauf - honey-deep als fester Honigton fuer Flaechen; honey-dark (Text) wird im Dunkelmodus heller und bleibt lesbar Codemod ueber src: text-/border-forest -> *-heading, bg-cream -> bg-surface, bg-honey-dark -> bg-honey-deep. Im hellen Modus haben die Rollen exakt die bisherigen Werte - das Erscheinungsbild bleibt gleich. Umschalter (components/theme-toggle.tsx) Automatisch -> Hell -> Dunkel in Kopfzeile, Mobilmenue und Admin-Leiste. Die Wahl liegt im Cookie "theme" (nur light/dark, geprueft in lib/theme.ts); der Server setzt data-theme schon beim Ausliefern, nichts blitzt hell auf. "Automatisch" folgt der Systemeinstellung. Gewaehlter und automatischer Dunkelblock in globals.css werden per Test auf Gleichheit geprueft. Logo, Biene und Select-Pfeil nutzen die Rollenfarben. In der Kopfzeile steht "Honig entdecken" zwischen 1280 und 1535 px nicht mehr doppelt neben "Shop" (sonst stiess die Navigation an), der Slogan erscheint erst ab 1680 px. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
118 lines
4.8 KiB
TypeScript
118 lines
4.8 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState, type TouchEvent } from "react";
|
||
import Image from "next/image";
|
||
import { PlaceholderImage } from "@/components/placeholder-image";
|
||
|
||
// Mindest-Wischstrecke in Pixeln, ab der ein Touch-Wisch als Bildwechsel
|
||
// gilt statt als (vertikales) Scrollen oder ein leichtes Zittern der Hand.
|
||
const SWIPE_THRESHOLD_PX = 40;
|
||
|
||
type Slide = { src: string } | { tone: "forest" | "cream" | "honey"; label: string };
|
||
|
||
function isImageSlide(slide: Slide): slide is { src: string } {
|
||
return "src" in slide;
|
||
}
|
||
|
||
// 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" },
|
||
{ tone: "forest", label: "Bienenstand" },
|
||
{ tone: "honey", label: "Landschaft" },
|
||
];
|
||
|
||
interface ProductGalleryProps {
|
||
images: string[];
|
||
productName: string;
|
||
/** Kurzbeschreibung (z.B. Geschmacksnoten) fuer aussagekraeftigeren Alt-Text
|
||
* auf dem Hauptbild — reiner Produktname allein ist fuer Screenreader UND
|
||
* Bildersuche weniger nuetzlich als Name + kurze inhaltliche Einordnung. */
|
||
description?: string;
|
||
}
|
||
|
||
export function ProductGallery({ images, productName, description }: ProductGalleryProps) {
|
||
const slides: Slide[] = images.length > 0 ? images.map((src) => ({ src })) : PLACEHOLDER_SLIDES;
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
const active = slides[activeIndex] ?? slides[0];
|
||
const touchStartX = useRef<number | null>(null);
|
||
const mainImageAlt = description ? `${productName} – ${description}` : productName;
|
||
|
||
function handleTouchStart(event: TouchEvent) {
|
||
touchStartX.current = event.touches[0].clientX;
|
||
}
|
||
|
||
// Wischen auf dem Hauptbild wechselt das Bild — auf dem Handy ist das die
|
||
// erwartete Bediengeste fuer eine Produktgalerie, nicht nur ein Tap auf die
|
||
// (auf kleinen Bildschirmen ohnehin winzigen) Vorschau-Kacheln darunter.
|
||
function handleTouchEnd(event: TouchEvent) {
|
||
if (touchStartX.current === null || slides.length <= 1) return;
|
||
const deltaX = event.changedTouches[0].clientX - touchStartX.current;
|
||
touchStartX.current = null;
|
||
if (Math.abs(deltaX) < SWIPE_THRESHOLD_PX) return;
|
||
|
||
setActiveIndex((current) => {
|
||
const direction = deltaX < 0 ? 1 : -1;
|
||
return (current + direction + slides.length) % slides.length;
|
||
});
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
className="relative aspect-[4/3] overflow-hidden rounded-lg touch-pan-y md:aspect-[4/5]"
|
||
onTouchStart={handleTouchStart}
|
||
onTouchEnd={handleTouchEnd}
|
||
>
|
||
{isImageSlide(active) ? (
|
||
// Hochgeladene Produktbilder liegen immer same-origin unter
|
||
// /uploads/... (siehe uploads.ts/isValidUploadPath) — next/image
|
||
// braucht dafuer KEINEN remotePatterns-Eintrag (nur externe
|
||
// Bildquellen brauchen den), siehe auch content-image.tsx. Als
|
||
// wahrscheinlichstes LCP-Element der Produktseite sofort und mit
|
||
// hoher Netzwerk-Prioritaet geladen, statt erst beim Scannen des
|
||
// Bild-Tags entdeckt zu werden. Bewusst NICHT ueber die Prop
|
||
// "priority": die ist seit Next.js 16 abgekuendigt und erzeugt nur
|
||
// noch einen Preload-Link, ohne fetchpriority am <img> zu setzen.
|
||
<Image
|
||
src={active.src}
|
||
alt={mainImageAlt}
|
||
fill
|
||
sizes="(min-width: 768px) 50vw, 100vw"
|
||
loading="eager"
|
||
fetchPriority="high"
|
||
className="object-cover"
|
||
/>
|
||
) : (
|
||
<PlaceholderImage tone={active.tone} label={active.label} className="w-full h-full" />
|
||
)}
|
||
</div>
|
||
{slides.length > 1 ? (
|
||
<div className="mt-3 grid grid-cols-4 gap-3">
|
||
{slides.map((slide, index) => (
|
||
<button
|
||
key={index}
|
||
type="button"
|
||
onClick={() => setActiveIndex(index)}
|
||
aria-label={`${isImageSlide(slide) ? `Produktbild ${index + 1}` : slide.label} – Bild ${index + 1} von ${slides.length} anzeigen`}
|
||
aria-current={index === activeIndex}
|
||
className={`relative aspect-square overflow-hidden rounded-lg border-2 transition-colors ${
|
||
index === activeIndex ? "border-heading" : "border-transparent hover:border-ink/20"
|
||
}`}
|
||
>
|
||
{isImageSlide(slide) ? (
|
||
<Image src={slide.src} alt="" fill sizes="120px" className="object-cover" />
|
||
) : (
|
||
<PlaceholderImage tone={slide.tone} label={slide.label} className="w-full h-full" />
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|