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 { usePageTitle } from "../components/PageTitleContext"; import { prefetchPostStream } from "../components/messageStream/hooks/usePostStream"; 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"; 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") ); 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(" "); } function mobileMenuNavClassName(active: boolean) { return `${navClassName(active)} w-fit justify-self-start`; } 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"; // Current page name shown in the header brand slot (falls back to the brand). const pageTitle = usePageTitle(); // Warm the common stream views (全部资料 / 热门资料 / 最新) in the background so // tapping them shows content immediately. Run one at a time, spaced out and // only while idle, so prefetching never competes with the current page or // janks low-end phones. Prefetch is JSON-only (no images). useEffect(() => { const base = { scope: { kind: "all" as const }, type: "all", q: "", lang }; const jobs = [ () => prefetchPostStream({ ...base, sort: "" }), () => prefetchPostStream({ ...base, sort: "popular" }), () => prefetchPostStream({ ...base, sort: "latest" }), ]; const ric = window as typeof window & { requestIdleCallback?: (cb: () => void) => number; cancelIdleCallback?: (id: number) => void; }; let i = 0; let stepTimer = 0; let idleId = 0; const runNext = () => { if (i >= jobs.length) return; jobs[i++](); stepTimer = window.setTimeout(schedule, 400); // space requests apart }; const schedule = () => { if (ric.requestIdleCallback) idleId = ric.requestIdleCallback(runNext); else stepTimer = window.setTimeout(runNext, 200); }; const startTimer = window.setTimeout(schedule, 600); return () => { window.clearTimeout(startTimer); window.clearTimeout(stepTimer); if (idleId) ric.cancelIdleCallback?.(idleId); }; }, [lang]); 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; // Opening the menu from the burger also closes the search overlay, whose // scroll-lock cleanup fires a programmatic scroll. Ignore scroll-to-close // for a brief window so that restore scroll doesn't shut the menu we just // opened; genuine user scrolls afterwards still close it. const openedAt = Date.now(); 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 = () => { if (Date.now() - openedAt < 250) return; 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]); // Lock background scroll while the mobile search overlay is open. // Uses the iOS-compatible position-fixed pattern so the underlying page // doesn't move at all (overflow:hidden alone is not enough on iOS Safari). useEffect(() => { if (!mobileSearchOpen) return; const scrollY = window.scrollY; const body = document.body; const prev = { position: body.style.position, top: body.style.top, left: body.style.left, right: body.style.right, width: body.style.width, }; body.style.position = "fixed"; body.style.top = `-${scrollY}px`; body.style.left = "0"; body.style.right = "0"; body.style.width = "100%"; return () => { body.style.position = prev.position; body.style.top = prev.top; body.style.left = prev.left; body.style.right = prev.right; body.style.width = prev.width; window.scrollTo(0, scrollY); }; }, [mobileSearchOpen]); return (
{/* Logo → home; page-name text → scroll to top of the current page. */} { if (isHome) { e.preventDefault(); window.scrollTo({ top: 0, behavior: "smooth" }); } }} className="shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]" >
{ setOpen(false); setMobileSearchOpen(false); }} />
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
{/* Logo → home; page-name text → scroll to top of the current page. */} { if (isHome) { e.preventDefault(); window.scrollTo({ top: 0, behavior: "smooth" }); } }} className="shrink-0 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg" >
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")}
) : null}
{mobileSearchOpen ? ( ) : null}
{outlet}
{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 activeSrc = `${NAVBAR_ICON_BASE}/${icon}-active.svg`; const inactiveSrc = `${NAVBAR_ICON_BASE}/${icon}-inactive.svg`; return ( {label} ); }