Files
gitlab-instance-0a899031_cn…/app/components/Navbar.tsx
2026-03-12 19:54:58 -05:00

210 lines
8.9 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/i18n/navigation";
import { getStoredUserEmail, pbLogout } from "@/app/lib/pocketbase";
import { getNavLinks } from "@/config";
import AuthModal from "./AuthModal";
import ThemeToggle from "./ThemeToggle";
import SiteMenu from "./SiteMenu";
import UserAvatar from "./UserAvatar";
export default function NavbarComponent() {
const { t } = useTranslation("common");
const { t: tNav } = useTranslation("nav");
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [userVip, setUserVip] = useState(false);
const navLinks = getNavLinks();
const fetchAuth = useCallback(async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include", cache: "no-store" });
const data = await res.json();
if (data?.user && data?.token) {
const { pbSaveAuth } = await import("@/app/lib/pocketbase");
pbSaveAuth(data.token, { id: data.user.id, email: data.user.email });
setUserEmail(data.user.email);
setUserVip(!!data.user.vip);
return;
}
} catch {
/* ignore */
}
setUserEmail(getStoredUserEmail());
setUserVip(false);
}, []);
useEffect(() => {
fetchAuth();
}, [fetchAuth]);
useEffect(() => {
const onAuthUpdated = () => fetchAuth();
window.addEventListener("auth:updated", onAuthUpdated);
return () => window.removeEventListener("auth:updated", onAuthUpdated);
}, [fetchAuth]);
const handleLogout = useCallback(async () => {
await pbLogout();
setUserEmail(null);
setUserVip(false);
}, []);
const isActive = useCallback((href: string) => {
if (href.startsWith("/#")) return false;
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
}, [pathname]);
return (
<nav className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b border-gray-100 dark:border-gray-800">
<div className="max-w-[1600px] mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 shrink-0">
<span className="text-2xl">🌏</span>
<span className="text-lg font-bold text-gray-900 dark:text-gray-100 tracking-tight">
{t("siteName")}
</span>
</Link>
<div className="hidden lg:flex items-center gap-2 overflow-x-auto max-w-[calc(100vw-400px)] hide-scrollbar">
{navLinks.slice(0, 5).map((link) => (
<Link
key={link.labelKey}
href={link.href}
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
isActive(link.href)
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<span>{link.icon}</span>
<span className="hidden 2xl:inline">{tNav(link.labelKey)}</span>
</Link>
))}
<span className="w-px h-5 bg-gray-200 dark:bg-gray-700 shrink-0" aria-hidden />
<button
onClick={() => setMenuOpen(true)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap shrink-0 ${
menuOpen
? "text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
>
<svg className="w-4 h-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
<span className="hidden xl:inline">{tNav("menu")}</span>
</button>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<button
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setMenuOpen(true)}
>
<svg className="w-5 h-5 text-gray-600 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</button>
{userEmail ? (
<div className="flex items-center gap-2">
<span className="hidden sm:block max-w-[120px] truncate text-sm text-gray-600 dark:text-gray-400">
{userEmail}
</span>
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { setUserEmail(null); setUserVip(false); }} />
</div>
) : null}
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-xs sm:text-sm font-medium text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
title={locale === "zh" ? "EN" : "中文"}
>
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<Link
href="/join"
className="text-xs sm:text-sm bg-[#ff4d4f] text-white px-3 sm:px-4 py-2 rounded-full hover:bg-[#ff7a45] transition-colors font-medium whitespace-nowrap"
>
{t("explore")}
</Link>
<button
className="md:hidden ml-1 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
<path d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
{mobileOpen && (
<div className="md:hidden border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 px-4 py-3 space-y-1">
{navLinks.map((link) => (
<Link
key={link.labelKey}
href={link.href}
className={`flex items-center gap-2 px-4 py-3 rounded-xl text-sm font-medium ${
isActive(link.href)
? "text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-800"
: "text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
}`}
onClick={() => setMobileOpen(false)}
>
<span className="text-lg">{link.icon}</span>
<span>{tNav(link.labelKey)}</span>
</Link>
))}
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="block w-full text-left px-4 py-3 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
>
{locale === "zh" ? "EN" : "中文"}
</button>
{userEmail ? (
<div className="pt-2 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between px-4 py-2.5 rounded-lg bg-gray-50 dark:bg-gray-800">
<UserAvatar email={userEmail} isVip={userVip} onLogout={() => { handleLogout(); setMobileOpen(false); }} />
<span className="truncate text-sm text-gray-600 dark:text-gray-400">{userEmail}</span>
</div>
) : null}
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={(email) => setUserEmail(email)}
/>
<SiteMenu
isOpen={menuOpen}
onClose={() => setMenuOpen(false)}
onLoginClick={() => { setMenuOpen(false); setAuthOpen(true); }}
/>
</nav>
);
}