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
This commit is contained in:
eachann 2026-03-06 10:57:42 +07:00 committed by decolua
parent b7b4ac5592
commit afb83f4563
12 changed files with 1092 additions and 9 deletions

View file

@ -1,11 +1,13 @@
"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 } from "@/shared/components";
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: [] };
@ -43,7 +45,10 @@ const getPageInfo = (pathname) => {
export default function Header({ onMenuClick, showMenuButton = true }) {
const pathname = usePathname();
const router = useRouter();
const { title, description, breadcrumbs } = getPageInfo(pathname);
// Memoize page info to prevent unnecessary recalculations
const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]);
const { title, description, breadcrumbs } = pageInfo;
const handleLogout = async () => {
try {
@ -103,7 +108,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
/>
)}
<h1 className="text-2xl font-semibold text-text-main tracking-tight">
{crumb.label}
{translate(crumb.label)}
</h1>
</div>
)}
@ -112,9 +117,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
</div>
) : title ? (
<div>
<h1 className="text-2xl font-semibold text-text-main tracking-tight">{title}</h1>
<h1 className="text-2xl font-semibold text-text-main tracking-tight">{translate(title)}</h1>
{description && (
<p className="text-sm text-text-muted">{description}</p>
<p className="text-sm text-text-muted">{translate(description)}</p>
)}
</div>
) : null}
@ -122,6 +127,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
{/* Right actions */}
<div className="flex items-center gap-3 ml-auto">
{/* Language switcher */}
<LanguageSwitcher />
{/* Theme toggle */}
<ThemeToggle />

View file

@ -0,0 +1,113 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { LOCALES, LOCALE_COOKIE, normalizeLocale } from "@/i18n/config";
import { reloadTranslations } from "@/i18n/runtime";
function getLocaleFromCookie() {
if (typeof document === "undefined") return "en";
const cookie = document.cookie
.split(";")
.find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`));
const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : "en";
return normalizeLocale(value);
}
// Locale display names - will be translated by runtime i18n
const getLocaleName = (locale) => {
const names = {
"en": "English",
"vi": "Tiếng Việt",
"zh-CN": "简体中文"
};
return names[locale] || locale;
};
export default function LanguageSwitcher({ className = "" }) {
const [locale, setLocale] = useState("en");
const [isPending, setIsPending] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef(null);
useEffect(() => {
setLocale(getLocaleFromCookie());
}, []);
// Close dropdown when clicking outside
useEffect(() => {
function handleClickOutside(event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const handleSetLocale = async (nextLocale) => {
if (nextLocale === locale || isPending) return;
setIsPending(true);
setIsOpen(false);
try {
await fetch("/api/locale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ locale: nextLocale }),
});
// Reload translations without full page reload
await reloadTranslations();
setLocale(nextLocale);
} catch (err) {
console.error("Failed to set locale:", err);
} finally {
setIsPending(false);
}
};
return (
<div className={`relative ${className}`} ref={dropdownRef}>
{/* Dropdown trigger - use data attribute to prevent i18n processing */}
<button
onClick={() => setIsOpen(!isOpen)}
disabled={isPending}
className="flex items-center gap-2 px-3 py-2 rounded-lg text-text-muted hover:text-text-main hover:bg-surface/60 transition-colors"
title="Language"
data-i18n-skip="true"
>
<span className="material-symbols-outlined text-xl">language</span>
<span className="text-sm font-medium">{getLocaleName(locale)}</span>
<span className="material-symbols-outlined text-base">
{isOpen ? "expand_less" : "expand_more"}
</span>
</button>
{/* Dropdown menu - use data attribute to prevent i18n processing */}
{isOpen && (
<div className="absolute right-0 mt-2 w-48 bg-surface border border-black/10 dark:border-white/10 rounded-lg shadow-lg overflow-hidden z-50" data-i18n-skip="true">
{LOCALES.map((item) => {
const active = locale === item;
return (
<button
key={item}
onClick={() => handleSetLocale(item)}
disabled={isPending}
className={`w-full flex items-center justify-between px-4 py-2.5 text-sm transition-colors ${
active
? "bg-primary/15 text-primary font-medium"
: "text-text-main hover:bg-surface-hover"
} ${isPending ? "opacity-70 cursor-wait" : ""}`}
>
<span>{getLocaleName(item)}</span>
{active && (
<span className="material-symbols-outlined text-base">check</span>
)}
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -17,6 +17,7 @@ export { default as OAuthModal } from "./OAuthModal";
export { default as ModelSelectModal } from "./ModelSelectModal";
export { default as ManualConfigModal } from "./ManualConfigModal";
export { default as UsageStats } from "./UsageStats";
export { default as LanguageSwitcher } from "./LanguageSwitcher";
export { default as RequestLogger } from "./RequestLogger";
export { default as KiroAuthModal } from "./KiroAuthModal";
export { default as KiroOAuthWrapper } from "./KiroOAuthWrapper";

View file

@ -1,11 +1,13 @@
"use client";
import { useState } from "react";
import { usePathname } from "next/navigation";
import Sidebar from "../Sidebar";
import Header from "../Header";
export default function DashboardLayout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const pathname = usePathname();
return (
<div className="flex h-screen w-full overflow-hidden bg-bg">
@ -33,7 +35,7 @@ export default function DashboardLayout({ children }) {
{/* Main content */}
<main className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300">
<Header onMenuClick={() => setSidebarOpen(true)} />
<Header key={pathname} onMenuClick={() => setSidebarOpen(true)} />
<div className="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10">
<div className="max-w-7xl mx-auto">{children}</div>
</div>