mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
236 lines
12 KiB
TypeScript
236 lines
12 KiB
TypeScript
"use client"
|
||
|
||
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "motion/react"
|
||
import { useEffect, useId, useRef, useState } from "react"
|
||
import { cn } from "@/lib/utils"
|
||
import { ease, useScrollNav, wordmarkSize } from "../shared/motion"
|
||
import type { NavGlasData, NavLeisteData, NavMastheadData } from "./definitions"
|
||
import type { BlockProps } from "./types"
|
||
import { actionClass, Brand, container, linkProps, MobileMenu, useDarkSurface } from "./ui"
|
||
|
||
function MenuButton({ open, onClick, className }: { open: boolean; onClick: () => void; className?: string }) {
|
||
return (
|
||
<button type="button" onClick={onClick} aria-expanded={open} aria-label="Menü öffnen" className={cn("flex cursor-pointer items-center gap-3 text-sm", className)}>
|
||
Menü
|
||
<span aria-hidden className="flex w-6 flex-col items-end gap-1.5">
|
||
<span className="h-px w-6 bg-current" />
|
||
<span className="h-px w-4 bg-current" />
|
||
</span>
|
||
</button>
|
||
)
|
||
}
|
||
|
||
// Klassische Leiste: transparent oben, Milchglas nach dem ersten Scrollen, versteckt sich beim Runterscrollen.
|
||
// Über dunklen Bildflächen (data-surface="dark") wird die Schrift hell.
|
||
export function NavLeiste({ data, brand, links }: BlockProps<NavLeisteData>) {
|
||
const { scrolled, hidden } = useScrollNav()
|
||
const onImage = useDarkSurface()
|
||
const [open, setOpen] = useState(false)
|
||
const light = onImage && !scrolled
|
||
// Menüpunkt mit demselben Ziel wie der Button nicht doppelt zeigen (z. B. „Kontakt“)
|
||
const inline = links.filter((link) => link.href !== data.href.trim())
|
||
|
||
return (
|
||
<>
|
||
<motion.header
|
||
data-nav
|
||
animate={{ y: hidden ? "-110%" : "0%" }}
|
||
transition={{ duration: 0.45, ease }}
|
||
className="fixed inset-x-0 top-0 z-50"
|
||
>
|
||
<div
|
||
className={cn(
|
||
"border-b transition-[background-color,border-color] duration-500",
|
||
scrolled ? "border-t-fg/10 bg-t-bg/75 backdrop-blur-xl" : "border-transparent"
|
||
)}
|
||
>
|
||
<nav className={cn(container, "flex h-16 items-center justify-between gap-6 transition-colors duration-500 md:h-20", light ? "text-white" : "text-t-fg")}>
|
||
<Brand brand={brand} />
|
||
<div className="hidden items-center gap-8 md:flex">
|
||
{inline.map((link) => (
|
||
<a key={link.href} href={link.href} className="group relative text-sm opacity-75 transition-opacity hover:opacity-100">
|
||
{link.label}
|
||
<span aria-hidden className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-current transition-transform duration-300 group-hover:origin-left group-hover:scale-x-100" />
|
||
</a>
|
||
))}
|
||
{data.cta.trim() && (
|
||
<a {...linkProps(data.href)} className={cn(actionClass(light ? "light" : "solid"), "px-5 py-2.5")}>
|
||
{data.cta}
|
||
</a>
|
||
)}
|
||
</div>
|
||
<MenuButton open={open} onClick={() => setOpen(true)} className="md:hidden" />
|
||
</nav>
|
||
</div>
|
||
</motion.header>
|
||
<MobileMenu open={open} onClose={() => setOpen(false)} links={links} brand={brand} cta={{ label: data.cta, href: data.href }} />
|
||
</>
|
||
)
|
||
}
|
||
|
||
// Schwebende Glas-Pille; die Hervorhebung gleitet von Menüpunkt zu Menüpunkt
|
||
export function NavGlas({ data, brand, links }: BlockProps<NavGlasData>) {
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
const pill = useId()
|
||
const [hovered, setHovered] = useState<string | null>(null)
|
||
const [open, setOpen] = useState(false)
|
||
const inline = links.filter((link) => link.href !== data.href.trim())
|
||
|
||
useEffect(() => {
|
||
if (!open) return
|
||
const close = (event: PointerEvent) => {
|
||
if (!ref.current?.contains(event.target as Node)) setOpen(false)
|
||
}
|
||
document.addEventListener("pointerdown", close)
|
||
return () => document.removeEventListener("pointerdown", close)
|
||
}, [open])
|
||
|
||
return (
|
||
<motion.header
|
||
data-nav
|
||
initial={{ opacity: 0, y: -16 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.8, ease, delay: 0.15 }}
|
||
className="fixed inset-x-0 top-3 z-50 flex justify-center px-3 md:top-5"
|
||
>
|
||
<div ref={ref} className="relative w-full max-w-3xl">
|
||
<nav className="flex items-center justify-between gap-2 rounded-full border border-t-fg/10 bg-t-bg/65 py-1.5 pr-1.5 pl-5 text-t-fg shadow-[0_12px_40px_-18px_rgba(0,0,0,0.45)] backdrop-blur-xl">
|
||
<Brand brand={brand} className="text-lg md:text-xl" />
|
||
<ul className="hidden items-center md:flex" onPointerLeave={() => setHovered(null)}>
|
||
{inline.map((link) => (
|
||
<li key={link.href} className="relative">
|
||
{hovered === link.href && (
|
||
<motion.span
|
||
layoutId={pill}
|
||
className="absolute inset-0 rounded-full bg-t-fg/[0.08]"
|
||
transition={{ type: "spring", bounce: 0.18, duration: 0.45 }}
|
||
/>
|
||
)}
|
||
<a href={link.href} onPointerEnter={() => setHovered(link.href)} className="relative block px-4 py-2 text-sm text-t-fg/75 transition-colors hover:text-t-fg">
|
||
{link.label}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="flex items-center gap-1">
|
||
{data.cta.trim() && (
|
||
<a {...linkProps(data.href)} className={cn(actionClass("solid"), "px-4 py-2.5 md:px-5")}>
|
||
{data.cta}
|
||
</a>
|
||
)}
|
||
{links.length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen(!open)}
|
||
aria-expanded={open}
|
||
aria-label={open ? "Menü schließen" : "Menü öffnen"}
|
||
className="grid size-10 cursor-pointer place-items-center rounded-full transition-colors hover:bg-t-fg/[0.06] md:hidden"
|
||
>
|
||
<span aria-hidden className="relative block h-3 w-5">
|
||
<span className={cn("absolute left-0 h-px w-5 bg-current transition-transform duration-300", open ? "top-1.5 rotate-45" : "top-0")} />
|
||
<span className={cn("absolute left-0 h-px w-5 bg-current transition-transform duration-300", open ? "top-1.5 -rotate-45" : "top-3")} />
|
||
</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
</nav>
|
||
<AnimatePresence>
|
||
{open && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -8, scale: 0.97 }}
|
||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||
exit={{ opacity: 0, y: -6, scale: 0.98, transition: { duration: 0.15 } }}
|
||
transition={{ duration: 0.25, ease }}
|
||
style={{ transformOrigin: "top right" }}
|
||
className="absolute inset-x-0 top-full mt-2 rounded-[1.75rem] border border-t-fg/10 bg-t-bg/90 p-2 text-t-fg shadow-2xl backdrop-blur-xl md:hidden"
|
||
>
|
||
{links.map((link) => (
|
||
<a key={link.href} href={link.href} onClick={() => setOpen(false)} className="block rounded-2xl px-4 py-3 text-lg transition-colors hover:bg-t-fg/[0.06]">
|
||
{link.label}
|
||
</a>
|
||
))}
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</div>
|
||
</motion.header>
|
||
)
|
||
}
|
||
|
||
// Magazin-Kopf: großer Schriftzug im Fluss der Seite, danach eine schmale Leiste oben
|
||
export function NavMasthead({ data, brand, links }: BlockProps<NavMastheadData>) {
|
||
const ref = useRef<HTMLElement>(null)
|
||
const { scrollY } = useScroll()
|
||
const [compact, setCompact] = useState(false)
|
||
const [open, setOpen] = useState(false)
|
||
const letters = Array.from(brand.name)
|
||
|
||
useMotionValueEvent(scrollY, "change", (y) => {
|
||
setCompact(y > (ref.current?.offsetHeight ?? 320) - 24)
|
||
})
|
||
|
||
return (
|
||
<>
|
||
<header ref={ref} className={cn(container, "relative pt-4 md:pt-5")}>
|
||
<div className="flex items-center justify-between gap-4 border-b border-t-fg/15 pb-3 text-[11px] uppercase tracking-[0.22em] text-t-muted md:pb-4">
|
||
<span>{data.edition}</span>
|
||
<nav className="hidden gap-7 md:flex">
|
||
{links.map((link) => (
|
||
<a key={link.href} href={link.href} className="transition-colors hover:text-t-fg">
|
||
{link.label}
|
||
</a>
|
||
))}
|
||
</nav>
|
||
<MenuButton open={open} onClick={() => setOpen(true)} className="text-[11px] tracking-[0.22em] uppercase md:hidden" />
|
||
</div>
|
||
<a
|
||
href="#top"
|
||
aria-label={brand.name}
|
||
className="block overflow-hidden pt-[0.08em] pb-[0.12em] text-center font-t-display leading-[0.9] tracking-[-0.04em]"
|
||
style={{ fontSize: wordmarkSize(brand.name, 19) }}
|
||
>
|
||
{letters.map((char, i) => (
|
||
<motion.span
|
||
key={i}
|
||
aria-hidden
|
||
className="inline-block"
|
||
initial={{ y: "105%" }}
|
||
animate={{ y: "0%" }}
|
||
transition={{ duration: 1.1, ease, delay: 0.1 + i * 0.045 }}
|
||
>
|
||
{char === " " ? " " : char}
|
||
</motion.span>
|
||
))}
|
||
</a>
|
||
<div className="flex items-center justify-between gap-4 border-y border-t-fg/15 py-3 text-[11px] uppercase tracking-[0.22em] text-t-muted">
|
||
<span>{brand.tagline}</span>
|
||
{data.cta.trim() && (
|
||
<a {...linkProps(data.href)} className="shrink-0 text-t-fg underline-offset-4 hover:underline">
|
||
{data.cta} →
|
||
</a>
|
||
)}
|
||
</div>
|
||
</header>
|
||
<motion.div
|
||
data-nav
|
||
initial={false}
|
||
animate={{ y: compact ? "0%" : "-110%" }}
|
||
transition={{ duration: 0.5, ease }}
|
||
className="fixed inset-x-0 top-0 z-50 border-b border-t-fg/10 bg-t-bg/85 text-t-fg backdrop-blur-xl"
|
||
>
|
||
<div className={cn(container, "flex h-14 items-center justify-between gap-6")}>
|
||
<Brand brand={brand} className="text-xl md:text-xl" />
|
||
<nav className="hidden gap-7 text-sm md:flex">
|
||
{links.map((link) => (
|
||
<a key={link.href} href={link.href} className="text-t-fg/70 transition-colors hover:text-t-fg">
|
||
{link.label}
|
||
</a>
|
||
))}
|
||
</nav>
|
||
<MenuButton open={open} onClick={() => setOpen(true)} className="md:hidden" />
|
||
</div>
|
||
</motion.div>
|
||
<MobileMenu open={open} onClose={() => setOpen(false)} links={links} brand={brand} cta={{ label: data.cta, href: data.href }} />
|
||
</>
|
||
)
|
||
}
|