mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
329 lines
14 KiB
TypeScript
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
|
|
}
|