since3-portal/modules/templates/catalog/blocks/ui.tsx
2026-10-11 18:22:32 +02:00

329 lines
14 KiB
TypeScript

"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 (
<p className={cn("flex items-center gap-3 text-[11px] font-medium uppercase tracking-[0.28em] text-t-muted", className)}>
<span aria-hidden className="h-px w-7 shrink-0 bg-t-accent" />
{children}
</p>
)
}
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 (
<RevealWords
as={as}
text={text}
onMount={onMount}
delay={delay}
emClassName={emClassName}
className={cn("font-t-display text-[clamp(2.5rem,5.6vw,5.5rem)] leading-[0.95] tracking-[-0.03em]", className)}
/>
)
}
// 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 (
<motion.div
className={cn("relative overflow-hidden bg-t-surface", className)}
initial={{ clipPath: "inset(100% 0% 0% 0%)" }}
whileInView={{ clipPath: "inset(0% 0% 0% 0%)" }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 1.2, ease: easeInOut }}
>
<motion.div
className="absolute inset-0"
initial={{ scale: 1.2 }}
whileInView={{ scale: 1 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={{ duration: 1.8, ease }}
>
<TImage src={src} alt={alt} sizes={sizes} />
</motion.div>
</motion.div>
)
}
// 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 (
<a {...linkProps(href)} className={cn("group inline-flex items-center gap-3 text-sm font-medium uppercase tracking-[0.18em]", className)}>
<span className="relative pb-1">
{children}
<span aria-hidden className="absolute bottom-0 left-0 h-px w-full origin-left bg-current transition-transform duration-500 ease-out group-hover:scale-x-[0.35]" />
</span>
<ArrowRightIcon className="size-4 transition-transform duration-300 group-hover:translate-x-1" />
</a>
)
}
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 (
<a {...linkProps(href)} className={cn(actionClass(variant), className)}>
{children}
<ArrowUpRightIcon className="size-4 transition-transform duration-300 group-hover:rotate-45" />
</a>
)
}
// 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<HTMLDivElement>) {
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 (
<motion.div
onPointerMove={onMove}
onPointerLeave={() => {
x.set(0)
y.set(0)
}}
style={{ x: springX, y: springY }}
className={cn("inline-block", className)}
>
{children}
</motion.div>
)
}
// 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<HTMLDivElement>(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<HTMLDivElement>, 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 (
<div ref={ref} onPointerEnter={(event) => track(event, true)} onPointerLeave={(event) => track(event, false)} className={cn("relative overflow-hidden bg-t-surface", className)}>
<TImage src={src} alt={alt} sizes={sizes} className="grayscale" />
<motion.div
aria-hidden
className="absolute inset-0"
initial={false}
animate={{ clipPath: `circle(${colored ? 150 : 0}% at ${origin.x}% ${origin.y}%)` }}
transition={{ duration: colored ? 0.9 : 0.5, ease }}
>
<TImage src={src} sizes={sizes} />
</motion.div>
</div>
)
}
export function Brand({ brand, className }: { brand: BrandData; className?: string }) {
return (
<a href="#top" className={cn("shrink-0 font-t-display text-xl tracking-tight md:text-2xl", className)}>
{brand.logo.trim() ? (
<span className="relative block h-8 w-36">
<TImage src={brand.logo} alt={brand.name} sizes="144px" className="object-contain object-left" />
</span>
) : (
brand.name
)}
</a>
)
}
// 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 (
<AnimatePresence>
{open && (
<motion.div
role="dialog"
aria-modal
aria-label="Menü"
data-nav
initial={{ clipPath: "inset(0% 0% 100% 0%)" }}
animate={{ clipPath: "inset(0% 0% 0% 0%)" }}
exit={{ clipPath: "inset(0% 0% 100% 0%)", transition: { duration: 0.45, ease: easeInOut } }}
transition={{ duration: 0.65, ease: easeInOut }}
className="fixed inset-0 z-[80] flex flex-col bg-t-bg px-5 pt-4 pb-10 text-t-fg"
>
<div className="flex h-12 items-center justify-between">
<Brand brand={brand} />
<button type="button" onClick={onClose} aria-label="Menü schließen" className="grid size-11 cursor-pointer place-items-center rounded-full border border-t-fg/15 transition-transform active:scale-95">
<XIcon className="size-5" />
</button>
</div>
<nav className="mt-auto flex flex-col">
{links.map((link, i) => (
<span key={link.href} className="overflow-hidden">
<motion.a
href={link.href}
onClick={(event) => {
event.preventDefault()
navigate(link.href)
}}
initial={{ y: "110%" }}
animate={{ y: "0%" }}
transition={{ duration: 0.7, ease, delay: 0.25 + i * 0.06 }}
className="block py-1 font-t-display text-[clamp(2.5rem,11vw,4.5rem)] leading-[1.08] tracking-[-0.03em]"
>
{link.label}
</motion.a>
</span>
))}
</nav>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }} className="mt-10 flex flex-wrap items-center justify-between gap-4">
{cta.label.trim() && (
<a
{...linkProps(cta.href)}
onClick={(event) => {
if (!cta.href.startsWith("#")) return
event.preventDefault()
navigate(cta.href)
}}
className={actionClass("solid")}
>
{cta.label}
</a>
)}
<a href={`mailto:${brand.email}`} className="text-sm text-t-muted">{brand.email}</a>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
// 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
}