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:
parent
b7b4ac5592
commit
afb83f4563
12 changed files with 1092 additions and 9 deletions
|
|
@ -324,7 +324,7 @@ export default function ProvidersPage() {
|
|||
API Key Compatible Providers{" "}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
|
||||
{/* {(compatibleProviders.length > 0 || anthropicCompatibleProviders.length > 0) && (
|
||||
<button
|
||||
onClick={() => handleBatchTest("compatible")}
|
||||
disabled={!!testingMode}
|
||||
|
|
@ -340,7 +340,7 @@ export default function ProvidersPage() {
|
|||
</span>
|
||||
{testingMode === "compatible" ? "Testing..." : "Test All"}
|
||||
</button>
|
||||
)}
|
||||
)} */}
|
||||
<Button size="sm" icon="add" onClick={() => setShowAddAnthropicCompatibleModal(true)}>
|
||||
Add Anthropic Compatible
|
||||
</Button>
|
||||
|
|
|
|||
30
src/app/api/locale/route.js
Normal file
30
src/app/api/locale/route.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
import { LOCALE_COOKIE, normalizeLocale, isSupportedLocale } from "@/i18n/config";
|
||||
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { locale } = await request.json();
|
||||
|
||||
if (!locale || !isSupportedLocale(locale)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid locale" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const normalized = normalizeLocale(locale);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(LOCALE_COOKIE, normalized, {
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 365, // 1 year
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, locale: normalized });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to set locale" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { ThemeProvider } from "@/shared/components/ThemeProvider";
|
|||
import "@/lib/initCloudSync"; // Auto-initialize cloud sync
|
||||
import "@/lib/network/initOutboundProxy"; // Auto-initialize outbound proxy env
|
||||
import { initConsoleLogCapture } from "@/lib/consoleLogBuffer";
|
||||
import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider";
|
||||
|
||||
// Hook console immediately at module load time (server-side only, runs once)
|
||||
initConsoleLogCapture();
|
||||
|
|
@ -39,7 +40,9 @@ export default function RootLayout({ children }) {
|
|||
</head>
|
||||
<body className={`${inter.variable} font-sans antialiased`}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<RuntimeI18nProvider>
|
||||
{children}
|
||||
</RuntimeI18nProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
27
src/i18n/RuntimeI18nProvider.js
Normal file
27
src/i18n/RuntimeI18nProvider.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { initRuntimeI18n, reloadTranslations } from "./runtime";
|
||||
|
||||
export function RuntimeI18nProvider({ children }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
initRuntimeI18n();
|
||||
}, []);
|
||||
|
||||
// Re-process DOM when route changes
|
||||
useEffect(() => {
|
||||
if (pathname) {
|
||||
// Double RAF to ensure React has committed changes to DOM
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
reloadTranslations();
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
26
src/i18n/config.js
Normal file
26
src/i18n/config.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const LOCALES = ["en", "vi", "zh-CN"];
|
||||
export const DEFAULT_LOCALE = "en";
|
||||
export const LOCALE_COOKIE = "locale";
|
||||
|
||||
export const LOCALE_NAMES = {
|
||||
"en": "English",
|
||||
"vi": "Tiếng Việt",
|
||||
"zh-CN": "简体中文"
|
||||
};
|
||||
|
||||
export function normalizeLocale(locale) {
|
||||
if (locale === "zh" || locale === "zh-CN") {
|
||||
return "zh-CN";
|
||||
}
|
||||
if (locale === "en") {
|
||||
return "en";
|
||||
}
|
||||
if (locale === "vi") {
|
||||
return "vi";
|
||||
}
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
export function isSupportedLocale(locale) {
|
||||
return LOCALES.includes(locale);
|
||||
}
|
||||
162
src/i18n/runtime.js
Normal file
162
src/i18n/runtime.js
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { DEFAULT_LOCALE, LOCALE_COOKIE, normalizeLocale } from "./config";
|
||||
|
||||
let translationMap = {};
|
||||
let currentLocale = DEFAULT_LOCALE;
|
||||
let reloadCallbacks = [];
|
||||
|
||||
// Read locale from cookie
|
||||
function getLocaleFromCookie() {
|
||||
if (typeof document === "undefined") return DEFAULT_LOCALE;
|
||||
const cookie = document.cookie
|
||||
.split(";")
|
||||
.find((c) => c.trim().startsWith(`${LOCALE_COOKIE}=`));
|
||||
const value = cookie ? decodeURIComponent(cookie.split("=")[1]) : DEFAULT_LOCALE;
|
||||
return normalizeLocale(value);
|
||||
}
|
||||
|
||||
// Load translation map
|
||||
async function loadTranslations(locale) {
|
||||
if (locale === "en") {
|
||||
translationMap = {};
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/i18n/literals/${locale}.json`);
|
||||
translationMap = await response.json();
|
||||
} catch (err) {
|
||||
console.error("Failed to load translations:", err);
|
||||
translationMap = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Translate text - exported for use in components
|
||||
export function translate(text) {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return text;
|
||||
if (currentLocale === "en") return text;
|
||||
return translationMap[trimmed] || text;
|
||||
}
|
||||
|
||||
// Get current locale - exported for use in components
|
||||
export function getCurrentLocale() {
|
||||
return currentLocale;
|
||||
}
|
||||
|
||||
// Register callback for locale changes
|
||||
export function onLocaleChange(callback) {
|
||||
reloadCallbacks.push(callback);
|
||||
return () => {
|
||||
reloadCallbacks = reloadCallbacks.filter(cb => cb !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
// Process text node
|
||||
function processTextNode(node) {
|
||||
if (!node.nodeValue || !node.nodeValue.trim()) return;
|
||||
|
||||
// Skip if parent is script, style, code, or structural elements
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
// Skip if parent or any ancestor has data-i18n-skip attribute
|
||||
let element = parent;
|
||||
while (element) {
|
||||
if (element.hasAttribute && element.hasAttribute('data-i18n-skip')) {
|
||||
return;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
|
||||
const tagName = parent.tagName?.toLowerCase();
|
||||
|
||||
// Skip elements that don't allow text nodes
|
||||
const skipTags = [
|
||||
"script", "style", "code", "pre",
|
||||
"colgroup", "table", "thead", "tbody", "tfoot", "tr",
|
||||
"select", "datalist", "optgroup"
|
||||
];
|
||||
|
||||
if (skipTags.includes(tagName)) return;
|
||||
|
||||
// Store original text if not already stored
|
||||
if (!node._originalText) {
|
||||
node._originalText = node.nodeValue;
|
||||
}
|
||||
|
||||
// Use original text for translation
|
||||
const original = node._originalText;
|
||||
const translated = translate(original);
|
||||
|
||||
// Only update if different to avoid unnecessary DOM mutations
|
||||
if (translated !== node.nodeValue) {
|
||||
node.nodeValue = translated;
|
||||
}
|
||||
}
|
||||
|
||||
// Process all text nodes in element
|
||||
function processElement(element) {
|
||||
if (!element) return;
|
||||
|
||||
const walker = document.createTreeWalker(
|
||||
element,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
false
|
||||
);
|
||||
|
||||
let node;
|
||||
const nodesToProcess = [];
|
||||
|
||||
// Collect all nodes first to avoid live collection issues
|
||||
while ((node = walker.nextNode())) {
|
||||
nodesToProcess.push(node);
|
||||
}
|
||||
|
||||
// Process collected nodes
|
||||
nodesToProcess.forEach(processTextNode);
|
||||
}
|
||||
|
||||
// Initialize runtime i18n
|
||||
export async function initRuntimeI18n() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
currentLocale = getLocaleFromCookie();
|
||||
await loadTranslations(currentLocale);
|
||||
|
||||
// Process existing DOM
|
||||
processElement(document.body);
|
||||
|
||||
// Watch for new nodes
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((node) => {
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
processElement(node);
|
||||
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||
processTextNode(node);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Reload translations when locale changes
|
||||
export async function reloadTranslations() {
|
||||
currentLocale = getLocaleFromCookie();
|
||||
await loadTranslations(currentLocale);
|
||||
|
||||
// Notify all registered callbacks
|
||||
reloadCallbacks.forEach(callback => callback());
|
||||
|
||||
// Re-process entire DOM (will use stored original text)
|
||||
processElement(document.body);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
113
src/shared/components/LanguageSwitcher.js
Normal file
113
src/shared/components/LanguageSwitcher.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue