423 lines
16 KiB
TypeScript
423 lines
16 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, useState } from "react";
|
|
|
|
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" },
|
|
];
|
|
|
|
const brandTitleCharacters = [
|
|
...Array.from("MOH").map((character) => ({ character, tone: "base" as const })),
|
|
{ character: " ", tone: "space" as const },
|
|
...Array.from("FARAWATI").map((character) => ({ character, tone: "accent" as const })),
|
|
];
|
|
const brandShellVariants = {
|
|
hidden: {},
|
|
visible: {
|
|
transition: {
|
|
staggerChildren: 0.035,
|
|
delayChildren: 0.18,
|
|
},
|
|
},
|
|
} as const;
|
|
const brandCharacterVariants = {
|
|
hidden: {
|
|
opacity: 0,
|
|
y: 18,
|
|
rotateX: -80,
|
|
filter: "blur(8px)",
|
|
},
|
|
visible: {
|
|
opacity: 1,
|
|
y: 0,
|
|
rotateX: 0,
|
|
filter: "blur(0px)",
|
|
transition: {
|
|
type: "spring" as const,
|
|
stiffness: 280,
|
|
damping: 20,
|
|
mass: 0.8,
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
type SiteHeaderProps = {
|
|
isAdmin?: boolean;
|
|
lightLogoUrl?: string | null;
|
|
darkLogoUrl?: string | null;
|
|
};
|
|
|
|
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"
|
|
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 pathname = usePathname();
|
|
const locale = useLocale();
|
|
const t = useTranslations("navigation");
|
|
const reducedMotion = useReducedMotion();
|
|
const isArabic = locale === "ar";
|
|
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);
|
|
};
|
|
}, []);
|
|
|
|
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={() => setLogoReplayKey((value) => value + 1)}
|
|
>
|
|
<SiteLogo
|
|
lightLogoUrl={lightLogoUrl}
|
|
darkLogoUrl={darkLogoUrl}
|
|
alt="mohfarawati"
|
|
priority
|
|
className="relative h-10 opacity-95"
|
|
replayKey={logoReplayKey}
|
|
/>
|
|
<motion.span
|
|
initial={isArabic ? false : "hidden"}
|
|
animate="visible"
|
|
variants={reducedMotion || isArabic ? undefined : brandShellVariants}
|
|
className="flex flex-col justify-center"
|
|
>
|
|
{isArabic ? (
|
|
<motion.span
|
|
initial={reducedMotion ? false : { opacity: 0, x: 16, filter: "blur(8px)" }}
|
|
animate={{ opacity: 1, x: 0, filter: "blur(0px)" }}
|
|
transition={reducedMotion ? { duration: 0 } : { duration: 0.42, delay: 0.12, ease: [0.22, 1, 0.36, 1] }}
|
|
className="text-[1.28rem] font-black leading-[1.2] whitespace-nowrap"
|
|
dir="rtl"
|
|
>
|
|
<span className="text-[hsl(var(--hero-ink))] dark:text-foreground">مــــحـــمــد</span>
|
|
{" "}
|
|
<span className="text-primary">فــــرواتـــي</span>
|
|
</motion.span>
|
|
) : (
|
|
<span className="flex flex-wrap items-center text-[1.28rem] font-black leading-[1.2]">
|
|
{brandTitleCharacters.map(({ character, tone }, index) => (
|
|
<motion.span
|
|
key={`${character}-${index}`}
|
|
variants={reducedMotion ? undefined : brandCharacterVariants}
|
|
className={cn(
|
|
character === " " ? "mr-[0.11em] w-[0.11em]" : "mr-[0.005em] inline-block",
|
|
tone === "base" ? "text-[hsl(var(--hero-ink))] dark:text-foreground" : "",
|
|
tone === "accent" ? "text-primary" : "",
|
|
)}
|
|
>
|
|
{character === " " ? "\u00A0" : character}
|
|
</motion.span>
|
|
))}
|
|
</span>
|
|
)}
|
|
<motion.span
|
|
initial={
|
|
reducedMotion
|
|
? false
|
|
: isArabic
|
|
? { opacity: 0, x: -16, clipPath: "inset(0 0 0 100%)", filter: "blur(10px)" }
|
|
: { 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)" }}
|
|
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>
|
|
</motion.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}
|
|
className={desktopControlButtonClassName}
|
|
/>
|
|
<SoundToggle
|
|
ariaLabel={t("soundMute")}
|
|
mutedAriaLabel={t("soundUnmute")}
|
|
variant="ghost"
|
|
className={desktopControlButtonClassName}
|
|
/>
|
|
<ThemeToggle
|
|
ariaLabel={t("themeToggle")}
|
|
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>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() => setIsOpen((open) => !open)}
|
|
variant="ghost"
|
|
size="icon"
|
|
className="col-start-3 ml-auto h-11 w-11 justify-self-end rounded-pill border border-border/70 bg-background/80 text-foreground backdrop-blur-chrome hover:bg-accent lg:hidden"
|
|
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>
|
|
</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}
|
|
className={mobileControlButtonClassName}
|
|
/>
|
|
<div className="flex items-center gap-2">
|
|
<SoundToggle
|
|
ariaLabel={t("soundMute")}
|
|
mutedAriaLabel={t("soundUnmute")}
|
|
variant="ghost"
|
|
className={mobileControlButtonClassName}
|
|
/>
|
|
<ThemeToggle
|
|
ariaLabel={t("themeToggle")}
|
|
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>
|
|
);
|
|
}
|