文件
csp/frontend/src/components/app-nav.tsx
2026-02-16 02:38:00 +08:00

429 行
17 KiB
TypeScript

"use client";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { PixelAvatar } from "@/components/pixel-avatar";
import { useUiPreferences } from "@/components/ui-preference-provider";
import { XpBar } from "@/components/xp-bar";
import { apiFetch } from "@/lib/api";
import { clearToken, readToken } from "@/lib/auth";
import type { ThemeId } from "@/themes/types";
type NavLink = {
label: string;
href: string;
};
type NavGroup = {
key: string;
label: string;
links: NavLink[];
};
type MeProfile = {
id?: number;
username?: string;
};
function buildNavGroups(t: (key: string) => string, isAdmin: boolean): NavGroup[] {
const groups: NavGroup[] = [
{
key: "learn",
label: t("nav.group.learn"),
links: [
{ label: t("nav.link.home"), href: "/" },
{ label: t("nav.link.problems"), href: "/problems" },
{ label: t("nav.link.submissions"), href: "/submissions" },
{ label: t("nav.link.wrong_book"), href: "/wrong-book" },
{ label: t("nav.link.kb"), href: "/kb" },
{ label: t("nav.link.run"), href: "/run" },
],
},
{
key: "contest",
label: t("nav.group.contest"),
links: [
{ label: t("nav.link.contests"), href: "/contests" },
{ label: t("nav.link.leaderboard"), href: "/leaderboard" },
],
},
{
key: "account",
label: t("nav.group.account"),
links: [
{ label: t("nav.link.auth"), href: "/auth" },
{ label: t("nav.link.me"), href: "/me" },
],
},
];
if (isAdmin) {
groups.splice(2, 0, {
key: "system",
label: t("nav.group.system"),
links: [
{ label: t("nav.link.imports"), href: "/imports" },
{ label: t("nav.link.backend_logs"), href: "/backend-logs" },
{ label: t("nav.link.admin_users"), href: "/admin-users" },
{ label: t("nav.link.admin_redeem"), href: "/admin-redeem" },
{ label: t("nav.link.api_docs"), href: "/api-docs" },
],
});
}
return groups;
}
function isActivePath(pathname: string, href: string): boolean {
if (pathname === href) return true;
if (href === "/") return pathname === "/";
return pathname.startsWith(`${href}/`);
}
function resolveActiveGroup(pathname: string, groups: NavGroup[]): string {
for (const group of groups) {
for (const item of group.links) {
if (isActivePath(pathname, item.href)) return group.key;
}
}
return groups[0]?.key ?? "learn";
}
function resolveActiveLink(pathname: string, group: NavGroup): string {
for (const item of group.links) {
if (isActivePath(pathname, item.href)) return item.href;
}
return group.links[0]?.href ?? "/";
}
export function AppNav() {
const pathname = usePathname();
const router = useRouter();
const { theme, setTheme, language, setLanguage, themes, t } = useUiPreferences();
const directHttpAccessUrl =
process.env.NEXT_PUBLIC_HTTP_ENTRY_URL?.trim() || "http://8.211.173.24:7888/";
const [hasToken, setHasToken] = useState<boolean>(() => Boolean(readToken()));
const [isAdmin, setIsAdmin] = useState(false);
const [meProfile, setMeProfile] = useState<MeProfile | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [desktopOpenGroup, setDesktopOpenGroup] = useState<string | null>(null);
const desktopMenuRef = useRef<HTMLDivElement | null>(null);
const navGroups = useMemo(() => buildNavGroups(t, isAdmin), [isAdmin, t]);
const activeGroup = resolveActiveGroup(pathname, navGroups);
const usePopupSecondary = theme === "default" || theme === "minecraft";
useEffect(() => {
let canceled = false;
const refresh = async () => {
const token = readToken();
if (canceled) return;
setHasToken(Boolean(token));
if (!token) {
setIsAdmin(false);
setMeProfile(null);
return;
}
try {
const me = await apiFetch<MeProfile>("/api/v1/me", {}, token);
if (!canceled) {
setIsAdmin((me?.username ?? "") === "admin");
setMeProfile(me ?? null);
}
} catch {
if (!canceled) {
setIsAdmin(false);
setMeProfile(null);
}
}
};
const onRefresh = () => {
void refresh();
};
void refresh();
window.addEventListener("storage", onRefresh);
window.addEventListener("focus", onRefresh);
return () => {
canceled = true;
window.removeEventListener("storage", onRefresh);
window.removeEventListener("focus", onRefresh);
};
}, [pathname]);
useEffect(() => {
if (!usePopupSecondary || !desktopOpenGroup) return;
const onMouseDown = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (desktopMenuRef.current?.contains(target)) return;
setDesktopOpenGroup(null);
};
window.addEventListener("mousedown", onMouseDown);
return () => window.removeEventListener("mousedown", onMouseDown);
}, [desktopOpenGroup, usePopupSecondary]);
const currentGroup = navGroups.find((g) => g.key === activeGroup) ?? navGroups[0];
const avatarSeed = meProfile
? `${meProfile.username ?? "user"}-${meProfile.id ?? ""}`
: "guest";
const handleLogout = () => {
clearToken();
setHasToken(false);
setIsAdmin(false);
setMeProfile(null);
setDesktopOpenGroup(null);
};
return (
<header className="print-hidden border-b bg-[color:var(--surface)]/95 backdrop-blur supports-[backdrop-filter]:bg-[color:var(--surface)]/85">
<div className="mx-auto max-w-6xl px-3 py-3 max-[390px]:px-2 max-[390px]:py-2 sm:px-4">
<div className="flex items-center justify-between md:hidden">
<span className="text-sm font-medium text-zinc-700">{t("nav.menu")}</span>
<button
type="button"
className="rounded-md border px-3 py-1 text-sm hover:bg-zinc-100 max-[390px]:px-2 max-[390px]:text-xs"
onClick={() => setMenuOpen((v) => !v)}
aria-expanded={menuOpen}
aria-controls="main-nav-links"
>
{menuOpen ? t("nav.collapse") : t("nav.expand")}
</button>
</div>
<div id="main-nav-links" className={`${menuOpen ? "mt-3 block" : "hidden"} md:mt-0 md:block`}>
<div className="space-y-3">
{usePopupSecondary ? (
<div ref={desktopMenuRef} className="hidden flex-wrap items-center gap-2 md:flex">
{navGroups.map((group) => {
const active = activeGroup === group.key;
const opened = desktopOpenGroup === group.key;
return (
<div key={group.key} className="relative">
<button
type="button"
className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
aria-expanded={opened}
onClick={() =>
setDesktopOpenGroup((prev) => (prev === group.key ? null : group.key))
}
>
{group.label}
</button>
{opened && (
<div
className={`absolute left-0 top-full z-50 mt-2 rounded-md border bg-[color:var(--surface)] p-1 shadow-lg ${group.key === "account" ? "min-w-[18rem]" : "min-w-[11rem]"
}`}
>
{group.links.map((item) => {
const linkActive = isActivePath(pathname, item.href);
return (
<button
key={item.href}
type="button"
className={`block w-full rounded px-3 py-1.5 text-left text-sm ${linkActive ? "bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
onClick={() => {
setDesktopOpenGroup(null);
router.push(item.href);
}}
>
{item.label}
</button>
);
})}
{group.key === "account" && (
<div className="mt-2 space-y-2 border-t border-zinc-200 px-2 pt-2">
<label className="block text-xs">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
value={theme}
onChange={(e) => setTheme(e.target.value as ThemeId)}
>
{themes.map((item) => (
<option key={item.id} value={item.id}>
{item.labels[language]}
</option>
))}
</select>
</label>
<label className="block text-xs">
<span className="text-zinc-500">{t("prefs.language")}</span>
<select
className="mt-1 w-full rounded-md border px-2 py-1 text-xs"
value={language}
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
>
<option value="en">{t("prefs.lang.en")}</option>
<option value="zh">{t("prefs.lang.zh")}</option>
</select>
</label>
<div className="flex items-center justify-between gap-2 rounded-md border border-zinc-200 px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<>
<PixelAvatar
seed={avatarSeed}
size={20}
className="border-zinc-700"
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
/>
{meProfile?.username && (
<span className="truncate text-xs text-zinc-500">{meProfile.username}</span>
)}
</>
)}
</div>
{hasToken && (
<button
type="button"
className="rounded-md border px-2 py-1 text-xs hover:bg-zinc-100"
onClick={handleLogout}
>
{t("nav.logout")}
</button>
)}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
) : (
<>
<div className="hidden flex-wrap items-center gap-2 md:flex">
{navGroups.map((group) => {
const active = activeGroup === group.key;
return (
<button
key={group.key}
type="button"
className={`rounded-md border px-3 py-1 text-sm ${active ? "border-zinc-900 bg-zinc-900 text-white" : "hover:bg-zinc-100"
}`}
onClick={() => router.push(group.links[0]?.href ?? "/")}
>
{group.label}
</button>
);
})}
</div>
<div className="hidden items-center gap-2 md:flex">
<span className="text-xs text-zinc-500">{t("nav.secondary_menu")}</span>
<select
className="min-w-[220px] rounded-md border px-3 py-1 text-sm"
value={resolveActiveLink(pathname, currentGroup)}
onChange={(e) => router.push(e.target.value)}
>
{currentGroup.links.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</select>
</div>
</>
)}
<div className="space-y-3 md:hidden">
{navGroups.map((group) => (
<section key={group.key} className="rounded-lg border p-2">
<h3 className="text-xs font-semibold text-zinc-600">{group.label}</h3>
<select
className="mt-2 w-full rounded-md border px-3 py-1 text-xs"
value={resolveActiveLink(pathname, group)}
onChange={(e) => {
setMenuOpen(false);
router.push(e.target.value);
}}
>
{group.links.map((item) => (
<option key={item.href} value={item.href}>
{item.label}
</option>
))}
</select>
</section>
))}
</div>
</div>
</div>
<div className={`mt-2 flex flex-wrap items-center justify-end gap-2 text-xs sm:text-sm ${usePopupSecondary ? "md:hidden" : ""}`}>
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.theme")}</span>
<select
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
value={theme}
onChange={(e) => {
setDesktopOpenGroup(null);
setTheme(e.target.value as ThemeId);
}}
>
{themes.map((item) => (
<option key={item.id} value={item.id}>
{item.labels[language]}
</option>
))}
</select>
</label>
<label className="inline-flex items-center gap-1">
<span className="text-zinc-500">{t("prefs.language")}</span>
<select
className="rounded-md border px-2 py-1 text-xs sm:text-sm"
value={language}
onChange={(e) => setLanguage(e.target.value === "zh" ? "zh" : "en")}
>
<option value="en">{t("prefs.lang.en")}</option>
<option value="zh">{t("prefs.lang.zh")}</option>
</select>
</label>
<a
href={directHttpAccessUrl}
target="_blank"
rel="noreferrer"
className="rounded-md border px-2 py-1 text-xs hover:bg-zinc-100 sm:text-sm"
title={directHttpAccessUrl}
>
{t("nav.link.http_ip_port")}
</a>
<span className={hasToken ? "text-emerald-700" : "text-zinc-500"}>
{hasToken ? t("nav.logged_in") : t("nav.logged_out")}
</span>
{hasToken && (
<>
{theme === "minecraft" && (
<div className="hidden md:block w-32 mr-2">
<XpBar level={5} currentXp={750} nextLevelXp={1000} />
</div>
)}
<PixelAvatar
seed={avatarSeed}
size={24}
className="border-zinc-700"
alt={meProfile?.username ? `${meProfile.username} avatar` : "avatar"}
/>
</>
)}
{hasToken && (
<button
onClick={handleLogout}
className="rounded-md border px-3 py-1 hover:bg-zinc-100"
>
{t("nav.logout")}
</button>
)}
</div>
</div>
</header>
);
}