"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
}