);
}
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 (`/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]);
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]);
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]"
>
{/* 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"
>