Files
sass-mohfarawati/components/layout/site-header.tsx
T
MOH 981afc9e33
CI / quality (push) Waiting to run
fix header transition
2026-03-10 17:47:33 +01:00

436 lines
17 KiB
TypeScript

"use client";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { LayoutDashboard, Menu, X } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale, useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { Container } from "@/components/layout/container";
import { LocaleToggle } from "@/components/layout/locale-toggle";
import { SiteLogo } from "@/components/layout/site-logo";
import { SoundToggle } from "@/components/sound-toggle";
import { ThemeToggle } from "@/components/theme-toggle";
import { Button } from "@/components/ui/button";
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
import { cn } from "@/lib/utils";
const navItems = [
{ key: "home", path: "" },
{ key: "portfolio", path: "/portfolio" },
{ key: "products", path: "/products" },
{ key: "about", path: "/about" },
{ key: "contact", path: "/contact" },
];
type SiteHeaderProps = {
isAdmin?: boolean;
lightLogoUrl?: string | null;
darkLogoUrl?: string | null;
};
function getDirectionalReveal(direction: "rtl" | "ltr") {
return direction === "rtl"
? {
initial: { opacity: 0, x: -16, clipPath: "inset(0 0 0 100%)", filter: "blur(10px)" },
animate: { opacity: 1, x: 0, y: 0, clipPath: "inset(0 0 0 0)", filter: "blur(0px)" },
}
: {
initial: { opacity: 0, x: 16, clipPath: "inset(0 100% 0 0)", filter: "blur(10px)" },
animate: { opacity: 1, x: 0, y: 0, clipPath: "inset(0 0 0 0)", filter: "blur(0px)" },
};
}
function isNavItemActive(currentPath: string, itemPath: string) {
if (itemPath === "/") {
return currentPath === "/";
}
return currentPath === itemPath || currentPath.startsWith(`${itemPath}/`);
}
function NavLinks({
onNavigate,
mobile = false,
}: {
onNavigate?: () => void;
mobile?: boolean;
}) {
const locale = useLocale();
const pathname = usePathname();
const t = useTranslations("navigation");
const currentPath = stripLocalePrefix(pathname);
return (
<>
{navItems.map((item) => (
(() => {
const itemPath = item.path || "/";
const isActive = isNavItemActive(currentPath, itemPath);
return (
<Link
key={item.key}
href={getLocalizedPath(locale, itemPath)}
className={cn(
"relative z-10 rounded-pill transition-colors",
mobile ? "px-4 py-3 text-sm font-medium" : "px-3.5 py-2 text-sm font-medium xl:px-4",
isActive
? "text-primary-foreground"
: "text-foreground/70 hover:text-foreground",
)}
onClick={onNavigate}
>
{isActive ? (
<motion.span
layoutId={`site-header-active-tab-${locale}`}
className="absolute inset-0 -z-10 rounded-pill bg-primary shadow-xs"
transition={{ type: "spring", stiffness: 380, damping: 30 }}
/>
) : null}
{t(item.key)}
</Link>
);
})()
))}
</>
);
}
export function SiteHeader({
isAdmin = false,
lightLogoUrl,
darkLogoUrl,
}: SiteHeaderProps) {
const [isOpen, setIsOpen] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [logoReplayKey, setLogoReplayKey] = useState(0);
const logoClickTimesRef = useRef<number[]>([]);
const pathname = usePathname();
const locale = useLocale();
const t = useTranslations("navigation");
const reducedMotion = useReducedMotion();
const isArabic = locale === "ar";
const languageDirection = isArabic ? "rtl" : "ltr";
const titleMotion = getDirectionalReveal(languageDirection);
const subtitleMotion = getDirectionalReveal(languageDirection);
const brandSubtitle = isArabic
? "مــــــطـــــــــــــور ويــــــــــب | فــــــــــــريــــــلانــــــســــــر"
: "Webdesigner Bremen | Freelancer";
const desktopControlRailClassName =
"inline-flex items-center gap-1 rounded-pill border border-border/70 bg-background/80 p-1 backdrop-blur-chrome";
const desktopControlButtonClassName =
"h-9 w-9 rounded-pill border border-transparent p-0 text-foreground/80 hover:bg-accent hover:text-foreground";
const mobileControlButtonClassName =
"h-10 w-10 rounded-pill border border-border/70 bg-background text-foreground/80 shadow-xs hover:bg-accent hover:text-foreground";
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 24);
};
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
const handleLogoClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
setLogoReplayKey((value) => value + 1);
const now = Date.now();
const recentClicks = logoClickTimesRef.current.filter((time) => now - time < 1200);
const nextClicks = [...recentClicks, now];
logoClickTimesRef.current = nextClicks;
if (nextClicks.length < 3) {
return;
}
logoClickTimesRef.current = [];
event.preventDefault();
toast(t("logoTripleClick"));
};
return (
<header className="fixed inset-x-0 top-3 z-40 bg-transparent">
<Container className="max-w-[88rem] px-5 sm:px-6 lg:px-8">
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
<motion.div
initial={false}
animate={isScrolled ? { opacity: 0, x: -28 } : { opacity: 1, x: 0 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"col-start-1 flex items-center justify-self-start",
isScrolled ? "lg:pointer-events-none lg:invisible" : "",
)}
>
<Link
href={getLocalizedPath(locale)}
className="inline-flex items-center gap-2.5 rounded-pill px-1 py-1 sm:gap-3 lg:py-2"
onClick={handleLogoClick}
>
<SiteLogo
lightLogoUrl={lightLogoUrl}
darkLogoUrl={darkLogoUrl}
alt="mohfarawati"
priority
className="relative h-10 opacity-95"
replayKey={logoReplayKey}
direction={languageDirection}
/>
<span
key={`${locale}-${logoReplayKey}`}
className="flex flex-col justify-center"
>
<motion.span
initial={reducedMotion ? false : titleMotion.initial}
animate={titleMotion.animate}
transition={reducedMotion ? { duration: 0 } : { duration: 0.7, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"text-[1.28rem] font-black leading-[1.2] whitespace-nowrap",
isArabic ? "text-right" : "",
)}
dir={isArabic ? "rtl" : undefined}
>
{isArabic ? (
<>
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">مــــحـــمــد</span>
{" "}
<span className="text-primary">فــــرواتـــي</span>
</>
) : (
<>
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">MOH</span>
{" "}
<span className="text-primary">FARAWATI</span>
</>
)}
</motion.span>
<motion.span
initial={reducedMotion ? false : subtitleMotion.initial}
animate={subtitleMotion.animate}
transition={reducedMotion ? { duration: 0 } : { duration: 0.7, delay: 0.34, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"-mt-0.5 text-[0.65rem] font-light leading-[1.2] tracking-[0.008em] text-foreground/78",
isArabic ? "text-right" : "",
)}
dir={isArabic ? "rtl" : undefined}
>
{brandSubtitle}
</motion.span>
</span>
</Link>
</motion.div>
<div className="col-start-2 hidden justify-center lg:flex">
<motion.nav
initial={false}
animate={isScrolled ? { y: -1, scale: 0.985 } : { y: 0, scale: 1 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"inline-flex items-center gap-1 rounded-pill border border-border/70 bg-background/80 p-1.5 backdrop-blur-chrome",
isArabic ? "w-auto max-w-none" : "min-w-[27rem] max-w-[42rem]",
)}
>
<NavLinks />
</motion.nav>
</div>
<motion.div
initial={false}
animate={isScrolled ? { opacity: 0, x: 28 } : { opacity: 1, x: 0 }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
className={cn(
"col-start-3 hidden items-center justify-end lg:flex",
isScrolled ? "pointer-events-none invisible" : "",
)}
>
<div className={desktopControlRailClassName}>
<LocaleToggle
locale={locale}
localeChangedLabels={{
de: t("localeChangedDe"),
en: t("localeChangedEn"),
ar: t("localeChangedAr"),
}}
className={desktopControlButtonClassName}
/>
<SoundToggle
ariaLabel={t("soundMute")}
mutedAriaLabel={t("soundUnmute")}
mutedToastLabel={t("soundMuted")}
unmutedToastLabel={t("soundEnabled")}
variant="ghost"
className={desktopControlButtonClassName}
/>
<ThemeToggle
ariaLabel={t("themeToggle")}
lightToastLabel={t("themeLight")}
darkToastLabel={t("themeDark")}
variant="ghost"
className={desktopControlButtonClassName}
/>
{isAdmin ? (
<Button asChild variant="ghost" size="icon" className={desktopControlButtonClassName}>
<Link href="/root" aria-label={t("root")}>
<LayoutDashboard className="h-4 w-4 transition-transform duration-300 ease-out hover:scale-110" />
</Link>
</Button>
) : null}
</div>
</motion.div>
<motion.div
initial={false}
animate={{ opacity: 1, x: 0, scale: 1, filter: "blur(0px)" }}
transition={reducedMotion ? { duration: 0 } : { duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="col-start-3 ml-auto justify-self-end lg:hidden"
>
<Button
type="button"
onClick={() => setIsOpen((open) => !open)}
variant="ghost"
size="icon"
className="h-11 w-11 rounded-pill border border-border/70 bg-background/80 text-foreground backdrop-blur-chrome hover:bg-accent"
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
>
<motion.span
animate={{ rotate: isOpen ? 180 : 0, scale: isOpen ? 0.96 : 1 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="flex items-center justify-center"
>
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
</motion.span>
</Button>
</motion.div>
</div>
</Container>
<AnimatePresence initial={false}>
{isOpen ? (
<motion.div
initial={{ opacity: 0, y: -14 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.22, ease: "easeOut" }}
className="bg-transparent lg:hidden"
>
<Container className="px-5 pb-2 pt-3 sm:px-6 lg:px-8">
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="overflow-hidden rounded-surface border border-border/70 bg-card/95 backdrop-blur-chrome"
>
<motion.nav
initial="closed"
animate="open"
exit="closed"
variants={{
open: {
transition: {
staggerChildren: 0.04,
delayChildren: 0.02,
},
},
closed: {
transition: {
staggerChildren: 0.03,
staggerDirection: -1,
},
},
}}
className="flex flex-col gap-1 p-2"
>
{navItems.map((item) => {
const itemPath = item.path || "/";
const isActive = isNavItemActive(stripLocalePrefix(pathname), itemPath);
return (
<motion.div
key={item.key}
variants={{
open: { opacity: 1, y: 0 },
closed: { opacity: 0, y: -8 },
}}
transition={{ duration: 0.18, ease: "easeOut" }}
>
<Link
href={getLocalizedPath(locale, itemPath)}
className={cn(
"flex items-center justify-between rounded-nested px-4 py-3 text-sm font-medium transition-colors",
isActive
? "bg-accent text-foreground shadow-xs"
: "text-foreground/80 hover:bg-accent/70 hover:text-foreground",
)}
onClick={() => setIsOpen(false)}
>
<span>{t(item.key)}</span>
<span className="text-xs uppercase tracking-[0.18em] opacity-60">
0{navItems.findIndex((navItem) => navItem.key === item.key) + 1}
</span>
</Link>
</motion.div>
);
})}
</motion.nav>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.18, ease: "easeOut", delay: 0.04 }}
className="border-t border-border/70 bg-background/70 p-2"
>
<div className="flex items-center justify-between gap-2">
<LocaleToggle
locale={locale}
localeChangedLabels={{
de: t("localeChangedDe"),
en: t("localeChangedEn"),
ar: t("localeChangedAr"),
}}
className={mobileControlButtonClassName}
/>
<div className="flex items-center gap-2">
<SoundToggle
ariaLabel={t("soundMute")}
mutedAriaLabel={t("soundUnmute")}
mutedToastLabel={t("soundMuted")}
unmutedToastLabel={t("soundEnabled")}
variant="ghost"
className={mobileControlButtonClassName}
/>
<ThemeToggle
ariaLabel={t("themeToggle")}
lightToastLabel={t("themeLight")}
darkToastLabel={t("themeDark")}
variant="ghost"
className={mobileControlButtonClassName}
/>
{isAdmin ? (
<Button asChild variant="ghost" size="icon" className={mobileControlButtonClassName}>
<Link href="/root" aria-label={t("root")} onClick={() => setIsOpen(false)}>
<LayoutDashboard className="h-4 w-4 transition-transform duration-300 ease-out hover:scale-110" />
</Link>
</Button>
) : null}
</div>
</div>
</motion.div>
</motion.div>
</Container>
</motion.div>
) : null}
</AnimatePresence>
</header>
);
}