feat: Onepage nach Filmcamp-Gliederung umgebaut
- Figtree als einzige Schrift, Logo mittig in fester Kopfzeile - Headerbild ueber volle Breite und Hoehe, ohne Preis, mit Terminleiste - "Was dich erwartet" stellt die Villa mit animierter Bilderfolge vor, darunter Tagesablauf als Karussell (Programm folgt als PDF) - Preis: regulaerer Preis durchgestrichen, kein Verkaufsstartdatum, Ueberschrift "Jetzt Platz sichern mit Early Bird" - Team als "Mit dabei", danach drei Reels im Hochformat - Logo, Headerbild, Portraets und Reels ueber feste Dateinamen im Medienverzeichnis (docs/MEDIEN.md), ohne Neustart austauschbar - Widerrufsbelehrung im Admin pflegbar, Seite /widerruf, Live-Verkauf ohne Freigabe gesperrt - Anreise: Abholung am Bahnhof Firenze Santa Maria Novella - Booking-Link entfernt, Gedankenstriche aus Kundentexten entfernt Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
3cba3e2341
commit
7203cd1e6e
37 files changed
+962
-517
No files matched your search
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { Media } from "@/content/site";
|
||||
|
||||
const INTERVAL_MS = 5500;
|
||||
|
||||
type ImageMedia = Extract<Media, { kind: "image" }>;
|
||||
|
||||
/**
|
||||
* Bilderfolge der Villa: weiche Ueberblendung mit langsamem Zoom.
|
||||
* Laeuft von selbst, laesst sich aber jederzeit anhalten (WCAG 2.2.2) und
|
||||
* pausiert bei Maus oder Tastaturfokus. Bei reduzierter Bewegung kein
|
||||
* automatischer Wechsel und kein Zoom, nur die Punkte zum Umschalten.
|
||||
*/
|
||||
export function Slideshow({ images, className = "" }: { images: readonly Media[]; className?: string }) {
|
||||
const slides = images.filter((m): m is ImageMedia => m.kind === "image");
|
||||
const reduce = useReducedMotion();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const running = !reduce && !paused && !hovered && slides.length > 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
const timer = window.setInterval(() => setIndex((i) => (i + 1) % slides.length), INTERVAL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [running, slides.length]);
|
||||
|
||||
if (slides.length === 0) return null;
|
||||
const current = slides[index]!;
|
||||
|
||||
return (
|
||||
<LazyMotion features={domAnimation}>
|
||||
<figure
|
||||
className={className}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setHovered(true)}
|
||||
onBlur={() => setHovered(false)}
|
||||
>
|
||||
<div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-sand md:aspect-[5/6]">
|
||||
<AnimatePresence initial={false}>
|
||||
<m.div
|
||||
key={current.src}
|
||||
className="absolute inset-0"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: reduce ? 0 : 1.2, ease: "easeInOut" }}
|
||||
>
|
||||
<m.div
|
||||
className="absolute inset-0"
|
||||
initial={{ scale: reduce ? 1 : 1.08 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: reduce ? 0 : INTERVAL_MS / 1000 + 1.2, ease: "linear" }}
|
||||
>
|
||||
<Image src={current.src} alt={current.alt} fill sizes="(min-width: 768px) 50vw, 100vw" className="object-cover" />
|
||||
</m.div>
|
||||
</m.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between gap-4">
|
||||
<figcaption aria-live={running ? "off" : "polite"} className="text-sm text-stone">
|
||||
{current.alt}
|
||||
</figcaption>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{slides.map((slide, i) => (
|
||||
<button
|
||||
key={slide.src}
|
||||
type="button"
|
||||
onClick={() => setIndex(i)}
|
||||
aria-label={`Bild ${i + 1} von ${slides.length} zeigen`}
|
||||
aria-current={i === index ? "true" : undefined}
|
||||
className="grid h-8 w-6 place-items-center"
|
||||
>
|
||||
<span className={`block h-1.5 rounded-full transition-all duration-300 ${i === index ? "w-5 bg-ink" : "w-1.5 bg-ink/30"}`} />
|
||||
</button>
|
||||
))}
|
||||
{!reduce && slides.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaused((p) => !p)}
|
||||
aria-label={paused ? "Bilderfolge abspielen" : "Bilderfolge anhalten"}
|
||||
className="ml-2 grid h-9 w-9 place-items-center rounded-full border border-ink/25 hover:border-ink"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden>
|
||||
{paused ? <path d="M8 5v14l11-7z" fill="currentColor" /> : <path d="M7 5h3v14H7zM14 5h3v14h-3z" fill="currentColor" />}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</figure>
|
||||
</LazyMotion>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user