473 lines
17 KiB
TypeScript
473 lines
17 KiB
TypeScript
import {
|
|
Check,
|
|
ChevronDown,
|
|
Globe,
|
|
Menu,
|
|
Search as SearchIcon,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
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";
|
|
import { adminUiPrefix } from "../adminPaths";
|
|
|
|
type PublicNavWhich =
|
|
| "home"
|
|
| "browseAll"
|
|
| "categories"
|
|
| "browseLatest"
|
|
| "browseRecommended"
|
|
| "browsePopular"
|
|
| "wallet"
|
|
| "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":
|
|
return pathname === "/browse" && sp.get("sort") === "latest";
|
|
case "browseRecommended":
|
|
return pathname === "/browse" && sp.get("sort") === "recommended";
|
|
case "browsePopular":
|
|
return pathname === "/browse" && sp.get("sort") === "popular";
|
|
case "wallet":
|
|
return pathname === "/wallet";
|
|
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 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}
|
|
>
|
|
<Globe size={16} className="shrink-0 text-ark-gold/80" aria-hidden />
|
|
<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"
|
|
}`}
|
|
>
|
|
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
|
{active ? (
|
|
<Check className="h-4 w-4" strokeWidth={2.4} />
|
|
) : null}
|
|
</span>
|
|
<span className="truncate font-medium">{option.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PublicLayout() {
|
|
const { t, lang, setLang } = useI18n();
|
|
const { pathname, search, hash } = useLocation();
|
|
const [open, setOpen] = useState(false);
|
|
const [q, setQ] = useState("");
|
|
const nav = useNavigate();
|
|
|
|
const na = (which: PublicNavWhich) =>
|
|
navIsActive(pathname, search, hash, which);
|
|
|
|
const goSearch = () => {
|
|
const s = q.trim();
|
|
if (!s) return;
|
|
nav(`/search?q=${encodeURIComponent(s)}`);
|
|
setOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-full flex flex-col pb-20 md:pb-0">
|
|
<header className="sticky top-0 z-40 border-b border-ark-line bg-ark-nav/98 backdrop-blur-md">
|
|
<div className="mx-auto max-w-[1280px] px-4 py-[15px] min-[440px]:px-5 sm:px-6 md:px-9 xl:px-0">
|
|
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
|
<div className="flex h-10 items-center gap-2 min-[1200px]:gap-0 lg:gap-4">
|
|
<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">
|
|
{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"
|
|
aria-label={t("mainNav")}
|
|
>
|
|
<Link
|
|
to="/"
|
|
className={navClassName(na("home"))}
|
|
aria-current={na("home") ? "page" : undefined}
|
|
>
|
|
{t("home")}
|
|
</Link>
|
|
<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
|
|
to="/browse?sort=latest"
|
|
className={navClassName(na("browseLatest"))}
|
|
aria-current={na("browseLatest") ? "page" : undefined}
|
|
>
|
|
{t("latest")}
|
|
</Link>
|
|
<Link
|
|
to="/browse?sort=recommended"
|
|
className={navClassName(na("browseRecommended"))}
|
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
|
>
|
|
{t("official")}
|
|
</Link>
|
|
<Link
|
|
to="/browse?sort=popular"
|
|
className={navClassName(na("browsePopular"))}
|
|
aria-current={na("browsePopular") ? "page" : undefined}
|
|
>
|
|
{t("popular")}
|
|
</Link>
|
|
<Link
|
|
to="/wallet"
|
|
className={navClassName(na("wallet"))}
|
|
aria-current={na("wallet") ? "page" : undefined}
|
|
>
|
|
{t("wallet")}
|
|
</Link>
|
|
</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">
|
|
<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]"
|
|
/>
|
|
</div>
|
|
<LanguageDropdown
|
|
lang={lang}
|
|
setLang={setLang}
|
|
ariaLabel={t("langLabel")}
|
|
className="hidden h-10 w-36 md:block lg:w-40"
|
|
/>
|
|
<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"
|
|
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">
|
|
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
|
|
<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")}
|
|
className="mb-1"
|
|
/>
|
|
<Link
|
|
to="/"
|
|
className={navClassName(na("home"))}
|
|
aria-current={na("home") ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t("home")}
|
|
</Link>
|
|
<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
|
|
to="/browse?sort=latest"
|
|
className={navClassName(na("browseLatest"))}
|
|
aria-current={na("browseLatest") ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t("latest")}
|
|
</Link>
|
|
<Link
|
|
to="/browse?sort=recommended"
|
|
className={navClassName(na("browseRecommended"))}
|
|
aria-current={na("browseRecommended") ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t("official")}
|
|
</Link>
|
|
<Link
|
|
to="/browse?sort=popular"
|
|
className={navClassName(na("browsePopular"))}
|
|
aria-current={na("browsePopular") ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t("popular")}
|
|
</Link>
|
|
<Link
|
|
to="/wallet"
|
|
className={navClassName(na("wallet"))}
|
|
aria-current={na("wallet") ? "page" : undefined}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
{t("wallet")}
|
|
</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] flex-1 px-4 py-6 min-[440px]:px-5 sm:px-6 md:px-9 md:py-10 xl:px-0">
|
|
<Outlet />
|
|
</main>
|
|
|
|
<footer className="mt-auto border-t border-ark-line bg-ark-nav/90 mb-20 md:mb-0">
|
|
<div className="mx-auto flex max-w-[1280px] flex-wrap gap-x-6 gap-y-2 px-4 py-6 text-sm text-neutral-400 min-[440px]:px-5 sm:px-6 md:px-9 xl:px-0">
|
|
<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"
|
|
>
|
|
{t("footerAbout")}
|
|
</Link>
|
|
<Link
|
|
to="/wallet"
|
|
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"
|
|
>
|
|
{t("profile")}
|
|
</Link>
|
|
{import.meta.env.VITE_DISABLE_ADMIN !== "true" ? (
|
|
<Link
|
|
to={`${adminUiPrefix}/login`}
|
|
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"
|
|
>
|
|
{t("footerAdminLogin")}
|
|
</Link>
|
|
) : null}
|
|
</div>
|
|
</footer>
|
|
|
|
<nav className="md:hidden fixed bottom-0 inset-x-0 z-40 border-t border-ark-line bg-ark-nav/95 backdrop-blur">
|
|
<div className="grid grid-cols-4 gap-1 px-1 py-2 text-center text-[11px]">
|
|
<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="/wallet"
|
|
label={t("wallet")}
|
|
icon="profile"
|
|
active={pathname === "/wallet"}
|
|
/>
|
|
<BottomNavIcon
|
|
to="/browse?sort=latest"
|
|
label={t("latest")}
|
|
icon="update"
|
|
active={
|
|
pathname === "/browse" &&
|
|
new URLSearchParams(search).get("sort") === "latest"
|
|
}
|
|
/>
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const NAVBAR_ICON_BASE = "/assets/ark-library/navbar";
|
|
|
|
function BottomNavIcon({
|
|
to,
|
|
label,
|
|
icon,
|
|
active,
|
|
}: {
|
|
to: string;
|
|
label: string;
|
|
icon: "home" | "document" | "profile" | "update";
|
|
active: boolean;
|
|
}) {
|
|
const src = active
|
|
? `${NAVBAR_ICON_BASE}/${icon}-active.svg`
|
|
: `${NAVBAR_ICON_BASE}/${icon}-inactive.svg`;
|
|
return (
|
|
<Link
|
|
to={to}
|
|
className={[
|
|
"flex flex-col items-center gap-1 rounded-lg py-1 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-neutral-400",
|
|
].join(" ")}
|
|
>
|
|
<img
|
|
src={src}
|
|
alt=""
|
|
className={[
|
|
"mx-auto h-7 w-7 object-contain",
|
|
active ? "opacity-100" : "opacity-55",
|
|
].join(" ")}
|
|
width={28}
|
|
height={28}
|
|
loading="lazy"
|
|
decoding="async"
|
|
/>
|
|
<span className="leading-tight">{label}</span>
|
|
</Link>
|
|
);
|
|
}
|