import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react"; import { AnimatePresence, m } from "framer-motion"; import { useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom"; import { pageTransition } from "../motion"; import { ArkLogoMark } from "../components/ArkLogoMark"; import { BackToTop } from "../components/BackToTop"; import { DocumentMeta } from "../components/DocumentMeta"; import { SearchPanel } from "../components/SearchPanel"; import { useI18n, type Lang } from "../i18n"; import { LANG_OPTIONS } from "../i18nLanguages"; type PublicNavWhich = | "home" | "browseAll" | "categories" | "browseLatest" | "browseRecommended" | "browsePopular" | "favorites" | "about"; function navIsActive( pathname: string, search: string, hash: string, which: PublicNavWhich, ): boolean { const sp = new URLSearchParams(search); switch (which) { case "home": return pathname === "/"; case "browseAll": return pathname === "/browse" && !sp.has("sort"); case "categories": return ( pathname === "/categories" || (pathname === "/" && hash === "#categories") ); case "browseLatest": return pathname === "/browse" && sp.get("sort") === "latest"; case "browseRecommended": return pathname === "/official-recommendations"; case "browsePopular": return pathname === "/browse" && sp.get("sort") === "popular"; case "favorites": return ( pathname === "/favorites" || (pathname === "/" && hash === "#favorites") ); case "about": return pathname === "/about"; default: return false; } } function navClassName(active: boolean) { return [ "relative shrink-0 rounded-sm px-2 py-2 text-[13px] font-medium leading-none whitespace-nowrap no-underline outline-none transition-colors", // Hover-only gold underline that slides in (resting/active look unchanged). "after:pointer-events-none after:absolute after:inset-x-2 after:bottom-1 after:h-[2px] after:origin-left after:scale-x-0 after:rounded-full after:bg-ark-gold after:transition-transform after:duration-300 hover:after:scale-x-100 motion-reduce:after:transition-none", "focus-visible:ring-2 focus-visible:ring-ark-gold/90 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg", active ? "text-ark-gold visited:text-ark-gold" : "text-[#d7d7dc] visited:text-[#d7d7dc] hover:text-ark-gold", ].join(" "); } const dropdownAnimationClass = "ark-header-popover-enter"; const headerMenuAnimationClass = "ark-header-menu-enter"; const publicMenuOpenChangeEvent = "ark:public-menu-open-change"; type LanguageDropdownProps = { lang: Lang; setLang: (lang: Lang) => void; ariaLabel: string; className?: string; }; function flagSrc(code: Lang): string { return `/assets/ark-library/flags/${code}.svg`; } function FlagIcon({ code, className = "", }: { code: Lang; className?: string; }) { return ( ); } function LanguageDropdown({ lang, setLang, ariaLabel, className = "", }: LanguageDropdownProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); const selected = LANG_OPTIONS.find((option) => option.code === lang); useEffect(() => { if (!open) return; const closeOnOutside = (event: MouseEvent | TouchEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", closeOnOutside); document.addEventListener("touchstart", closeOnOutside); window.addEventListener("keydown", closeOnEscape); return () => { document.removeEventListener("mousedown", closeOnOutside); document.removeEventListener("touchstart", closeOnOutside); window.removeEventListener("keydown", closeOnEscape); }; }, [open]); return (
{open ? (
{LANG_OPTIONS.map((option) => { const active = option.code === lang; return ( ); })}
) : null}
); } type MobileLanguageButtonProps = { lang: Lang; setLang: (lang: Lang) => void; ariaLabel: string; onOpen?: () => void; }; function MobileLanguageButton({ lang, setLang, ariaLabel, onOpen, }: MobileLanguageButtonProps) { const [open, setOpen] = useState(false); const rootRef = useRef(null); useEffect(() => { if (!open) return; const closeOnOutside = (event: MouseEvent | TouchEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; document.addEventListener("mousedown", closeOnOutside); document.addEventListener("touchstart", closeOnOutside); window.addEventListener("keydown", closeOnEscape); return () => { document.removeEventListener("mousedown", closeOnOutside); document.removeEventListener("touchstart", closeOnOutside); window.removeEventListener("keydown", closeOnEscape); }; }, [open]); return (
{open ? (
{LANG_OPTIONS.map((option) => { const active = option.code === lang; return ( ); })}
) : null}
); } export function PublicLayout() { const { t, lang, setLang } = useI18n(); const { pathname, search, hash } = useLocation(); const outlet = useOutlet(); const [open, setOpen] = useState(false); const [mobileSearchOpen, setMobileSearchOpen] = useState(false); const [q, setQ] = useState(""); const menuRef = useRef(null); const mobileMenuButtonRef = useRef(null); const desktopMenuButtonRef = useRef(null); const nav = useNavigate(); const na = (which: PublicNavWhich) => navIsActive(pathname, search, hash, which); const isHome = pathname === "/"; const footerInContentFlow = pathname === "/browse"; const popularHref = "/browse?sort=popular"; const goSearch = () => { const s = q.trim(); if (!s) return; nav(`/browse?q=${encodeURIComponent(s)}`); setOpen(false); setMobileSearchOpen(false); }; useEffect(() => { window.dispatchEvent( new CustomEvent(publicMenuOpenChangeEvent, { detail: open }), ); }, [open]); useEffect(() => { if (!open) return; const closeOnOutside = (event: MouseEvent | TouchEvent) => { const target = event.target as Node; if ( menuRef.current?.contains(target) || mobileMenuButtonRef.current?.contains(target) || desktopMenuButtonRef.current?.contains(target) ) { return; } setOpen(false); }; const closeOnScroll = () => setOpen(false); document.addEventListener("mousedown", closeOnOutside); document.addEventListener("touchstart", closeOnOutside); window.addEventListener("scroll", closeOnScroll); return () => { document.removeEventListener("mousedown", closeOnOutside); document.removeEventListener("touchstart", closeOnOutside); window.removeEventListener("scroll", closeOnScroll); }; }, [open]); return (
{t("brand")}
{ setOpen(false); setMobileSearchOpen(false); }} />
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
{t("brand")}
setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && goSearch()} placeholder={t("searchPlaceholder")} className="min-w-0 flex-1 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20]" />
{open ? (
setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && goSearch()} placeholder={t("searchPlaceholder")} className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]" />
setOpen(false)} > {t("all")} setOpen(false)} > {t("categories")} setOpen(false)} > {t("official")} setOpen(false)} > {t("latest")} setOpen(false)} > {t("popular")} setOpen(false)} > {t("favorites")} setOpen(false)} > {t("footerAbout")}
) : null}
{mobileSearchOpen ? ( ) : null}
{outlet}
{t("footerAbout")}
{pathname === "/browse" ? : null}
); } const NAVBAR_ICON_BASE = "/assets/ark-library/navbar"; function BottomNavIcon({ to, label, icon, active, }: { to: string; label: string; icon: "home" | "document" | "bookmark" | "update"; active: boolean; }) { const src = active ? `${NAVBAR_ICON_BASE}/${icon}-active.svg` : `${NAVBAR_ICON_BASE}/${icon}-inactive.svg`; return ( {label} ); }