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"; import { homePathForLang, isHomePathname, languageFromPathname, localizePath, stripLangPrefix, } from "../languageRoutes"; import { useLocalizedPath } from "../useLocalizedPath"; import { WalletButton } from "../wallet/WalletButton"; 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); const stripped = stripLangPrefix(pathname); switch (which) { case "home": return isHomePathname(pathname); case "browseAll": return stripped === "/browse" && !sp.has("sort"); case "categories": return ( stripped === "/categories" || (isHomePathname(pathname) && hash === "#categories") ); case "browseLatest": return stripped === "/browse" && sp.get("sort") === "latest"; case "browseRecommended": return stripped === "/official-recommendations"; case "browsePopular": return stripped === "/browse" && sp.get("sort") === "popular"; case "favorites": return ( stripped === "/favorites" || (isHomePathname(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(" "); } 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 [desktopSearchOpen, setDesktopSearchOpen] = useState(false); const [q, setQ] = useState(""); const menuRef = useRef(null); const mobileMenuButtonRef = useRef(null); const desktopMenuButtonRef = useRef(null); const desktopSearchRef = useRef(null); const desktopSearchPanelRef = useRef(null); const nav = useNavigate(); const lp = useLocalizedPath(); // Keep i18n state in sync with URL so deep links (`/ms/browse`) flip the // UI language even if the user navigated via address bar or shared link. useEffect(() => { const urlLang = languageFromPathname(pathname); if (urlLang !== lang) setLang(urlLang); // eslint-disable-next-line react-hooks/exhaustive-deps }, [pathname]); const na = (which: PublicNavWhich) => navIsActive(pathname, search, hash, which); const isHome = isHomePathname(pathname); const homePath = homePathForLang(lang); const changeLang = (nextLang: Lang) => { setLang(nextLang); if (isHome) { nav(homePathForLang(nextLang), { replace: true }); } else { // Preserve sub-path and query/hash; only swap the language prefix. const canonical = stripLangPrefix(pathname); nav(localizePath(canonical, nextLang) + search + hash, { replace: true, }); } }; // Routes that render a full-bleed asset stream and manage their own inner // width / padding via `MessageStream`. Both 全部资料 (/browse) and the // per-category view (/category/) reuse the same component, so they // need the same zero outer padding here — otherwise the category page's // bubbles render narrower than the all-resources page. const strippedPath = stripLangPrefix(pathname); const footerInContentFlow = strippedPath === "/browse" || strippedPath.startsWith("/category/"); // Current page name shown in the header brand slot (falls back to the brand). const pageTitle = usePageTitle(); // Warm the common stream views (全部资料 / 热门资料 / 最新) so tapping them // shows content immediately. The default "all" stream is the most common // destination (banners, Home cards) and fires right on mount so a fast tap // still hits a warm cache. Popular / latest stay deferred to idle time so // they don't compete with the current page on low-end phones. useEffect(() => { const base = { scope: { kind: "all" as const }, type: "all", q: "", lang }; prefetchPostStream({ ...base, sort: "" }); const jobs = [ () => 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, 300); return () => { window.clearTimeout(startTimer); window.clearTimeout(stepTimer); if (idleId) ric.cancelIdleCallback?.(idleId); }; }, [lang]); const popularHref = lp("/browse?sort=popular"); const goSearch = () => { const s = q.trim(); if (!s) return; nav(lp(`/browse?q=${encodeURIComponent(s)}`)); setOpen(false); setMobileSearchOpen(false); setDesktopSearchOpen(false); }; useEffect(() => { window.dispatchEvent( new CustomEvent(publicMenuOpenChangeEvent, { detail: open }), ); }, [open]); useEffect(() => { if (!desktopSearchOpen) return; const closeOnOutside = (event: MouseEvent | TouchEvent) => { const target = event.target as Node; if ( desktopSearchRef.current?.contains(target) || desktopSearchPanelRef.current?.contains(target) ) { return; } setDesktopSearchOpen(false); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === "Escape") setDesktopSearchOpen(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); }; }, [desktopSearchOpen]); 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]); // Lock background scroll while the full-screen mobile menu drawer is open. useEffect(() => { if (!open) 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); }; }, [open]); 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); setDesktopSearchOpen(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" >
{open ? (
setOpen(false)} />
) : null} {mobileSearchOpen ? ( setMobileSearchOpen(false)} /> ) : null} {desktopSearchOpen ? ( setDesktopSearchOpen(false)} variant="desktop" /> ) : null}
{outlet}
{stripLangPrefix(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} ); }