429 行
17 KiB
TypeScript
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>
|
|
);
|
|
}
|