2026-06-02 03:43:13 +08:00
|
|
|
import {
|
|
|
|
|
ChevronDown,
|
|
|
|
|
Heart,
|
|
|
|
|
Menu,
|
|
|
|
|
Search as SearchIcon,
|
|
|
|
|
X,
|
|
|
|
|
} from "lucide-react";
|
2026-05-29 11:50:27 +08:00
|
|
|
import { AnimatePresence, m } from "framer-motion";
|
2026-05-26 18:37:17 +08:00
|
|
|
import { useEffect, useRef, useState } from "react";
|
2026-05-29 12:49:22 +08:00
|
|
|
import { Link, useLocation, useNavigate, useOutlet } from "react-router-dom";
|
2026-05-29 11:50:27 +08:00
|
|
|
import { pageTransition } from "../motion";
|
2026-05-16 00:18:22 +08:00
|
|
|
import { ArkLogoMark } from "../components/ArkLogoMark";
|
2026-05-30 02:37:30 +08:00
|
|
|
import { usePageTitle } from "../components/PageTitleContext";
|
2026-05-30 02:44:53 +08:00
|
|
|
import { prefetchPostStream } from "../components/messageStream/hooks/usePostStream";
|
2026-05-29 11:50:27 +08:00
|
|
|
import { BackToTop } from "../components/BackToTop";
|
2026-05-28 22:28:23 +08:00
|
|
|
import { DocumentMeta } from "../components/DocumentMeta";
|
2026-05-28 18:51:55 +08:00
|
|
|
import { SearchPanel } from "../components/SearchPanel";
|
2026-05-16 00:18:22 +08:00
|
|
|
import { useI18n, type Lang } from "../i18n";
|
2026-05-26 07:36:53 +08:00
|
|
|
import { LANG_OPTIONS } from "../i18nLanguages";
|
2026-06-01 16:35:40 +08:00
|
|
|
import {
|
|
|
|
|
homePathForLang,
|
|
|
|
|
isHomePathname,
|
|
|
|
|
languageFromPathname,
|
2026-06-01 16:36:55 +08:00
|
|
|
localizePath,
|
2026-06-01 16:35:40 +08:00
|
|
|
stripLangPrefix,
|
|
|
|
|
} from "../languageRoutes";
|
|
|
|
|
import { useLocalizedPath } from "../useLocalizedPath";
|
2026-06-02 00:32:46 +08:00
|
|
|
import { WalletButton } from "../wallet/WalletButton";
|
2026-05-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
type PublicNavWhich =
|
|
|
|
|
| "home"
|
|
|
|
|
| "browseAll"
|
|
|
|
|
| "categories"
|
|
|
|
|
| "browseLatest"
|
|
|
|
|
| "browseRecommended"
|
|
|
|
|
| "browsePopular"
|
2026-05-30 00:59:06 +08:00
|
|
|
| "favorites";
|
2026-05-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
function navIsActive(
|
|
|
|
|
pathname: string,
|
|
|
|
|
search: string,
|
|
|
|
|
hash: string,
|
|
|
|
|
which: PublicNavWhich,
|
|
|
|
|
): boolean {
|
|
|
|
|
const sp = new URLSearchParams(search);
|
2026-06-01 16:35:40 +08:00
|
|
|
const stripped = stripLangPrefix(pathname);
|
2026-05-16 00:18:22 +08:00
|
|
|
switch (which) {
|
|
|
|
|
case "home":
|
2026-06-01 15:09:58 +08:00
|
|
|
return isHomePathname(pathname);
|
2026-05-16 00:18:22 +08:00
|
|
|
case "browseAll":
|
2026-06-01 16:35:40 +08:00
|
|
|
return stripped === "/browse" && !sp.has("sort");
|
2026-05-16 00:18:22 +08:00
|
|
|
case "categories":
|
2026-05-28 16:19:21 +08:00
|
|
|
return (
|
2026-06-01 16:35:40 +08:00
|
|
|
stripped === "/categories" ||
|
2026-06-01 15:09:58 +08:00
|
|
|
(isHomePathname(pathname) && hash === "#categories")
|
2026-05-28 16:19:21 +08:00
|
|
|
);
|
2026-05-16 00:18:22 +08:00
|
|
|
case "browseLatest":
|
2026-06-01 16:35:40 +08:00
|
|
|
return stripped === "/browse" && sp.get("sort") === "latest";
|
2026-05-16 00:18:22 +08:00
|
|
|
case "browseRecommended":
|
2026-06-01 16:35:40 +08:00
|
|
|
return stripped === "/official-recommendations";
|
2026-05-16 00:18:22 +08:00
|
|
|
case "browsePopular":
|
2026-06-01 16:35:40 +08:00
|
|
|
return stripped === "/browse" && sp.get("sort") === "popular";
|
2026-05-28 16:28:50 +08:00
|
|
|
case "favorites":
|
|
|
|
|
return (
|
2026-06-01 16:35:40 +08:00
|
|
|
stripped === "/favorites" ||
|
2026-06-01 15:09:58 +08:00
|
|
|
(isHomePathname(pathname) && hash === "#favorites")
|
2026-05-28 16:28:50 +08:00
|
|
|
);
|
2026-05-16 00:18:22 +08:00
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function navClassName(active: boolean) {
|
|
|
|
|
return [
|
2026-05-29 11:50:27 +08:00
|
|
|
"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",
|
2026-05-16 00:18:22 +08:00
|
|
|
"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(" ");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 22:52:46 +08:00
|
|
|
function mobileMenuNavClassName(active: boolean) {
|
|
|
|
|
return `${navClassName(active)} w-fit justify-self-start`;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-28 22:57:05 +08:00
|
|
|
const dropdownAnimationClass = "ark-header-popover-enter";
|
|
|
|
|
const headerMenuAnimationClass = "ark-header-menu-enter";
|
2026-05-28 22:01:17 +08:00
|
|
|
|
2026-05-28 22:41:23 +08:00
|
|
|
const publicMenuOpenChangeEvent = "ark:public-menu-open-change";
|
|
|
|
|
|
2026-05-26 18:37:17 +08:00
|
|
|
type LanguageDropdownProps = {
|
|
|
|
|
lang: Lang;
|
|
|
|
|
setLang: (lang: Lang) => void;
|
|
|
|
|
ariaLabel: string;
|
|
|
|
|
className?: string;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-28 10:27:47 +08:00
|
|
|
function flagSrc(code: Lang): string {
|
|
|
|
|
return `/assets/ark-library/flags/${code}.svg`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FlagIcon({
|
|
|
|
|
code,
|
|
|
|
|
className = "",
|
|
|
|
|
}: {
|
|
|
|
|
code: Lang;
|
|
|
|
|
className?: string;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<img
|
|
|
|
|
src={flagSrc(code)}
|
|
|
|
|
alt=""
|
|
|
|
|
aria-hidden
|
|
|
|
|
draggable={false}
|
|
|
|
|
decoding="async"
|
|
|
|
|
className={`shrink-0 rounded-full object-cover ${className}`}
|
|
|
|
|
/>
|
|
|
|
|
);
|
2026-05-27 11:00:52 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-26 18:37:17 +08:00
|
|
|
function LanguageDropdown({
|
|
|
|
|
lang,
|
|
|
|
|
setLang,
|
|
|
|
|
ariaLabel,
|
|
|
|
|
className = "",
|
|
|
|
|
}: LanguageDropdownProps) {
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
const rootRef = useRef<HTMLDivElement>(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 (
|
|
|
|
|
<div ref={rootRef} className={`relative ${className}`}>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen((value) => !value)}
|
|
|
|
|
className="flex h-full w-full items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 text-sm text-neutral-200 shadow-inner outline-none transition hover:border-ark-gold/50 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
|
|
|
|
aria-label={ariaLabel}
|
|
|
|
|
aria-haspopup="listbox"
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
>
|
2026-05-28 10:27:47 +08:00
|
|
|
<FlagIcon code={lang} className="h-5 w-5" />
|
2026-05-26 18:37:17 +08:00
|
|
|
<span className="min-w-0 flex-1 truncate text-left">
|
|
|
|
|
{selected?.label ?? lang}
|
|
|
|
|
</span>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
size={16}
|
|
|
|
|
className={`shrink-0 text-neutral-400 transition ${
|
|
|
|
|
open ? "rotate-180 text-ark-gold" : ""
|
|
|
|
|
}`}
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open ? (
|
|
|
|
|
<div
|
2026-05-28 22:01:17 +08:00
|
|
|
className={`${dropdownAnimationClass} absolute left-0 right-0 top-[calc(100%+0.5rem)] z-50 overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl`}
|
2026-05-26 18:37:17 +08:00
|
|
|
role="listbox"
|
|
|
|
|
aria-label={ariaLabel}
|
|
|
|
|
>
|
|
|
|
|
{LANG_OPTIONS.map((option) => {
|
|
|
|
|
const active = option.code === lang;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={option.code}
|
|
|
|
|
type="button"
|
|
|
|
|
role="option"
|
|
|
|
|
aria-selected={active}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setLang(option.code as Lang);
|
|
|
|
|
setOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition ${
|
|
|
|
|
active
|
|
|
|
|
? "bg-ark-gold/10 text-ark-gold2"
|
|
|
|
|
: "text-neutral-200 hover:bg-white/10 hover:text-white"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-05-28 10:27:47 +08:00
|
|
|
<FlagIcon code={option.code} className="h-5 w-5" />
|
2026-05-26 18:37:17 +08:00
|
|
|
<span className="truncate font-medium">{option.label}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-27 11:00:52 +08:00
|
|
|
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<HTMLDivElement>(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 (
|
|
|
|
|
<div ref={rootRef} className="relative shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
onOpen?.();
|
|
|
|
|
setOpen((value) => !value);
|
|
|
|
|
}}
|
2026-05-28 10:27:47 +08:00
|
|
|
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-[#191921] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
2026-05-27 11:00:52 +08:00
|
|
|
aria-label={ariaLabel}
|
|
|
|
|
aria-haspopup="listbox"
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
>
|
2026-05-28 10:27:47 +08:00
|
|
|
<FlagIcon code={lang} className="h-7 w-7" />
|
2026-05-27 11:00:52 +08:00
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open ? (
|
|
|
|
|
<div
|
2026-05-28 22:01:17 +08:00
|
|
|
className={`${dropdownAnimationClass} absolute right-0 top-[calc(100%+0.5rem)] z-50 w-44 overflow-hidden rounded-2xl border border-white/10 bg-[#1c1c21]/95 p-1.5 shadow-2xl shadow-black/70 ring-1 ring-ark-line/80 backdrop-blur-xl`}
|
2026-05-27 11:00:52 +08:00
|
|
|
role="listbox"
|
|
|
|
|
aria-label={ariaLabel}
|
|
|
|
|
>
|
|
|
|
|
{LANG_OPTIONS.map((option) => {
|
|
|
|
|
const active = option.code === lang;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={option.code}
|
|
|
|
|
type="button"
|
|
|
|
|
role="option"
|
|
|
|
|
aria-selected={active}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setLang(option.code as Lang);
|
|
|
|
|
setOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
className={`flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm transition ${
|
|
|
|
|
active
|
|
|
|
|
? "bg-ark-gold/10 text-ark-gold2"
|
|
|
|
|
: "text-neutral-200 hover:bg-white/10 hover:text-white"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-05-28 10:27:47 +08:00
|
|
|
<FlagIcon code={option.code} className="h-5 w-5" />
|
2026-05-27 11:00:52 +08:00
|
|
|
<span className="truncate font-medium">{option.label}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 00:18:22 +08:00
|
|
|
export function PublicLayout() {
|
|
|
|
|
const { t, lang, setLang } = useI18n();
|
|
|
|
|
const { pathname, search, hash } = useLocation();
|
2026-05-29 11:50:27 +08:00
|
|
|
const outlet = useOutlet();
|
2026-05-16 00:18:22 +08:00
|
|
|
const [open, setOpen] = useState(false);
|
2026-05-27 11:00:52 +08:00
|
|
|
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
|
2026-05-31 02:55:04 +08:00
|
|
|
const [desktopSearchOpen, setDesktopSearchOpen] = useState(false);
|
2026-05-16 00:18:22 +08:00
|
|
|
const [q, setQ] = useState("");
|
2026-05-28 22:28:23 +08:00
|
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const mobileMenuButtonRef = useRef<HTMLButtonElement>(null);
|
|
|
|
|
const desktopMenuButtonRef = useRef<HTMLButtonElement>(null);
|
2026-05-31 02:55:04 +08:00
|
|
|
const desktopSearchRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const desktopSearchPanelRef = useRef<HTMLDivElement>(null);
|
2026-05-16 00:18:22 +08:00
|
|
|
const nav = useNavigate();
|
2026-06-01 16:35:40 +08:00
|
|
|
const lp = useLocalizedPath();
|
|
|
|
|
|
|
|
|
|
// Keep i18n state in sync with URL so deep links (`/malay/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]);
|
2026-05-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
const na = (which: PublicNavWhich) =>
|
2026-05-28 16:49:30 +08:00
|
|
|
navIsActive(pathname, search, hash, which);
|
2026-06-01 15:09:58 +08:00
|
|
|
const isHome = isHomePathname(pathname);
|
|
|
|
|
const homePath = homePathForLang(lang);
|
|
|
|
|
const changeLang = (nextLang: Lang) => {
|
|
|
|
|
setLang(nextLang);
|
2026-06-01 16:36:55 +08:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-06-01 15:09:58 +08:00
|
|
|
};
|
2026-06-01 16:35:40 +08:00
|
|
|
const footerInContentFlow = stripLangPrefix(pathname) === "/browse";
|
2026-05-30 02:37:30 +08:00
|
|
|
// Current page name shown in the header brand slot (falls back to the brand).
|
|
|
|
|
const pageTitle = usePageTitle();
|
2026-05-30 02:44:53 +08:00
|
|
|
|
2026-06-02 11:30:47 +08:00
|
|
|
// 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.
|
2026-05-30 02:44:53 +08:00
|
|
|
useEffect(() => {
|
2026-05-30 02:46:26 +08:00
|
|
|
const base = { scope: { kind: "all" as const }, type: "all", q: "", lang };
|
2026-06-02 11:30:47 +08:00
|
|
|
prefetchPostStream({ ...base, sort: "" });
|
|
|
|
|
|
2026-05-30 02:46:26 +08:00
|
|
|
const jobs = [
|
|
|
|
|
() => prefetchPostStream({ ...base, sort: "popular" }),
|
|
|
|
|
() => prefetchPostStream({ ...base, sort: "latest" }),
|
|
|
|
|
];
|
2026-05-30 02:44:53 +08:00
|
|
|
const ric = window as typeof window & {
|
|
|
|
|
requestIdleCallback?: (cb: () => void) => number;
|
|
|
|
|
cancelIdleCallback?: (id: number) => void;
|
|
|
|
|
};
|
2026-05-30 02:46:26 +08:00
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-02 11:30:47 +08:00
|
|
|
const startTimer = window.setTimeout(schedule, 300);
|
2026-05-30 02:46:26 +08:00
|
|
|
return () => {
|
|
|
|
|
window.clearTimeout(startTimer);
|
|
|
|
|
window.clearTimeout(stepTimer);
|
|
|
|
|
if (idleId) ric.cancelIdleCallback?.(idleId);
|
|
|
|
|
};
|
2026-05-30 02:44:53 +08:00
|
|
|
}, [lang]);
|
2026-06-01 16:35:40 +08:00
|
|
|
const popularHref = lp("/browse?sort=popular");
|
2026-05-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
const goSearch = () => {
|
|
|
|
|
const s = q.trim();
|
|
|
|
|
if (!s) return;
|
2026-06-01 16:35:40 +08:00
|
|
|
nav(lp(`/browse?q=${encodeURIComponent(s)}`));
|
2026-05-16 00:18:22 +08:00
|
|
|
setOpen(false);
|
2026-05-27 11:00:52 +08:00
|
|
|
setMobileSearchOpen(false);
|
2026-05-31 02:55:04 +08:00
|
|
|
setDesktopSearchOpen(false);
|
2026-05-16 00:18:22 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-28 22:41:23 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent<boolean>(publicMenuOpenChangeEvent, { detail: open }),
|
|
|
|
|
);
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
2026-05-31 02:55:04 +08:00
|
|
|
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]);
|
|
|
|
|
|
2026-05-28 22:28:23 +08:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
2026-05-30 03:04:09 +08:00
|
|
|
// 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();
|
|
|
|
|
|
2026-05-28 22:28:23 +08:00
|
|
|
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);
|
|
|
|
|
};
|
2026-05-30 03:04:09 +08:00
|
|
|
const closeOnScroll = () => {
|
|
|
|
|
if (Date.now() - openedAt < 250) return;
|
|
|
|
|
setOpen(false);
|
|
|
|
|
};
|
2026-05-28 22:28:23 +08:00
|
|
|
|
|
|
|
|
document.addEventListener("mousedown", closeOnOutside);
|
|
|
|
|
document.addEventListener("touchstart", closeOnOutside);
|
2026-05-28 22:41:23 +08:00
|
|
|
window.addEventListener("scroll", closeOnScroll);
|
2026-05-28 22:28:23 +08:00
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener("mousedown", closeOnOutside);
|
|
|
|
|
document.removeEventListener("touchstart", closeOnOutside);
|
2026-05-28 22:41:23 +08:00
|
|
|
window.removeEventListener("scroll", closeOnScroll);
|
2026-05-28 22:28:23 +08:00
|
|
|
};
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
2026-05-30 01:02:18 +08:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-05-16 00:18:22 +08:00
|
|
|
return (
|
2026-05-30 21:27:48 +08:00
|
|
|
<div className="flex min-h-[100dvh] flex-col bg-ark-bg">
|
2026-05-28 22:28:23 +08:00
|
|
|
<DocumentMeta />
|
2026-05-30 18:14:58 +08:00
|
|
|
<header className="sticky top-0 z-40 select-none bg-[#08070c] backdrop-blur-md md:border-b md:border-ark-line md:bg-ark-nav/98">
|
2026-05-28 15:31:45 +08:00
|
|
|
<div className="flex h-[64px] items-center justify-between bg-[#08070c] px-4 py-3 md:hidden">
|
2026-05-30 02:37:30 +08:00
|
|
|
<div className="flex h-8 min-w-0 shrink items-center gap-2 text-[20px] font-black leading-5 tracking-tight text-ark-gold">
|
|
|
|
|
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
|
|
|
|
<Link
|
2026-06-01 15:09:58 +08:00
|
|
|
to={homePath}
|
2026-05-30 02:37:30 +08:00
|
|
|
aria-label={t("brand")}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
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]"
|
|
|
|
|
>
|
|
|
|
|
<ArkLogoMark className="h-8 w-8 shrink-0" />
|
|
|
|
|
</Link>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
|
|
|
|
className="truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
|
|
|
|
>
|
|
|
|
|
{pageTitle || t("brand")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-05-27 11:00:52 +08:00
|
|
|
|
|
|
|
|
<div className="flex h-[40px] w-[136px] shrink-0 items-center gap-[8px]">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setOpen(false);
|
2026-05-31 02:55:04 +08:00
|
|
|
setDesktopSearchOpen(false);
|
2026-05-27 11:00:52 +08:00
|
|
|
setMobileSearchOpen((value) => !value);
|
|
|
|
|
}}
|
|
|
|
|
className="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-[#191921] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
|
|
|
|
aria-label={t("search")}
|
|
|
|
|
aria-expanded={mobileSearchOpen}
|
|
|
|
|
>
|
|
|
|
|
<img
|
|
|
|
|
src="/assets/ark-library/header-search.svg"
|
|
|
|
|
alt=""
|
|
|
|
|
className="h-6 w-6"
|
|
|
|
|
width={24}
|
|
|
|
|
height={24}
|
|
|
|
|
aria-hidden
|
|
|
|
|
draggable={false}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
<MobileLanguageButton
|
|
|
|
|
lang={lang}
|
2026-06-01 15:09:58 +08:00
|
|
|
setLang={changeLang}
|
2026-05-27 11:00:52 +08:00
|
|
|
ariaLabel={t("langLabel")}
|
|
|
|
|
onOpen={() => {
|
|
|
|
|
setOpen(false);
|
2026-05-31 02:55:04 +08:00
|
|
|
setDesktopSearchOpen(false);
|
2026-05-27 11:00:52 +08:00
|
|
|
setMobileSearchOpen(false);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<button
|
2026-05-28 22:28:23 +08:00
|
|
|
ref={mobileMenuButtonRef}
|
2026-05-27 11:00:52 +08:00
|
|
|
type="button"
|
|
|
|
|
className="inline-flex h-[40px] w-[40px] shrink-0 items-center justify-center rounded-full bg-[#191921] text-[#a8a9ae] outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-[#08070c]"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setMobileSearchOpen(false);
|
2026-05-31 02:55:04 +08:00
|
|
|
setDesktopSearchOpen(false);
|
2026-05-27 11:00:52 +08:00
|
|
|
setOpen((v) => !v);
|
|
|
|
|
}}
|
|
|
|
|
aria-label="menu"
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
>
|
|
|
|
|
{open ? (
|
|
|
|
|
<X size={24} strokeWidth={2.4} />
|
|
|
|
|
) : (
|
|
|
|
|
<img
|
|
|
|
|
src="/assets/ark-library/header-menu.svg"
|
|
|
|
|
alt=""
|
|
|
|
|
className="h-6 w-6"
|
|
|
|
|
width={24}
|
|
|
|
|
height={24}
|
|
|
|
|
aria-hidden
|
|
|
|
|
draggable={false}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-31 18:35:20 +08:00
|
|
|
<div className="mx-auto hidden max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:block md:px-9 xl:px-6">
|
2026-05-16 00:18:22 +08:00
|
|
|
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
2026-05-31 03:36:54 +08:00
|
|
|
<div className="flex h-10 items-center gap-2 min-[1000px]:gap-0 lg:gap-4">
|
2026-05-30 02:37:30 +08:00
|
|
|
<div className="flex min-w-0 shrink items-center gap-2.5 text-xl font-bold tracking-wide text-ark-gold">
|
|
|
|
|
{/* Logo → home; page-name text → scroll to top of the current page. */}
|
|
|
|
|
<Link
|
2026-06-01 15:09:58 +08:00
|
|
|
to={homePath}
|
2026-05-30 02:37:30 +08:00
|
|
|
aria-label={t("brand")}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
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"
|
|
|
|
|
>
|
|
|
|
|
<ArkLogoMark className="h-10 w-10 shrink-0" />
|
|
|
|
|
</Link>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
|
2026-06-01 15:42:00 +08:00
|
|
|
className="max-w-[14rem] truncate rounded-sm text-left text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg sm:inline"
|
2026-05-30 02:37:30 +08:00
|
|
|
>
|
|
|
|
|
{pageTitle || t("brand")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-05-16 00:18:22 +08:00
|
|
|
|
|
|
|
|
<nav
|
2026-05-31 03:36:54 +08:00
|
|
|
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-0.5 min-[1000px]:flex lg:gap-5"
|
2026-05-16 00:18:22 +08:00
|
|
|
aria-label={t("mainNav")}
|
|
|
|
|
>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/browse")}
|
2026-05-16 00:18:22 +08:00
|
|
|
className={navClassName(na("browseAll"))}
|
|
|
|
|
aria-current={na("browseAll") ? "page" : undefined}
|
|
|
|
|
>
|
|
|
|
|
{t("all")}
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/categories")}
|
2026-05-16 00:18:22 +08:00
|
|
|
className={navClassName(na("categories"))}
|
|
|
|
|
aria-current={na("categories") ? "page" : undefined}
|
|
|
|
|
>
|
|
|
|
|
{t("categories")}
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/official-recommendations")}
|
2026-05-16 00:18:22 +08:00
|
|
|
className={navClassName(na("browseRecommended"))}
|
|
|
|
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
|
|
|
|
>
|
|
|
|
|
{t("official")}
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/browse?sort=latest")}
|
2026-05-28 15:31:45 +08:00
|
|
|
className={navClassName(na("browseLatest"))}
|
|
|
|
|
aria-current={na("browseLatest") ? "page" : undefined}
|
|
|
|
|
>
|
|
|
|
|
{t("latest")}
|
|
|
|
|
</Link>
|
2026-05-31 02:04:26 +08:00
|
|
|
<Link
|
|
|
|
|
to={popularHref}
|
|
|
|
|
className={navClassName(na("browsePopular"))}
|
|
|
|
|
aria-current={na("browsePopular") ? "page" : undefined}
|
|
|
|
|
>
|
|
|
|
|
{t("popular")}
|
|
|
|
|
</Link>
|
2026-05-16 00:18:22 +08:00
|
|
|
</nav>
|
|
|
|
|
|
2026-05-31 03:36:54 +08:00
|
|
|
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1000px]:flex-none">
|
2026-06-02 03:43:13 +08:00
|
|
|
<Link
|
|
|
|
|
to={lp("/favorites")}
|
|
|
|
|
aria-label={t("favorites")}
|
|
|
|
|
title={t("favorites")}
|
|
|
|
|
aria-current={na("favorites") ? "page" : undefined}
|
|
|
|
|
className={`hidden h-10 w-10 shrink-0 items-center justify-center rounded-full border bg-[#1a1b20] outline-none transition hover:border-ark-gold/50 hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:inline-flex ${
|
|
|
|
|
na("favorites")
|
|
|
|
|
? "border-ark-gold/70 text-ark-gold"
|
|
|
|
|
: "border-ark-line text-neutral-200"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<Heart size={18} strokeWidth={2} />
|
|
|
|
|
</Link>
|
2026-05-31 03:36:54 +08:00
|
|
|
<div ref={desktopSearchRef} className="hidden md:block">
|
2026-05-28 18:51:55 +08:00
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-31 03:21:23 +08:00
|
|
|
onClick={() => setDesktopSearchOpen(true)}
|
2026-05-31 03:36:54 +08:00
|
|
|
className={`inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border bg-[#1a1b20] outline-none transition hover:border-ark-gold/50 hover:text-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
|
|
|
|
desktopSearchOpen
|
|
|
|
|
? "border-ark-gold/70 text-ark-gold"
|
|
|
|
|
: "border-ark-line text-neutral-200"
|
|
|
|
|
}`}
|
2026-05-28 18:51:55 +08:00
|
|
|
aria-label={t("searchNow")}
|
2026-05-31 03:21:23 +08:00
|
|
|
aria-expanded={desktopSearchOpen}
|
2026-05-28 18:51:55 +08:00
|
|
|
>
|
2026-05-31 03:36:54 +08:00
|
|
|
<SearchIcon size={18} />
|
2026-05-28 18:51:55 +08:00
|
|
|
</button>
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
2026-05-26 18:37:17 +08:00
|
|
|
<LanguageDropdown
|
|
|
|
|
lang={lang}
|
2026-06-01 15:09:58 +08:00
|
|
|
setLang={changeLang}
|
2026-05-26 18:37:17 +08:00
|
|
|
ariaLabel={t("langLabel")}
|
|
|
|
|
className="hidden h-10 w-36 md:block lg:w-40"
|
|
|
|
|
/>
|
2026-06-02 00:32:46 +08:00
|
|
|
<div className="hidden md:block">
|
|
|
|
|
<WalletButton />
|
|
|
|
|
</div>
|
2026-05-16 00:18:22 +08:00
|
|
|
<button
|
2026-05-28 22:28:23 +08:00
|
|
|
ref={desktopMenuButtonRef}
|
2026-05-16 00:18:22 +08:00
|
|
|
type="button"
|
2026-05-31 03:36:54 +08:00
|
|
|
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg min-[1000px]:hidden"
|
2026-05-31 02:55:04 +08:00
|
|
|
onClick={() => {
|
|
|
|
|
setDesktopSearchOpen(false);
|
|
|
|
|
setOpen((v) => !v);
|
|
|
|
|
}}
|
2026-05-16 00:18:22 +08:00
|
|
|
aria-label="menu"
|
|
|
|
|
>
|
|
|
|
|
{open ? <X size={18} /> : <Menu size={18} />}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{open ? (
|
2026-05-28 22:01:17 +08:00
|
|
|
<div
|
2026-05-28 22:28:23 +08:00
|
|
|
ref={menuRef}
|
2026-05-31 03:36:54 +08:00
|
|
|
className={`${headerMenuAnimationClass} fixed inset-x-0 top-[64px] z-50 grid gap-2 bg-[#08070c] px-4 py-3 shadow-2xl shadow-black/50 min-[440px]:px-5 sm:px-6 md:top-[70px] md:px-9 min-[1000px]:hidden`}
|
2026-05-28 22:01:17 +08:00
|
|
|
>
|
2026-05-16 00:18:22 +08:00
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/browse")}
|
2026-05-30 22:52:46 +08:00
|
|
|
className={mobileMenuNavClassName(na("browseAll"))}
|
2026-05-16 00:18:22 +08:00
|
|
|
aria-current={na("browseAll") ? "page" : undefined}
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
{t("all")}
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/categories")}
|
2026-05-30 22:52:46 +08:00
|
|
|
className={mobileMenuNavClassName(na("categories"))}
|
2026-05-16 00:18:22 +08:00
|
|
|
aria-current={na("categories") ? "page" : undefined}
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
{t("categories")}
|
|
|
|
|
</Link>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/official-recommendations")}
|
2026-05-30 22:52:46 +08:00
|
|
|
className={mobileMenuNavClassName(na("browseRecommended"))}
|
2026-05-28 15:31:45 +08:00
|
|
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
2026-05-16 00:18:22 +08:00
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
>
|
2026-05-28 15:31:45 +08:00
|
|
|
{t("official")}
|
2026-05-16 00:18:22 +08:00
|
|
|
</Link>
|
|
|
|
|
<Link
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/browse?sort=latest")}
|
2026-05-30 22:52:46 +08:00
|
|
|
className={mobileMenuNavClassName(na("browseLatest"))}
|
2026-05-28 15:31:45 +08:00
|
|
|
aria-current={na("browseLatest") ? "page" : undefined}
|
2026-05-16 00:18:22 +08:00
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
>
|
2026-05-28 15:31:45 +08:00
|
|
|
{t("latest")}
|
2026-05-16 00:18:22 +08:00
|
|
|
</Link>
|
2026-05-31 02:04:26 +08:00
|
|
|
<Link
|
|
|
|
|
to={popularHref}
|
|
|
|
|
className={mobileMenuNavClassName(na("browsePopular"))}
|
|
|
|
|
aria-current={na("browsePopular") ? "page" : undefined}
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
{t("popular")}
|
|
|
|
|
</Link>
|
2026-06-02 03:43:13 +08:00
|
|
|
<Link
|
|
|
|
|
to={lp("/favorites")}
|
|
|
|
|
className={`${mobileMenuNavClassName(na("favorites"))} flex items-center gap-2`}
|
|
|
|
|
aria-current={na("favorites") ? "page" : undefined}
|
|
|
|
|
onClick={() => setOpen(false)}
|
|
|
|
|
>
|
|
|
|
|
<Heart size={16} strokeWidth={2} />
|
|
|
|
|
{t("favorites")}
|
|
|
|
|
</Link>
|
2026-06-02 00:32:46 +08:00
|
|
|
<div className="mt-2 w-full max-w-xs">
|
2026-06-02 00:45:58 +08:00
|
|
|
<WalletButton compact onOpenLogin={() => setOpen(false)} />
|
2026-06-02 00:32:46 +08:00
|
|
|
</div>
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</header>
|
|
|
|
|
|
2026-05-28 18:51:55 +08:00
|
|
|
{mobileSearchOpen ? (
|
|
|
|
|
<SearchPanel
|
|
|
|
|
lang={lang}
|
|
|
|
|
t={t}
|
|
|
|
|
query={q}
|
|
|
|
|
onQueryChange={setQ}
|
|
|
|
|
onSearch={goSearch}
|
2026-05-31 03:10:56 +08:00
|
|
|
onResultClick={() => setMobileSearchOpen(false)}
|
2026-05-28 18:51:55 +08:00
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-05-31 02:55:04 +08:00
|
|
|
{desktopSearchOpen ? (
|
|
|
|
|
<SearchPanel
|
|
|
|
|
panelRef={desktopSearchPanelRef}
|
|
|
|
|
lang={lang}
|
|
|
|
|
t={t}
|
|
|
|
|
query={q}
|
|
|
|
|
onQueryChange={setQ}
|
|
|
|
|
onSearch={goSearch}
|
2026-05-31 03:10:56 +08:00
|
|
|
onResultClick={() => setDesktopSearchOpen(false)}
|
2026-05-31 02:55:04 +08:00
|
|
|
variant="desktop"
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-05-28 10:36:38 +08:00
|
|
|
<main
|
2026-05-30 21:30:08 +08:00
|
|
|
className={`mx-auto w-full max-w-[1280px] max-md:pb-[calc(68px+max(env(safe-area-inset-bottom),0px)+1rem)] ${
|
2026-05-28 15:31:45 +08:00
|
|
|
isHome
|
|
|
|
|
? "flex-1 px-0 pb-6 pt-0 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
|
|
|
|
: footerInContentFlow
|
2026-05-31 02:55:04 +08:00
|
|
|
? "flex-1 px-0 pb-0 pt-0 md:px-9 xl:px-0"
|
2026-05-28 15:31:45 +08:00
|
|
|
: "flex-1 px-4 pb-6 pt-6 min-[440px]:px-5 sm:px-6 md:px-9 md:pb-10 md:pt-10 xl:px-0"
|
2026-05-28 10:36:38 +08:00
|
|
|
}`}
|
|
|
|
|
>
|
2026-05-29 11:50:27 +08:00
|
|
|
<AnimatePresence mode="wait" initial={false}>
|
|
|
|
|
<m.div
|
|
|
|
|
key={`${pathname}${search}`}
|
|
|
|
|
variants={pageTransition}
|
|
|
|
|
initial="initial"
|
|
|
|
|
animate="enter"
|
|
|
|
|
exit="exit"
|
|
|
|
|
>
|
|
|
|
|
{outlet}
|
|
|
|
|
</m.div>
|
|
|
|
|
</AnimatePresence>
|
2026-05-16 00:18:22 +08:00
|
|
|
</main>
|
|
|
|
|
|
2026-05-30 21:27:48 +08:00
|
|
|
<nav className="fixed inset-x-0 bottom-0 z-40 select-none bg-[#0C0D0F]/95 pb-[max(env(safe-area-inset-bottom),0px)] backdrop-blur md:hidden">
|
2026-06-01 23:00:28 +08:00
|
|
|
<div className="grid h-[68px] grid-cols-3 gap-3 px-5 py-[10px] text-center text-[11px] leading-[17.6px]">
|
2026-05-16 00:18:22 +08:00
|
|
|
<BottomNavIcon
|
2026-06-01 15:09:58 +08:00
|
|
|
to={homePath}
|
2026-05-16 00:18:22 +08:00
|
|
|
label={t("home")}
|
|
|
|
|
icon="home"
|
2026-06-01 15:09:58 +08:00
|
|
|
active={isHome}
|
2026-05-16 00:18:22 +08:00
|
|
|
/>
|
|
|
|
|
<BottomNavIcon
|
2026-06-01 16:35:40 +08:00
|
|
|
to={lp("/browse")}
|
2026-05-16 00:18:22 +08:00
|
|
|
label={t("all")}
|
|
|
|
|
icon="document"
|
|
|
|
|
active={
|
2026-06-01 16:35:40 +08:00
|
|
|
stripLangPrefix(pathname) === "/browse" &&
|
|
|
|
|
!new URLSearchParams(search).get("sort")
|
2026-05-16 00:18:22 +08:00
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
<BottomNavIcon
|
2026-05-28 16:37:00 +08:00
|
|
|
to={popularHref}
|
2026-05-28 16:19:21 +08:00
|
|
|
label={t("popular")}
|
2026-05-16 00:18:22 +08:00
|
|
|
icon="update"
|
2026-05-28 18:52:01 +08:00
|
|
|
active={na("browsePopular")}
|
2026-05-16 00:18:22 +08:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</nav>
|
2026-05-29 11:50:27 +08:00
|
|
|
|
2026-06-01 16:35:40 +08:00
|
|
|
{stripLangPrefix(pathname) === "/browse" ? <BackToTop /> : null}
|
2026-05-16 00:18:22 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const NAVBAR_ICON_BASE = "/assets/ark-library/navbar";
|
|
|
|
|
|
|
|
|
|
function BottomNavIcon({
|
|
|
|
|
to,
|
|
|
|
|
label,
|
|
|
|
|
icon,
|
|
|
|
|
active,
|
|
|
|
|
}: {
|
|
|
|
|
to: string;
|
|
|
|
|
label: string;
|
2026-05-28 10:36:38 +08:00
|
|
|
icon: "home" | "document" | "bookmark" | "update";
|
2026-05-16 00:18:22 +08:00
|
|
|
active: boolean;
|
|
|
|
|
}) {
|
2026-05-30 21:48:14 +08:00
|
|
|
const activeSrc = `${NAVBAR_ICON_BASE}/${icon}-active.svg`;
|
|
|
|
|
const inactiveSrc = `${NAVBAR_ICON_BASE}/${icon}-inactive.svg`;
|
2026-05-16 00:18:22 +08:00
|
|
|
return (
|
|
|
|
|
<Link
|
|
|
|
|
to={to}
|
|
|
|
|
className={[
|
2026-05-28 10:36:38 +08:00
|
|
|
"flex min-w-0 flex-col items-center gap-1 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
|
|
|
|
|
active ? "text-ark-gold" : "text-[#908F92]",
|
2026-05-16 00:18:22 +08:00
|
|
|
].join(" ")}
|
|
|
|
|
>
|
2026-05-30 21:48:14 +08:00
|
|
|
<span className="relative h-6 w-6" aria-hidden>
|
|
|
|
|
<img
|
|
|
|
|
src={inactiveSrc}
|
|
|
|
|
alt=""
|
|
|
|
|
className={`absolute inset-0 h-6 w-6 object-contain ${
|
|
|
|
|
active ? "opacity-0" : "opacity-100"
|
|
|
|
|
}`}
|
|
|
|
|
width={24}
|
|
|
|
|
height={24}
|
|
|
|
|
loading="eager"
|
|
|
|
|
decoding="sync"
|
|
|
|
|
/>
|
|
|
|
|
<img
|
|
|
|
|
src={activeSrc}
|
|
|
|
|
alt=""
|
|
|
|
|
className={`absolute inset-0 h-6 w-6 object-contain ${
|
|
|
|
|
active ? "opacity-100" : "opacity-0"
|
|
|
|
|
}`}
|
|
|
|
|
width={24}
|
|
|
|
|
height={24}
|
|
|
|
|
loading="eager"
|
|
|
|
|
decoding="sync"
|
|
|
|
|
/>
|
|
|
|
|
</span>
|
2026-05-16 00:18:22 +08:00
|
|
|
<span className="leading-tight">{label}</span>
|
|
|
|
|
</Link>
|
|
|
|
|
);
|
|
|
|
|
}
|