Refactor site header, heroes, and design system
This commit is contained in:
@@ -1,16 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { Menu, X } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { AnimatePresence, motion } 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 { useState } from "react";
|
||||
|
||||
import { Container } from "@/components/layout/container";
|
||||
import { LocaleToggle } from "@/components/layout/locale-toggle";
|
||||
import { SiteLogo } from "@/components/layout/site-logo";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getLocalizedPath } from "@/lib/locale";
|
||||
import { getLocalizedPath, stripLocalePrefix } from "@/lib/locale";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ key: "home", path: "" },
|
||||
@@ -22,111 +25,253 @@ const navItems = [
|
||||
|
||||
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({
|
||||
isAdmin,
|
||||
onNavigate,
|
||||
mobile = false,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
onNavigate?: () => void;
|
||||
mobile?: boolean;
|
||||
}) {
|
||||
const locale = useLocale();
|
||||
const pathname = usePathname();
|
||||
const t = useTranslations("navigation");
|
||||
const currentPath = stripLocalePrefix(pathname);
|
||||
|
||||
return (
|
||||
<>
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={getLocalizedPath(locale, item.path || "/")}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
(() => {
|
||||
const itemPath = item.path || "/";
|
||||
const isActive = isNavItemActive(currentPath, itemPath);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
href={getLocalizedPath(locale, itemPath)}
|
||||
className={cn(
|
||||
"relative z-10 rounded-[var(--radius-pill)] text-sm font-medium transition-colors",
|
||||
mobile ? "px-4 py-3" : "px-3.5 py-2 xl:px-4",
|
||||
isActive ? "text-primary-foreground" : "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{isActive ? (
|
||||
<motion.span
|
||||
layoutId="site-header-active-tab"
|
||||
className="absolute inset-0 -z-10 rounded-[var(--radius-pill)] bg-primary shadow-[0_14px_34px_-22px_hsl(var(--brand-primary)/0.65)]"
|
||||
transition={{ type: "spring", stiffness: 380, damping: 30 }}
|
||||
/>
|
||||
) : null}
|
||||
{t(item.key)}
|
||||
</Link>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
{isAdmin ? (
|
||||
<a
|
||||
href="/root"
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
{t("root")}
|
||||
</a>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SiteHeader({ isAdmin = false }: SiteHeaderProps) {
|
||||
export function SiteHeader({
|
||||
isAdmin = false,
|
||||
lightLogoUrl,
|
||||
darkLogoUrl,
|
||||
}: SiteHeaderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("navigation");
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 border-b border-border/80 bg-background/88 backdrop-blur-chrome">
|
||||
<Container>
|
||||
<div className="flex min-h-header items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<header className="sticky top-3 z-40 bg-transparent">
|
||||
<Container className="max-w-[88rem]">
|
||||
<div className="relative grid min-h-[4.5rem] grid-cols-[auto_1fr_auto] items-center gap-3 lg:min-h-[5rem]">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href={getLocalizedPath(locale)}
|
||||
className="inline-flex items-center rounded-md border border-input bg-background px-3 py-2 shadow-sm"
|
||||
className="inline-flex items-center py-1 sm:py-1.5 lg:py-2"
|
||||
>
|
||||
<Image
|
||||
src="/logos/light-primary.svg"
|
||||
<SiteLogo
|
||||
lightLogoUrl={lightLogoUrl}
|
||||
darkLogoUrl={darkLogoUrl}
|
||||
alt="mohfarawati"
|
||||
width={140}
|
||||
height={24}
|
||||
className="block h-6 w-auto dark:hidden"
|
||||
priority
|
||||
/>
|
||||
<Image
|
||||
src="/logos/dark-primary.svg"
|
||||
alt="mohfarawati"
|
||||
width={140}
|
||||
height={24}
|
||||
className="hidden h-6 w-auto dark:block"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-5 md:flex">
|
||||
<NavLinks isAdmin={isAdmin} />
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<ThemeToggle ariaLabel={t("themeToggle")} />
|
||||
<LocaleToggle locale={locale} />
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/2 hidden -translate-x-1/2 -translate-y-1/2 lg:flex">
|
||||
<motion.nav
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: "easeOut" }}
|
||||
className="pointer-events-auto inline-flex min-w-[27rem] max-w-[42rem] items-center justify-center gap-1 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.22),rgba(255,255,255,0.1))] p-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
|
||||
>
|
||||
<NavLinks />
|
||||
</motion.nav>
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center justify-end lg:flex">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.28, ease: "easeOut", delay: 0.03 }}
|
||||
className="inline-flex items-center gap-1 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.22),rgba(255,255,255,0.1))] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
|
||||
>
|
||||
<LocaleToggle
|
||||
locale={locale}
|
||||
showLabel
|
||||
className="h-9 rounded-[var(--radius-pill)] px-3 text-foreground hover:bg-white/34 dark:hover:bg-white/10"
|
||||
/>
|
||||
{isAdmin ? (
|
||||
<Button asChild variant="ghost" size="icon" className="h-9 w-9 rounded-[var(--radius-pill)] border border-transparent text-foreground hover:border-white/12 hover:bg-white/34 dark:hover:border-white/10 dark:hover:bg-white/10">
|
||||
<Link href="/root" aria-label={t("root")}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
className="h-9 w-9 rounded-[var(--radius-pill)] border border-transparent text-foreground hover:border-white/12 hover:bg-white/34 dark:hover:border-white/10 dark:hover:bg-white/10"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setIsOpen((open) => !open)}
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="md:hidden"
|
||||
className="ml-auto h-11 w-11 rounded-[var(--radius-pill)] border border-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,255,255,0.11))] text-foreground shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.3),rgba(255,255,255,0.14))] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)] dark:hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.12),rgba(255,255,255,0.05))] lg:hidden"
|
||||
aria-label={isOpen ? t("closeMenu") : t("openMenu")}
|
||||
>
|
||||
{isOpen ? <X className="h-4 w-4" /> : <Menu className="h-4 w-4" />}
|
||||
<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>
|
||||
|
||||
{isOpen ? (
|
||||
<div className="border-t border-border/80 bg-background md:hidden">
|
||||
<Container className="py-4">
|
||||
<nav className="flex flex-col gap-3">
|
||||
<NavLinks isAdmin={isAdmin} onNavigate={() => setIsOpen(false)} />
|
||||
</nav>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<ThemeToggle ariaLabel={t("themeToggle")} />
|
||||
<LocaleToggle locale={locale} />
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
) : null}
|
||||
<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="pb-2 pt-3">
|
||||
<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-white/24 bg-[linear-gradient(180deg,rgba(255,255,255,0.24),rgba(255,255,255,0.1))] shadow-[inset_0_1px_0_rgba(255,255,255,0.46),inset_0_-1px_0_rgba(255,255,255,0.08),0_18px_44px_-28px_rgba(15,23,42,0.32)] backdrop-blur-[28px] dark:border-white/12 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.09),rgba(255,255,255,0.03))] dark:shadow-[inset_0_1px_0_rgba(255,255,255,0.12),inset_0_-1px_0_rgba(255,255,255,0.03),0_20px_48px_-28px_rgba(0,0,0,0.72)]"
|
||||
>
|
||||
<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-primary text-primary-foreground"
|
||||
: "text-foreground hover:bg-accent hover:text-accent-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-white/18 bg-[linear-gradient(180deg,rgba(255,255,255,0.14),rgba(255,255,255,0.06))] p-2 dark:border-white/8 dark:bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<LocaleToggle
|
||||
locale={locale}
|
||||
showLabel
|
||||
className="h-10 rounded-nested px-3 text-foreground hover:bg-white/34 dark:hover:bg-white/10"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{isAdmin ? (
|
||||
<Button asChild variant="ghost" size="icon" className="h-10 w-10 rounded-nested border border-white/18 bg-white/18 text-foreground hover:bg-white/34 dark:border-white/10 dark:bg-white/6 dark:hover:bg-white/10">
|
||||
<Link href="/root" aria-label={t("root")} onClick={() => setIsOpen(false)}>
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<ThemeToggle
|
||||
ariaLabel={t("themeToggle")}
|
||||
variant="ghost"
|
||||
className="h-10 w-10 rounded-nested border border-white/18 bg-white/18 text-foreground hover:bg-white/34 dark:border-white/10 dark:bg-white/6 dark:hover:bg-white/10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Container>
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user