"use client" import { AnimatePresence, motion, useInView, useMotionValue, useScroll, useSpring } from "motion/react" import { useEffect, useEffectEvent, useRef, useState, useSyncExternalStore, type PointerEvent, type ReactNode } from "react" import { ArrowRightIcon, ArrowUpRightIcon, XIcon } from "lucide-react" import { cn } from "@/lib/utils" import { TImage } from "../shared/media" import { RevealWords, ease } from "../shared/motion" import type { BrandData, NavLink } from "./types" // Gemeinsame Bausteine der Blöcke. Alles richtet sich nach den Paletten-Farben (t-*), damit jeder Block // auf hellen wie dunklen Paletten funktioniert und sich mit anderen Blöcken kombinieren lässt. export const container = "mx-auto w-full max-w-[1440px] px-5 md:px-10" export const em = "italic text-t-accent" // Für Bewegungen auf der Fläche (Paneele, Masken); `ease` aus shared/motion für Auftritte export const easeInOut = [0.77, 0, 0.175, 1] as const const SAFE_HREF = /^(#|\/|mailto:|tel:|https?:\/\/)/i // Button-Ziele kommen aus dem Editor: nur Anker, eigene Pfade, mailto:, tel: und http(s) export function linkProps(href: string) { const target = SAFE_HREF.test(href.trim()) ? href.trim() : "#" return /^https?:\/\//i.test(target) ? { href: target, target: "_blank", rel: "noreferrer" } : { href: target } } export const telHref = (phone: string) => `tel:${phone.replace(/[^\d+]/g, "")}` export function useMedia(query: string) { return useSyncExternalStore( (onChange) => { const media = window.matchMedia(query) media.addEventListener("change", onChange) return () => media.removeEventListener("change", onChange) }, () => window.matchMedia(query).matches, () => false ) } export function Eyebrow({ children, className }: { children: string; className?: string }) { if (!children.trim()) return null return (

{children}

) } export function Heading({ text, as = "h2", className, emClassName = em, onMount, delay }: { text: string as?: "h1" | "h2" | "h3" className?: string emClassName?: string onMount?: boolean delay?: number }) { if (!text.trim()) return null return ( ) } // Bild, das beim Erscheinen von unten aufgedeckt wird und sich dabei leicht zurückzoomt export function RevealImage({ src, alt = "", className, sizes }: { src: string; alt?: string; className?: string; sizes?: string }) { return ( ) } // Textlink mit Linie darunter, die beim Darüberfahren zusammenschnurrt export function LineLink({ href, children, className }: { href: string; children: string; className?: string }) { if (!children.trim()) return null return ( {children} ) } const actionStyles = { solid: "bg-t-accent text-t-accent-fg hover:shadow-[0_14px_40px_-14px_var(--t-accent)]", outline: "border border-t-fg/20 text-t-fg hover:border-t-fg/60", inverse: "bg-t-fg text-t-bg", light: "border border-white/35 text-white backdrop-blur-sm hover:border-white/80", } export type ActionVariant = keyof typeof actionStyles export const actionClass = (variant: ActionVariant = "solid") => cn( "group inline-flex cursor-pointer items-center justify-center gap-2 rounded-full px-6 py-3.5 text-sm font-medium", "transition-[scale,box-shadow,border-color,background-color] duration-200 ease-out active:scale-[0.97]", actionStyles[variant] ) export function Action({ href, children, variant = "solid", className }: { href: string; children: string; variant?: ActionVariant; className?: string }) { if (!children.trim()) return null return ( {children} ) } // Folgt der Maus ein Stück, sobald sie in der Nähe ist (nur mit Maus, nicht bei Touch) export function Magnetic({ children, className, strength = 0.3 }: { children: ReactNode; className?: string; strength?: number }) { const x = useMotionValue(0) const y = useMotionValue(0) const springX = useSpring(x, { stiffness: 220, damping: 18, mass: 0.6 }) const springY = useSpring(y, { stiffness: 220, damping: 18, mass: 0.6 }) function onMove(event: PointerEvent) { if (event.pointerType !== "mouse") return const rect = event.currentTarget.getBoundingClientRect() x.set((event.clientX - (rect.left + rect.width / 2)) * strength) y.set((event.clientY - (rect.top + rect.height / 2)) * strength) } return ( { x.set(0) y.set(0) }} style={{ x: springX, y: springY }} className={cn("inline-block", className)} > {children} ) } // Schwarzweiß-Bild, das sich beim Darüberfahren vom Mauspunkt aus einfärbt; auf Touch-Geräten beim Erscheinen export function InkImage({ src, alt = "", className, sizes }: { src: string; alt?: string; className?: string; sizes?: string }) { const ref = useRef(null) const [hover, setHover] = useState(false) const [origin, setOrigin] = useState({ x: 50, y: 50 }) const touch = useMedia("(hover: none)") const seen = useInView(ref, { once: true, amount: 0.6 }) const colored = hover || (touch && seen) function track(event: PointerEvent, active: boolean) { if (event.pointerType !== "mouse") return const rect = event.currentTarget.getBoundingClientRect() setOrigin({ x: ((event.clientX - rect.left) / rect.width) * 100, y: ((event.clientY - rect.top) / rect.height) * 100 }) setHover(active) } return (
track(event, true)} onPointerLeave={(event) => track(event, false)} className={cn("relative overflow-hidden bg-t-surface", className)}>
) } export function Brand({ brand, className }: { brand: BrandData; className?: string }) { return ( {brand.logo.trim() ? ( ) : ( brand.name )} ) } // Anker-Links erst nach dem Schließen des Menüs ansteuern, sonst blockiert die Scroll-Sperre function goTo(href: string) { if (!href.startsWith("#")) return setTimeout(() => document.getElementById(href.slice(1))?.scrollIntoView({ behavior: "smooth" }), 80) } // Vollbild-Menü fürs Handy (Navigation „Leiste“ und „Magazin-Kopf“) export function MobileMenu({ open, onClose, links, brand, cta }: { open: boolean onClose: () => void links: NavLink[] brand: BrandData cta: { label: string; href: string } }) { const onKey = useEffectEvent((event: KeyboardEvent) => { if (event.key === "Escape") onClose() }) useEffect(() => { if (!open) return const root = document.documentElement const previous = root.style.overflow root.style.overflow = "hidden" window.addEventListener("keydown", onKey) return () => { root.style.overflow = previous window.removeEventListener("keydown", onKey) } }, [open]) function navigate(href: string) { onClose() goTo(href) } return ( {open && (
{cta.label.trim() && ( { if (!cta.href.startsWith("#")) return event.preventDefault() navigate(cta.href) }} className={actionClass("solid")} > {cta.label} )} {brand.email}
)}
) } // Liegt unter der Navigation gerade eine dunkle Bildfläche (data-surface="dark")? Dann braucht sie helle Schrift. export function useDarkSurface(probeY = 36) { const [dark, setDark] = useState(false) const { scrollY } = useScroll() useEffect(() => { const check = () => { const hit = document.elementsFromPoint(window.innerWidth / 2, probeY).find((element) => !element.closest("[data-nav]")) setDark(Boolean(hit?.closest('[data-surface="dark"]'))) } const frame = requestAnimationFrame(check) const timer = setTimeout(check, 700) const unsubscribe = scrollY.on("change", check) window.addEventListener("resize", check) return () => { cancelAnimationFrame(frame) clearTimeout(timer) unsubscribe() window.removeEventListener("resize", check) } }, [scrollY, probeY]) return dark }