Files
9router/src/shared/components/Header.js
eachann afb83f4563 feat: add runtime i18n with English, Vietnamese, and Simplified Chinese support
- Implement runtime i18n using MutationObserver for automatic DOM translation
- Add language switcher dropdown in dashboard header (EN/VI/ZH)
- Support 3 languages: English (default), Tiếng Việt, 简体中文
- Add translation files: vi.json (197 entries), zh-CN.json (513 entries, cleaned)
- Translate dashboard UI: sidebar menu, header, settings, MITM page
- Use cookie-based locale persistence with /api/locale endpoint
- Zero component changes required - translations applied at runtime
- Fix Header flicker on route change with key={pathname}

Co-authored-by: eachann <each1024@qq.com>
Based on PR #247 from decolua/9router with runtime approach

Made-with: Cursor
2026-03-06 10:57:42 +07:00

154 lines
6.1 KiB
JavaScript

"use client";
import { usePathname, useRouter } from "next/navigation";
import { useMemo } from "react";
import Link from "next/link";
import Image from "next/image";
import PropTypes from "prop-types";
import { ThemeToggle, LanguageSwitcher } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config";
import { translate } from "@/i18n/runtime";
const getPageInfo = (pathname) => {
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
// Provider detail page: /dashboard/providers/[id]
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
if (providerMatch) {
const providerId = providerMatch[1];
const providerInfo = OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId];
if (providerInfo) {
return {
title: providerInfo.name,
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: providerInfo.name, image: `/providers/${providerInfo.id}.png` }
]
};
}
}
if (pathname.includes("/providers")) return { title: "Providers", description: "Manage your AI provider connections", breadcrumbs: [] };
if (pathname.includes("/combos")) return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] };
if (pathname.includes("/usage")) return { title: "Usage & Analytics", description: "Monitor your API usage, token consumption, and request logs", breadcrumbs: [] };
if (pathname.includes("/mitm")) return { title: "MITM Proxy", description: "Intercept CLI tool traffic and route through 9Router", breadcrumbs: [] };
if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] };
if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] };
if (pathname.includes("/translator")) return { title: "Translator", description: "Debug translation flow between formats", breadcrumbs: [] };
if (pathname.includes("/console-log")) return { title: "Console Log", description: "Live server console output", breadcrumbs: [] };
if (pathname === "/dashboard") return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
return { title: "", description: "", breadcrumbs: [] };
};
export default function Header({ onMenuClick, showMenuButton = true }) {
const pathname = usePathname();
const router = useRouter();
// Memoize page info to prevent unnecessary recalculations
const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]);
const { title, description, breadcrumbs } = pageInfo;
const handleLogout = async () => {
try {
const res = await fetch("/api/auth/logout", { method: "POST" });
if (res.ok) {
router.push("/login");
router.refresh();
}
} catch (err) {
console.error("Failed to logout:", err);
}
};
return (
<header className="flex items-center justify-between px-8 py-5 border-b border-black/5 dark:border-white/5 bg-bg/80 backdrop-blur-xl z-10 sticky top-0">
{/* Mobile menu button */}
<div className="flex items-center gap-3 lg:hidden">
{showMenuButton && (
<button
onClick={onMenuClick}
className="text-text-main hover:text-primary transition-colors"
>
<span className="material-symbols-outlined">menu</span>
</button>
)}
</div>
{/* Page title with breadcrumbs - desktop */}
<div className="hidden lg:flex flex-col">
{breadcrumbs.length > 0 ? (
<div className="flex items-center gap-2">
{breadcrumbs.map((crumb, index) => (
<div key={`${crumb.label}-${crumb.href || "current"}`} className="flex items-center gap-2">
{index > 0 && (
<span className="material-symbols-outlined text-text-muted text-base">
chevron_right
</span>
)}
{crumb.href ? (
<Link
href={crumb.href}
className="text-text-muted hover:text-primary transition-colors"
>
{crumb.label}
</Link>
) : (
<div className="flex items-center gap-2">
{crumb.image && (
<Image
src={crumb.image}
alt={crumb.label}
width={28}
height={28}
className="object-contain rounded max-w-[28px] max-h-[28px]"
sizes="28px"
onError={(e) => { e.currentTarget.style.display = "none"; }}
/>
)}
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
{translate(crumb.label)}
</h1>
</div>
)}
</div>
))}
</div>
) : title ? (
<div>
<h1 className="text-2xl font-semibold text-text-main tracking-tight">{translate(title)}</h1>
{description && (
<p className="text-sm text-text-muted">{translate(description)}</p>
)}
</div>
) : null}
</div>
{/* Right actions */}
<div className="flex items-center gap-3 ml-auto">
{/* Language switcher */}
<LanguageSwitcher />
{/* Theme toggle */}
<ThemeToggle />
{/* Logout button */}
<button
onClick={handleLogout}
className="flex items-center justify-center p-2 rounded-lg text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-all"
title="Logout"
>
<span className="material-symbols-outlined">logout</span>
</button>
</div>
</header>
);
}
Header.propTypes = {
onMenuClick: PropTypes.func,
showMenuButton: PropTypes.bool,
};