- 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]>
137 lines
5.2 KiB
TypeScript
137 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { LazyMotion, domAnimation, m } from "motion/react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { NAV_ITEMS } from "@/content/site";
|
|
import { Logo } from "./Logo";
|
|
|
|
const LEFT = NAV_ITEMS.slice(0, 3);
|
|
const RIGHT = NAV_ITEMS.slice(3);
|
|
|
|
type NavItem = (typeof NAV_ITEMS)[number];
|
|
|
|
/**
|
|
* Kopfzeile wie bei der Filmcamp-Referenz: Logo mittig, Abschnitte links und
|
|
* rechts. Ueber dem Headerbild transparent mit heller Schrift, nach dem
|
|
* Scrollen auf hellem Grund.
|
|
*/
|
|
export function SiteHeader({ logoLight, logoDark }: { logoLight: string | null; logoDark: string | null }) {
|
|
const [active, setActive] = useState<string | null>(null);
|
|
const [scrolled, setScrolled] = useState(false);
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
const menuButton = useRef<HTMLButtonElement>(null);
|
|
const firstLink = useRef<HTMLAnchorElement>(null);
|
|
|
|
useEffect(() => {
|
|
const onScroll = () => setScrolled(window.scrollY > 24);
|
|
onScroll();
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
return () => window.removeEventListener("scroll", onScroll);
|
|
}, []);
|
|
|
|
// Markiert den Abschnitt, der gerade die Bildschirmmitte kreuzt.
|
|
useEffect(() => {
|
|
const sections = NAV_ITEMS.map((item) => document.getElementById(item.id)).filter(Boolean) as HTMLElement[];
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
for (const entry of entries) if (entry.isIntersecting) setActive(entry.target.id);
|
|
},
|
|
{ rootMargin: "-45% 0px -50% 0px" },
|
|
);
|
|
sections.forEach((s) => observer.observe(s));
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!menuOpen) return;
|
|
firstLink.current?.focus();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
setMenuOpen(false);
|
|
menuButton.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, [menuOpen]);
|
|
|
|
const solid = scrolled || menuOpen;
|
|
|
|
const navList = (items: readonly NavItem[], align: string) => (
|
|
<ul className={`flex items-center gap-7 text-[0.9375rem] font-medium ${align}`}>
|
|
{items.map((item) => (
|
|
<li key={item.id} className="relative">
|
|
<a href={`#${item.id}`} aria-current={active === item.id ? "location" : undefined} className="block py-2 opacity-85 transition-opacity hover:opacity-100 aria-[current]:opacity-100">
|
|
{item.label}
|
|
</a>
|
|
{active === item.id && (
|
|
<m.span layoutId="nav-indicator" className="absolute inset-x-0 -bottom-0.5 h-0.5 rounded-full bg-current" transition={{ type: "spring", stiffness: 380, damping: 34 }} />
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
|
|
return (
|
|
<header
|
|
className={`fixed inset-x-0 top-0 z-40 transition-[background-color,color,box-shadow] duration-300 ${
|
|
solid ? "bg-plaster/95 text-ink shadow-[0_1px_0_var(--color-line)] backdrop-blur" : "on-dark bg-transparent text-paper"
|
|
}`}
|
|
>
|
|
<a href="#inhalt" className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:bg-paper focus:px-3 focus:py-2 focus:text-ink">
|
|
Zum Inhalt springen
|
|
</a>
|
|
<LazyMotion features={domAnimation}>
|
|
<div className="container-edge grid h-[4.5rem] grid-cols-[1fr_auto_1fr] items-center gap-4">
|
|
<div className="flex items-center">
|
|
<button
|
|
ref={menuButton}
|
|
type="button"
|
|
className="inline-flex h-11 items-center rounded-full border border-current/40 px-4 text-sm font-semibold xl:hidden"
|
|
aria-expanded={menuOpen}
|
|
aria-controls="mobile-menu"
|
|
onClick={() => setMenuOpen((v) => !v)}
|
|
>
|
|
{menuOpen ? "Schließen" : "Menü"}
|
|
</button>
|
|
<nav aria-label="Abschnitte" className="hidden xl:block">
|
|
{navList(LEFT, "justify-start")}
|
|
</nav>
|
|
</div>
|
|
|
|
<a href="#top" aria-label="ARTISTA Modelcamp 2027, zum Seitenanfang" className="flex h-8 items-center text-xl md:h-10 md:text-2xl">
|
|
<Logo src={solid ? logoDark : logoLight} />
|
|
</a>
|
|
|
|
<div className="flex items-center justify-end gap-7">
|
|
<nav aria-label="Weitere Abschnitte" className="hidden xl:block">
|
|
{navList(RIGHT, "justify-end")}
|
|
</nav>
|
|
<a href="#preis" className="btn btn-primary hidden min-h-10 px-5 py-2 text-sm sm:inline-flex">
|
|
Platz sichern
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</LazyMotion>
|
|
{menuOpen && (
|
|
<nav id="mobile-menu" aria-label="Abschnitte" className="border-t border-line bg-plaster text-ink xl:hidden">
|
|
<ul className="container-edge py-4">
|
|
{NAV_ITEMS.map((item, index) => (
|
|
<li key={item.id}>
|
|
<a
|
|
ref={index === 0 ? firstLink : undefined}
|
|
href={`#${item.id}`}
|
|
onClick={() => setMenuOpen(false)}
|
|
className="display block border-b border-line/70 py-3 text-2xl"
|
|
>
|
|
{item.label}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</nav>
|
|
)}
|
|
</header>
|
|
);
|
|
}
|