feat: add dashboard i18n with next-intl (EN + PT-BR), language selector in header

This commit is contained in:
diegosouzapw
2026-02-25 13:13:35 -03:00
parent 5811e677f1
commit f7fb68a798
11 changed files with 1062 additions and 57 deletions

View File

@@ -6,6 +6,8 @@ import Image from "next/image";
import PropTypes from "prop-types";
import { ThemeToggle } from "@/shared/components";
import TokenHealthBadge from "./TokenHealthBadge";
import LanguageSelector from "./LanguageSelector";
import { useTranslations } from "next-intl";
import {
OAUTH_PROVIDERS,
APIKEY_PROVIDERS,
@@ -14,7 +16,9 @@ import {
ANTHROPIC_COMPATIBLE_PREFIX,
} from "@/shared/constants/providers";
const getPageInfo = (pathname) => {
function usePageInfo(pathname: string | null) {
const t = useTranslations("header");
if (!pathname) return { title: "", description: "", breadcrumbs: [] };
// Provider detail page: /dashboard/providers/[id]
@@ -29,7 +33,7 @@ const getPageInfo = (pathname) => {
title: providerInfo.name,
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: t("providers"), href: "/dashboard/providers" },
{ label: providerInfo.name, image: `/providers/${providerInfo.id}.png` },
],
};
@@ -37,22 +41,22 @@ const getPageInfo = (pathname) => {
if (providerId.startsWith(OPENAI_COMPATIBLE_PREFIX)) {
return {
title: "OpenAI Compatible",
title: t("openaiCompatible"),
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: "OpenAI Compatible", image: "/providers/oai-cc.png" },
{ label: t("providers"), href: "/dashboard/providers" },
{ label: t("openaiCompatible"), image: "/providers/oai-cc.png" },
],
};
}
if (providerId.startsWith(ANTHROPIC_COMPATIBLE_PREFIX)) {
return {
title: "Anthropic Compatible",
title: t("anthropicCompatible"),
description: "",
breadcrumbs: [
{ label: "Providers", href: "/dashboard/providers" },
{ label: "Anthropic Compatible", image: "/providers/anthropic-m.png" },
{ label: t("providers"), href: "/dashboard/providers" },
{ label: t("anthropicCompatible"), image: "/providers/anthropic-m.png" },
],
};
}
@@ -60,40 +64,41 @@ const getPageInfo = (pathname) => {
if (pathname.includes("/providers"))
return {
title: "Providers",
description: "Manage your AI provider connections",
title: t("providers"),
description: t("providerDescription"),
breadcrumbs: [],
};
if (pathname.includes("/combos"))
return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] };
return { title: t("combos"), description: t("comboDescription"), breadcrumbs: [] };
if (pathname.includes("/usage"))
return {
title: "Usage & Analytics",
description: "Monitor your API usage, token consumption, and request logs",
title: t("usage"),
description: t("usageDescription"),
breadcrumbs: [],
};
if (pathname.includes("/analytics"))
return {
title: "Analytics",
description: "Charts, trends, and evaluation insights",
title: t("analytics"),
description: t("analyticsDescription"),
breadcrumbs: [],
};
if (pathname.includes("/cli-tools"))
return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] };
return { title: t("cliTools"), description: t("cliToolsDescription"), breadcrumbs: [] };
if (pathname === "/dashboard")
return { title: "Home", description: "Welcome to OmniRoute", breadcrumbs: [] };
return { title: t("home"), description: t("homeDescription"), breadcrumbs: [] };
if (pathname.includes("/endpoint"))
return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] };
return { title: t("endpoint"), description: t("endpointDescription"), breadcrumbs: [] };
if (pathname.includes("/profile"))
return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] };
return { title: t("settings"), description: t("settingsDescription"), breadcrumbs: [] };
return { title: "", description: "", breadcrumbs: [] };
};
}
export default function Header({ onMenuClick, showMenuButton = true }) {
const pathname = usePathname();
const router = useRouter();
const { title, description, breadcrumbs } = getPageInfo(pathname);
const t = useTranslations("header");
const { title, description, breadcrumbs } = usePageInfo(pathname);
const handleLogout = async () => {
try {
@@ -175,6 +180,9 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
{/* Right actions */}
<div className="flex items-center gap-3 ml-auto">
{/* Language selector */}
<LanguageSelector />
{/* Theme toggle */}
<ThemeToggle />
@@ -185,7 +193,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
<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"
title={t("logout")}
>
<span className="material-symbols-outlined">logout</span>
</button>

View File

@@ -0,0 +1,90 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation";
import { LANGUAGES, LOCALE_COOKIE } from "@/i18n/config";
import type { Locale } from "@/i18n/config";
import { useLocale } from "next-intl";
/** Persist locale preference in cookie + localStorage (outside component scope for ESLint) */
function persistLocale(code: Locale) {
document.cookie = `${LOCALE_COOKIE}=${code};path=/;max-age=${365 * 24 * 60 * 60};samesite=lax`;
try {
localStorage.setItem(LOCALE_COOKIE, code);
} catch {
// Ignore
}
}
export default function LanguageSelector() {
const locale = useLocale();
const router = useRouter();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const currentLang = LANGUAGES.find((l) => l.code === locale) || LANGUAGES[0];
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const handleSelect = (code: Locale) => {
if (code === locale) {
setOpen(false);
return;
}
persistLocale(code);
setOpen(false);
router.refresh();
};
return (
<div ref={ref} className="relative">
{/* Trigger button */}
<button
onClick={() => setOpen(!open)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm font-medium text-text-main hover:bg-surface-hover transition-all border border-transparent hover:border-border"
title={currentLang.name}
>
<span className="text-base leading-none">{currentLang.flag}</span>
<span className="text-xs font-semibold tracking-wide">{currentLang.label}</span>
<span
className={`material-symbols-outlined text-[14px] text-text-muted transition-transform ${open ? "rotate-180" : ""}`}
>
expand_more
</span>
</button>
{/* Dropdown */}
{open && (
<div className="absolute right-0 top-full mt-1 w-40 rounded-xl border border-border bg-bg shadow-xl z-50 overflow-hidden animate-in fade-in slide-in-from-top-1 duration-150">
{LANGUAGES.map((lang) => (
<button
key={lang.code}
onClick={() => handleSelect(lang.code)}
className={`w-full flex items-center gap-2.5 px-3 py-2.5 text-sm transition-colors ${
lang.code === locale
? "bg-primary/10 text-primary font-semibold"
: "text-text-main hover:bg-surface-hover"
}`}
>
<span className="text-base leading-none">{lang.flag}</span>
<span className="flex-1 text-left">{lang.name}</span>
{lang.code === locale && (
<span className="material-symbols-outlined text-[16px] text-primary">check</span>
)}
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -10,31 +10,32 @@ import OmniRouteLogo from "./OmniRouteLogo";
import Button from "./Button";
import { ConfirmModal } from "./Modal";
import CloudSyncStatus from "./CloudSyncStatus";
import { useTranslations } from "next-intl";
const navItems = [
{ href: "/dashboard", label: "Home", icon: "home", exact: true },
{ href: "/dashboard/endpoint", label: "Endpoint", icon: "api" },
{ href: "/dashboard/api-manager", label: "API Manager", icon: "vpn_key" },
{ href: "/dashboard/providers", label: "Providers", icon: "dns" },
{ href: "/dashboard/combos", label: "Combos", icon: "layers" },
{ href: "/dashboard/logs", label: "Logs", icon: "description" },
{ href: "/dashboard/costs", label: "Costs", icon: "account_balance_wallet" },
{ href: "/dashboard/analytics", label: "Analytics", icon: "analytics" },
{ href: "/dashboard/limits", label: "Limits & Quotas", icon: "tune" },
{ href: "/dashboard/health", label: "Health", icon: "health_and_safety" },
{ href: "/dashboard/cli-tools", label: "CLI Tools", icon: "terminal" },
// Nav items use i18n keys resolved inside the component
const navItemDefs = [
{ href: "/dashboard", i18nKey: "home", icon: "home", exact: true },
{ href: "/dashboard/endpoint", i18nKey: "endpoint", icon: "api" },
{ href: "/dashboard/api-manager", i18nKey: "apiManager", icon: "vpn_key" },
{ href: "/dashboard/providers", i18nKey: "providers", icon: "dns" },
{ href: "/dashboard/combos", i18nKey: "combos", icon: "layers" },
{ href: "/dashboard/logs", i18nKey: "logs", icon: "description" },
{ href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
{ href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
{ href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ href: "/dashboard/cli-tools", i18nKey: "cliTools", icon: "terminal" },
];
// Debug items (only show when ENABLE_REQUEST_LOGS=true)
const debugItems = [{ href: "/dashboard/translator", label: "Translator", icon: "translate" }];
const debugItemDefs = [{ href: "/dashboard/translator", i18nKey: "translator", icon: "translate" }];
const systemItems = [{ href: "/dashboard/settings", label: "Settings", icon: "settings" }];
const systemItemDefs = [{ href: "/dashboard/settings", i18nKey: "settings", icon: "settings" }];
const helpItems = [
{ href: "/docs", label: "Docs", icon: "menu_book" },
const helpItemDefs = [
{ href: "/docs", i18nKey: "docs", icon: "menu_book" },
{
href: "https://github.com/diegosouzapw/OmniRoute/issues",
label: "Issues",
i18nKey: "issues",
icon: "bug_report",
external: true,
},
@@ -50,6 +51,8 @@ export default function Sidebar({
onToggleCollapse?: any;
}) {
const pathname = usePathname();
const t = useTranslations("sidebar");
const tc = useTranslations("common");
const [showShutdownModal, setShowShutdownModal] = useState(false);
const [showRestartModal, setShowRestartModal] = useState(false);
const [isShuttingDown, setIsShuttingDown] = useState(false);
@@ -100,6 +103,13 @@ export default function Sidebar({
}, 3000);
};
// Resolve i18n keys → labels
const resolveItems = (defs) => defs.map((d) => ({ ...d, label: t(d.i18nKey) }));
const navItems = resolveItems(navItemDefs);
const debugItems = resolveItems(debugItemDefs);
const systemItems = resolveItems(systemItemDefs);
const helpItems = resolveItems(helpItemDefs);
const renderNavLink = (item) => {
const active = !item.external && isActive(item.href, item.exact);
const className = cn(
@@ -271,7 +281,7 @@ export default function Sidebar({
>
<button
onClick={() => setShowRestartModal(true)}
title="Restart server"
title={t("restart")}
className={cn(
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
"text-amber-500 hover:bg-amber-500/10 border border-amber-500/20 hover:border-amber-500/40",
@@ -279,11 +289,11 @@ export default function Sidebar({
)}
>
<span className="material-symbols-outlined text-[18px]">restart_alt</span>
{!collapsed && "Restart"}
{!collapsed && t("restart")}
</button>
<button
onClick={() => setShowShutdownModal(true)}
title="Shutdown server"
title={t("shutdown")}
className={cn(
"flex items-center justify-center gap-2 rounded-lg font-medium transition-all",
"text-red-500 hover:bg-red-500/10 border border-red-500/20 hover:border-red-500/40",
@@ -291,7 +301,7 @@ export default function Sidebar({
)}
>
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
{!collapsed && "Shutdown"}
{!collapsed && t("shutdown")}
</button>
</div>
</aside>
@@ -301,10 +311,10 @@ export default function Sidebar({
isOpen={showShutdownModal}
onClose={() => setShowShutdownModal(false)}
onConfirm={handleShutdown}
title="Close Proxy"
message="Are you sure you want to close the proxy server?"
confirmText="Close"
cancelText="Cancel"
title={t("shutdown")}
message={t("shutdownConfirm")}
confirmText={t("shutdown")}
cancelText={tc("cancel")}
variant="danger"
loading={isShuttingDown}
/>
@@ -314,10 +324,10 @@ export default function Sidebar({
isOpen={showRestartModal}
onClose={() => setShowRestartModal(false)}
onConfirm={handleRestart}
title="Restart Proxy"
message="Are you sure you want to restart the proxy server? It will be back online in a few seconds."
confirmText="Restart"
cancelText="Cancel"
title={t("restart")}
message={t("restartConfirm")}
confirmText={t("restart")}
cancelText={tc("cancel")}
variant="warning"
loading={isRestarting}
/>