Files
gitlab-instance-0a899031_cn…/app/digital/components/Header.tsx
root 9de2465793 s
2026-06-07 15:57:47 +08:00

240 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useCallback } from "react";
import { Link, useLocale, usePathname, useRouter, useTranslation } from "@/app/digital/i18n/navigation";
import { getStoredUserEmail } from "@/app/digital/lib/pocketbase";
import { useCrossSiteUrls } from "@/app/digital/lib/useCrossSiteUrls";
import AuthModal from "./AuthModal";
import DigitalThemeIcon from "./DigitalThemeIcon";
import ThemeToggle from "./ThemeToggle";
import UserAvatar from "./UserAvatar";
export default function Header() {
const { t } = useTranslation("common");
const { t: tNav } = useTranslation("nav");
const locale = useLocale();
const pathname = usePathname();
const router = useRouter();
const [mobileOpen, setMobileOpen] = useState(false);
const [scrolled, setScrolled] = useState(() =>
typeof window !== "undefined" ? window.scrollY > 8 : false
);
const [authOpen, setAuthOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
const [vip, setVip] = useState(false);
const { vipUrl } = useCrossSiteUrls();
const navLinks = [
{ labelKey: "roadmap" as const, href: "/#roadmap" },
{ labelKey: "tools" as const, href: "/#tools" },
{ labelKey: "community" as const, href: vipUrl, external: true },
{ labelKey: "resources" as const, href: "/#resources" },
];
const fetchAuthAndVip = useCallback(async () => {
try {
const meRes = await fetch("/api/digital/auth/me", { credentials: "include" });
const meData = await meRes.json();
if (meData?.user && meData?.token) {
const { pbSaveAuth } = await import("@/app/digital/lib/pocketbase");
pbSaveAuth(meData.token, { id: meData.user.id, email: meData.user.email });
setUserEmail(meData.user.email);
// 用邮箱查 VIP比 cookie userId 更可靠
const vipRes = await fetch(`/api/digital/vip/check?email=${encodeURIComponent(meData.user.email)}`, { credentials: "include" });
const vipData = await vipRes.json();
setVip(vipData?.vip === true);
} else {
setUserEmail(getStoredUserEmail());
setVip(false);
}
} catch {
/* ignore */
}
}, []);
useEffect(() => {
fetchAuthAndVip();
}, [fetchAuthAndVip]);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 8);
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
const onVipUpdated = () => fetchAuthAndVip();
const onAuthSuccess = () => fetchAuthAndVip();
const onAuthUpdated = () => fetchAuthAndVip();
window.addEventListener("vip:updated", onVipUpdated);
window.addEventListener("auth:success", onAuthSuccess);
window.addEventListener("auth:updated", onAuthUpdated);
return () => {
window.removeEventListener("vip:updated", onVipUpdated);
window.removeEventListener("auth:success", onAuthSuccess);
window.removeEventListener("auth:updated", onAuthUpdated);
};
}, [fetchAuthAndVip]);
return (
<header
className={`dn-header fixed top-0 left-0 right-0 z-50 w-full max-w-[100vw] transition-all duration-300 ${
scrolled
? "dn-header-scrolled bg-white/80 backdrop-blur-lg shadow-sm dark:bg-slate-900/80 dark:shadow-slate-950/50"
: "bg-transparent"
}`}
>
<div className="dn-container mx-auto flex h-16 w-full min-w-0 max-w-7xl items-center justify-between gap-2 pl-[max(1rem,env(safe-area-inset-left))] pr-[max(1rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))] sm:pr-[max(1.5rem,env(safe-area-inset-right))] lg:pl-[max(2rem,env(safe-area-inset-left))] lg:pr-[max(2rem,env(safe-area-inset-right))]">
<Link href="/" className="dn-brand flex min-w-0 shrink items-center gap-2 text-lg font-bold text-slate-900 dark:text-slate-100">
<DigitalThemeIcon name="globe" emoji="🌍" className="h-7 w-7 text-2xl" />
<span className="hidden sm:inline">{t("siteName")}</span>
<span className="sm:hidden">{t("siteNameShort")}</span>
</Link>
<nav className="dn-nav hidden items-center gap-1 md:flex">
{navLinks.map((link) => (
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="dn-nav-link rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
) : (
<Link
key={link.href}
href={link.href}
className="dn-nav-link rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</Link>
)
))}
</nav>
<div className="flex shrink-0 items-center gap-1 sm:gap-3">
{userEmail ? (
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); router.replace("/"); }} />
) : (
<button
onClick={() => setAuthOpen(true)}
className="dn-button-secondary hidden rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 sm:inline-flex"
>
{t("loginRegister")}
</button>
)}
<button
type="button"
onClick={() => router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" })}
className="dn-header-control inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
aria-label={locale === "zh" ? "Switch to English" : "切换到中文"}
title={locale === "zh" ? "EN" : "中文"}
>
{locale === "zh" ? "EN" : "中文"}
</button>
<ThemeToggle />
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="dn-header-control inline-flex shrink-0 items-center justify-center rounded-lg p-2 text-slate-600 transition-colors hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800 md:hidden"
aria-label={t("toggleMenu")}
>
{mobileOpen ? (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
<Link
href="/#roadmap"
className="dn-button-primary hidden rounded-full bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500 sm:inline-flex"
>
{t("explore")}
</Link>
</div>
</div>
{mobileOpen && (
<div className="dn-mobile-menu overflow-x-hidden border-t border-slate-100 bg-white/95 backdrop-blur-lg dark:border-slate-800 dark:bg-slate-900/95 md:hidden">
<nav className="mx-auto w-full max-w-7xl space-y-1 px-4 py-3 pr-[max(1rem,env(safe-area-inset-right))]">
{navLinks.map((link) => (
"external" in link && link.external ? (
<a
key={link.href}
href={link.href}
target="_blank"
rel="noopener noreferrer"
onClick={() => setMobileOpen(false)}
className="dn-nav-link block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</a>
) : (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className="dn-nav-link block rounded-lg px-3 py-2.5 text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{tNav(link.labelKey)}
</Link>
)
))}
<button
type="button"
onClick={() => {
setMobileOpen(false);
router.replace(pathname, { locale: locale === "zh" ? "en" : "zh" });
}}
className="block w-full rounded-lg px-3 py-2.5 text-left text-base font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
{locale === "zh" ? "EN" : "中文"}
</button>
{userEmail ? (
<div className="dn-mobile-account mt-2 flex items-center gap-3 rounded-lg border border-slate-100 bg-slate-50 px-4 py-2.5 dark:border-slate-700 dark:bg-slate-800">
<UserAvatar email={userEmail} vip={vip} onLogout={() => { setUserEmail(null); setVip(false); setMobileOpen(false); router.replace("/"); }} />
<Link
href="/profile"
onClick={() => setMobileOpen(false)}
className="min-w-0 flex-1"
>
<span className="block truncate text-sm font-semibold text-slate-700 dark:text-slate-200">{userEmail}</span>
<span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">{t("profile")}</span>
</Link>
</div>
) : (
<button
onClick={() => {
setMobileOpen(false);
setAuthOpen(true);
}}
className="dn-button-secondary mt-2 block w-full rounded-full border border-slate-200 px-4 py-2.5 text-center text-base font-medium text-slate-600 dark:border-slate-700 dark:text-slate-400"
>
{t("loginRegister")}
</button>
)}
<Link
href="/#roadmap"
onClick={() => setMobileOpen(false)}
className="dn-button-primary mt-2 block rounded-full bg-sky-500 px-4 py-2.5 text-center text-base font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
>
{t("explore")}
</Link>
</nav>
</div>
)}
<AuthModal
isOpen={authOpen}
onClose={() => setAuthOpen(false)}
onSuccess={() => fetchAuthAndVip()}
/>
</header>
);
}