Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 5s
Build & Deploy / 🏗️ Build (push) Failing after 14s
Build & Deploy / 🧪 QA (push) Failing after 1m48s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🩺 Health Check (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
"use client";
|
|
import { AnimatePresence, motion } from "framer-motion";
|
|
import Image from "next/image";
|
|
import Link from "next/link";
|
|
import { useSafePathname } from "./analytics/useSafePathname";
|
|
import * as React from "react";
|
|
|
|
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
|
|
|
export const Header: React.FC = () => {
|
|
const pathname = useSafePathname();
|
|
const [isScrolled, setIsScrolled] = React.useState(false);
|
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
|
|
|
React.useEffect(() => {
|
|
const handleScroll = () => {
|
|
setIsScrolled(window.scrollY > 20);
|
|
};
|
|
window.addEventListener("scroll", handleScroll);
|
|
return () => window.removeEventListener("scroll", handleScroll);
|
|
}, []);
|
|
|
|
// Close mobile menu on pathname change and handle body scroll lock
|
|
React.useEffect(() => {
|
|
setIsMobileMenuOpen(false);
|
|
}, [pathname]);
|
|
|
|
React.useEffect(() => {
|
|
if (isMobileMenuOpen) {
|
|
document.body.style.overflow = "hidden";
|
|
} else {
|
|
document.body.style.overflow = "unset";
|
|
}
|
|
return () => {
|
|
document.body.style.overflow = "unset";
|
|
};
|
|
}, [isMobileMenuOpen]);
|
|
|
|
const isActive = (path: string) => pathname === path;
|
|
|
|
const navLinks = [
|
|
{ href: "/about", label: "Über mich" },
|
|
{ href: "/websites", label: "Websites" },
|
|
{ href: "/case-studies", label: "Case Studies", prefix: true },
|
|
{ href: "/blog", label: "Blog", prefix: true },
|
|
];
|
|
|
|
return (
|
|
<header className="sticky top-0 z-[100] w-full">
|
|
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
|
|
<div
|
|
className={`absolute inset-0 transition-all duration-500 -z-10 ${isScrolled
|
|
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
|
|
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
|
|
}`}
|
|
/>
|
|
|
|
{/* Animated tech border at bottom */}
|
|
<div className="absolute bottom-0 left-0 right-0 h-px overflow-hidden pointer-events-none">
|
|
<div
|
|
className="h-full w-full"
|
|
style={{
|
|
background: isScrolled
|
|
? "linear-gradient(90deg, transparent 0%, rgba(148, 163, 184, 0.15) 30%, rgba(191, 206, 228, 0.1) 50%, rgba(148, 163, 184, 0.15) 70%, transparent 100%)"
|
|
: "transparent",
|
|
transition: "background 0.5s ease",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="narrow-container py-4 flex items-center justify-between relative z-10">
|
|
<Link href="/" className="flex items-center gap-4 group">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 md:w-12 md:h-12 bg-black rounded-xl flex items-center justify-center group-hover:scale-105 transition-all duration-500 shadow-sm shrink-0 relative overflow-hidden">
|
|
<Image
|
|
src={IconWhite}
|
|
alt="Marc Mintel Icon"
|
|
width={32}
|
|
height={32}
|
|
className="w-6 h-6 md:w-8 md:h-8 relative z-10"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
|
|
{/* Desktop Navigation */}
|
|
<nav className="hidden md:flex items-center gap-8">
|
|
{navLinks.map((link) => {
|
|
const active = link.prefix
|
|
? isActive(link.href) || pathname?.startsWith(`${link.href}/`)
|
|
: isActive(link.href);
|
|
|
|
return (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${active
|
|
? "text-slate-900"
|
|
: "text-slate-400 hover:text-slate-900"
|
|
}`}
|
|
>
|
|
{active && (
|
|
<span className="absolute -bottom-1 left-0 right-0 flex justify-center">
|
|
<span className="w-1 h-1 rounded-full bg-slate-900 animate-circuit-pulse" />
|
|
</span>
|
|
)}
|
|
{link.label}
|
|
</Link>
|
|
);
|
|
})}
|
|
<Link
|
|
href="/contact"
|
|
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
|
style={{
|
|
transitionTimingFunction: "cubic-bezier(0.23, 1, 0.32, 1)",
|
|
}}
|
|
>
|
|
Anfrage
|
|
</Link>
|
|
</nav>
|
|
|
|
{/* Mobile Toggle */}
|
|
<button
|
|
className="md:hidden relative z-[110] p-2 w-10 h-10 flex items-center justify-center rounded-xl bg-slate-900 text-white active:scale-90 transition-all duration-300 shadow-lg shadow-slate-200"
|
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
|
aria-label="Toggle Menu"
|
|
>
|
|
<div className="w-5 h-3.5 relative flex flex-col justify-between">
|
|
<motion.span
|
|
animate={
|
|
isMobileMenuOpen ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }
|
|
}
|
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
|
className="w-full h-0.5 bg-current rounded-full origin-center"
|
|
/>
|
|
<motion.span
|
|
animate={
|
|
isMobileMenuOpen ? { opacity: 0, x: -10 } : { opacity: 1, x: 0 }
|
|
}
|
|
transition={{ duration: 0.2 }}
|
|
className="w-full h-0.5 bg-current rounded-full"
|
|
/>
|
|
<motion.span
|
|
animate={
|
|
isMobileMenuOpen ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }
|
|
}
|
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
|
|
className="w-full h-0.5 bg-current rounded-full origin-center"
|
|
/>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Mobile Navigation - Bottom-Anchored Control Center */}
|
|
<AnimatePresence>
|
|
{isMobileMenuOpen && (
|
|
<React.Fragment key="mobile-control-center">
|
|
{/* Dimmed Backdrop */}
|
|
<motion.div
|
|
key="cc-backdrop"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
className="fixed inset-0 z-[101] bg-black/30 backdrop-blur-sm md:hidden"
|
|
/>
|
|
|
|
{/* Bottom Sheet */}
|
|
<motion.div
|
|
key="cc-sheet"
|
|
initial={{ y: "100%" }}
|
|
animate={{ y: 0 }}
|
|
exit={{ y: "100%" }}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 350,
|
|
damping: 30,
|
|
mass: 0.8,
|
|
}}
|
|
drag="y"
|
|
dragConstraints={{ top: 0, bottom: 0 }}
|
|
dragElastic={0.15}
|
|
onDragEnd={(_, info) => {
|
|
if (info.offset.y > 80 || info.velocity.y > 300) {
|
|
setIsMobileMenuOpen(false);
|
|
}
|
|
}}
|
|
className="fixed inset-x-0 bottom-0 z-[102] md:hidden bg-white rounded-t-[2rem] shadow-[0_-8px_40px_rgba(0,0,0,0.12)] flex flex-col max-h-[85vh] overflow-hidden"
|
|
>
|
|
{/* Grab Handle */}
|
|
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
|
|
<div className="w-10 h-1 bg-slate-200 rounded-full" />
|
|
</div>
|
|
|
|
{/* Status Bar */}
|
|
<div className="px-6 py-3 flex justify-between items-center border-b border-slate-100/80">
|
|
<div className="flex items-center gap-2 text-[9px] font-mono font-bold tracking-[0.15em] text-slate-400 uppercase">
|
|
<div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
|
|
Online
|
|
</div>
|
|
<div className="text-[9px] font-mono font-bold tracking-widest text-slate-400 uppercase">
|
|
{pathname === "/"
|
|
? "HOME"
|
|
: pathname.toUpperCase().replace(/^\//, "")}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tiled Navigation Grid */}
|
|
<div className="px-5 pt-5 pb-3 flex-1 overflow-y-auto">
|
|
<div className="grid grid-cols-2 gap-2.5">
|
|
{[
|
|
{ href: "/about", label: "Über mich", sub: "Architect" },
|
|
{ href: "/websites", label: "Websites", sub: "Systems" },
|
|
{
|
|
href: "/case-studies",
|
|
label: "Cases",
|
|
sub: "Solutions",
|
|
prefix: true,
|
|
},
|
|
{
|
|
href: "/blog",
|
|
label: "Blog",
|
|
sub: "Insights",
|
|
prefix: true,
|
|
},
|
|
].map((item, i) => {
|
|
const active = item.prefix
|
|
? pathname?.startsWith(item.href)
|
|
: pathname === item.href;
|
|
|
|
return (
|
|
<motion.div
|
|
key={item.href}
|
|
initial={{ opacity: 0, scale: 0.85, y: 15 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
transition={{
|
|
delay: 0.05 + i * 0.04,
|
|
type: "spring",
|
|
stiffness: 400,
|
|
damping: 25,
|
|
}}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<Link
|
|
href={item.href}
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${active
|
|
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
|
|
: "bg-white border-slate-100 active:bg-slate-50"
|
|
}`}
|
|
>
|
|
<div>
|
|
<span className="text-[15px] font-black tracking-tight text-slate-900 block leading-tight mb-1">
|
|
{item.label}
|
|
</span>
|
|
<span className="text-[9px] font-mono font-bold text-slate-400 uppercase tracking-[0.2em]">
|
|
{item.sub}
|
|
</span>
|
|
</div>
|
|
{active && (
|
|
<div className="absolute top-4 right-4 w-1.5 h-1.5 rounded-full bg-slate-900" />
|
|
)}
|
|
</Link>
|
|
</motion.div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Primary CTA */}
|
|
<div className="px-5 pb-5 pt-2">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.25, type: "spring", stiffness: 300 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
>
|
|
<Link
|
|
href="/contact"
|
|
onClick={() => setIsMobileMenuOpen(false)}
|
|
className="flex items-center justify-between w-full p-4 bg-slate-900 text-white rounded-2xl active:bg-slate-800 transition-colors"
|
|
>
|
|
<span className="text-[13px] font-bold uppercase tracking-[0.15em]">
|
|
Projekt anfragen
|
|
</span>
|
|
<svg
|
|
className="w-4 h-4 text-slate-400"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
>
|
|
<path
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
strokeWidth="2.5"
|
|
d="M14 5l7 7m0 0l-7 7m7-7H3"
|
|
/>
|
|
</svg>
|
|
</Link>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Safe-Area Spacer (iOS home indicator) */}
|
|
<div className="h-[env(safe-area-inset-bottom,0px)]" />
|
|
</motion.div>
|
|
</React.Fragment>
|
|
)}
|
|
</AnimatePresence>
|
|
</header>
|
|
);
|
|
};
|