Files
Arkie-Library-Frontend/src/layouts/PublicLayout.tsx

622 lines
22 KiB
TypeScript
Raw Normal View History

import { ChevronDown, Menu, Search as SearchIcon, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
2026-05-16 00:18:22 +08:00
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { ArkLogoMark } from "../components/ArkLogoMark";
import { useI18n, type Lang } from "../i18n";
import { LANG_OPTIONS } from "../i18nLanguages";
2026-05-16 00:18:22 +08:00
type PublicNavWhich =
| "home"
| "browseAll"
| "categories"
| "browseLatest"
| "browseRecommended"
| "browsePopular"
| "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 === "/" && hash === "#categories";
case "browseLatest":
2026-05-28 15:31:45 +08:00
return pathname === "/" && hash === "#latest";
2026-05-16 00:18:22 +08:00
case "browseRecommended":
2026-05-28 15:31:45 +08:00
return pathname === "/" && hash === "#official";
2026-05-16 00:18:22 +08:00
case "browsePopular":
2026-05-28 15:31:45 +08:00
return pathname === "/" && hash === "#popular";
2026-05-16 00:18:22 +08:00
case "about":
return pathname === "/about";
default:
return false;
}
}
function navClassName(active: boolean) {
return [
"shrink-0 rounded-sm px-2 py-2 text-[13px] font-medium leading-none whitespace-nowrap no-underline outline-none transition-colors",
"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(" ");
}
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 (
<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
}
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}
>
<FlagIcon code={lang} className="h-5 w-5" />
<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
className="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"
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"
}`}
>
<FlagIcon code={option.code} className="h-5 w-5" />
<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);
}}
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}
>
<FlagIcon code={lang} className="h-7 w-7" />
2026-05-27 11:00:52 +08:00
</button>
{open ? (
<div
className="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"
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"
}`}
>
<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();
const [open, setOpen] = useState(false);
2026-05-27 11:00:52 +08:00
const [mobileSearchOpen, setMobileSearchOpen] = useState(false);
2026-05-16 00:18:22 +08:00
const [q, setQ] = useState("");
const nav = useNavigate();
const na = (which: PublicNavWhich) =>
navIsActive(pathname, search, hash, which);
2026-05-28 15:31:45 +08:00
const isHome = pathname === "/";
const footerInContentFlow =
pathname === "/browse" || pathname.startsWith("/category/");
2026-05-16 00:18:22 +08:00
const goSearch = () => {
const s = q.trim();
if (!s) return;
2026-05-27 11:33:48 +08:00
nav(`/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-16 00:18:22 +08:00
};
return (
<div className="min-h-full flex flex-col">
2026-05-27 11:00:52 +08:00
<header className="sticky top-0 z-40 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-27 11:00:52 +08:00
<Link
to="/"
2026-05-28 15:31:45 +08:00
className="flex h-8 shrink-0 items-center gap-2 rounded-sm text-[20px] font-black leading-5 tracking-tight 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]"
2026-05-27 11:00:52 +08:00
aria-label={t("brand")}
>
2026-05-28 15:31:45 +08:00
<ArkLogoMark className="h-8 w-8 shrink-0" />
<span className="truncate text-ark-gold">{t("brand")}</span>
2026-05-27 11:00:52 +08:00
</Link>
<div className="flex h-[40px] w-[136px] shrink-0 items-center gap-[8px]">
<button
type="button"
onClick={() => {
setOpen(false);
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}
setLang={setLang}
ariaLabel={t("langLabel")}
onOpen={() => {
setOpen(false);
setMobileSearchOpen(false);
}}
/>
<button
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);
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>
{mobileSearchOpen ? (
<div className="border-t border-white/10 bg-[#08070c] px-5 pb-3 md:hidden max-[360px]:px-3">
<div className="flex h-11 items-center gap-2 rounded-full border border-[#2a2a31] bg-[#191921] px-4">
<SearchIcon size={18} className="shrink-0 text-[#a8a9ae]" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && goSearch()}
placeholder={t("searchPlaceholder")}
className="min-w-0 flex-1 bg-transparent text-sm text-neutral-100 outline-none placeholder:text-[#777985]"
/>
</div>
</div>
) : null}
<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-0">
2026-05-16 00:18:22 +08:00
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
2026-05-16 00:18:22 +08:00
<Link
to="/"
className="flex min-w-0 shrink-0 items-center gap-2.5 rounded-sm text-xl font-bold tracking-wide 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"
>
<ArkLogoMark className="h-10 w-10 shrink-0" />
<span className="max-w-[8rem] truncate text-ark-gold sm:inline">
2026-05-16 00:18:22 +08:00
{t("brand")}
</span>
</Link>
<nav
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 min-[1200px]:flex lg:gap-5"
2026-05-16 00:18:22 +08:00
aria-label={t("mainNav")}
>
<Link
to="/browse"
className={navClassName(na("browseAll"))}
aria-current={na("browseAll") ? "page" : undefined}
>
{t("all")}
</Link>
<Link
to="/#categories"
className={navClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
>
{t("categories")}
</Link>
<Link
2026-05-28 15:31:45 +08:00
to="/#official"
2026-05-16 00:18:22 +08:00
className={navClassName(na("browseRecommended"))}
aria-current={na("browseRecommended") ? "page" : undefined}
>
{t("official")}
</Link>
<Link
2026-05-28 15:31:45 +08:00
to="/#latest"
className={navClassName(na("browseLatest"))}
aria-current={na("browseLatest") ? "page" : undefined}
>
{t("latest")}
</Link>
<Link
to="/#popular"
2026-05-16 00:18:22 +08:00
className={navClassName(na("browsePopular"))}
aria-current={na("browsePopular") ? "page" : undefined}
>
{t("popular")}
</Link>
2026-05-28 15:31:45 +08:00
<Link
to="/about"
className={navClassName(na("about"))}
aria-current={na("about") ? "page" : undefined}
>
{t("footerAbout")}
</Link>
2026-05-16 00:18:22 +08:00
</nav>
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 min-[1200px]:flex-none">
<div className="hidden h-10 min-w-0 flex-1 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex min-[1200px]:w-44 min-[1200px]:flex-none lg:pr-4 xl:w-52">
2026-05-16 00:18:22 +08:00
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input
value={q}
onChange={(e) => 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]"
2026-05-16 00:18:22 +08:00
/>
</div>
<LanguageDropdown
lang={lang}
setLang={setLang}
ariaLabel={t("langLabel")}
className="hidden h-10 w-36 md:block lg:w-40"
/>
2026-05-16 00:18:22 +08:00
<button
type="button"
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-[1200px]:hidden"
2026-05-16 00:18:22 +08:00
onClick={() => setOpen((v) => !v)}
aria-label="menu"
>
{open ? <X size={18} /> : <Menu size={18} />}
</button>
</div>
</div>
</div>
{open ? (
<div className="grid gap-2 border-t border-ark-line bg-ark-nav px-4 py-3 min-[440px]:px-5 sm:px-6 md:px-9 min-[1200px]:hidden">
2026-05-27 11:00:52 +08:00
<div className="mb-1 hidden items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2 md:flex">
2026-05-16 00:18:22 +08:00
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
<input
value={q}
onChange={(e) => 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]"
/>
</div>
<LanguageDropdown
lang={lang}
setLang={setLang}
ariaLabel={t("langLabel")}
2026-05-27 11:00:52 +08:00
className="mb-1 hidden md:block"
/>
2026-05-16 00:18:22 +08:00
<Link
to="/browse"
className={navClassName(na("browseAll"))}
aria-current={na("browseAll") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("all")}
</Link>
<Link
to="/#categories"
className={navClassName(na("categories"))}
aria-current={na("categories") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("categories")}
</Link>
<Link
2026-05-28 15:31:45 +08:00
to="/#official"
className={navClassName(na("browseRecommended"))}
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-05-28 15:31:45 +08:00
to="/#latest"
className={navClassName(na("browseLatest"))}
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>
<Link
2026-05-28 15:31:45 +08:00
to="/#popular"
2026-05-16 00:18:22 +08:00
className={navClassName(na("browsePopular"))}
aria-current={na("browsePopular") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("popular")}
</Link>
<Link
to="/about"
className={navClassName(na("about"))}
aria-current={na("about") ? "page" : undefined}
onClick={() => setOpen(false)}
>
{t("footerAbout")}
</Link>
</div>
) : null}
</header>
<main
className={`mx-auto w-full max-w-[1280px] ${
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
? "px-0 pb-0 pt-0 md:px-9 md:pt-10 xl:px-0"
: "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-16 00:18:22 +08:00
<Outlet />
</main>
2026-05-28 15:31:45 +08:00
<footer className="mt-auto bg-transparent md:border-t md:border-ark-line md:bg-ark-nav/90">
<div className="mx-auto flex h-[52px] max-w-[358px] items-center justify-center px-4 py-4 text-[13px] leading-5 md:h-auto md:max-w-[1280px] md:justify-start md:px-9 md:py-6 md:text-sm xl:px-0">
2026-05-16 00:18:22 +08:00
<Link
to="/about"
className={`rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
na("about") ? "text-ark-gold" : "text-[#A8A9AE]"
}`}
2026-05-16 00:18:22 +08:00
>
{t("footerAbout")}
</Link>
</div>
</footer>
<nav className="sticky inset-x-0 bottom-0 z-40 bg-[#0C0D0F]/90 backdrop-blur md:hidden">
2026-05-28 15:31:45 +08:00
<div className="grid h-[78px] grid-cols-4 gap-3 px-5 py-4 text-center text-[11px] leading-[17.6px]">
2026-05-16 00:18:22 +08:00
<BottomNavIcon
to="/"
label={t("home")}
icon="home"
active={pathname === "/"}
/>
<BottomNavIcon
to="/browse"
label={t("all")}
icon="document"
active={
pathname === "/browse" && !new URLSearchParams(search).get("sort")
}
/>
<BottomNavIcon
to="/favorites"
label={t("favorites")}
icon="bookmark"
active={pathname === "/favorites"}
2026-05-16 00:18:22 +08:00
/>
<BottomNavIcon
2026-05-28 15:31:45 +08:00
to="/#latest"
2026-05-16 00:18:22 +08:00
label={t("latest")}
icon="update"
2026-05-28 15:31:45 +08:00
active={pathname === "/" && hash === "#latest"}
2026-05-16 00:18:22 +08:00
/>
</div>
</nav>
</div>
);
}
const NAVBAR_ICON_BASE = "/assets/ark-library/navbar";
function BottomNavIcon({
to,
label,
icon,
active,
}: {
to: string;
label: string;
icon: "home" | "document" | "bookmark" | "update";
2026-05-16 00:18:22 +08:00
active: boolean;
}) {
const src = active
? `${NAVBAR_ICON_BASE}/${icon}-active.svg`
: `${NAVBAR_ICON_BASE}/${icon}-inactive.svg`;
return (
<Link
to={to}
className={[
"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(" ")}
>
<img
src={src}
alt=""
className="mx-auto h-6 w-6 object-contain"
width={24}
height={24}
2026-05-16 00:18:22 +08:00
loading="lazy"
decoding="async"
/>
<span className="leading-tight">{label}</span>
</Link>
);
}