mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
* fix(i18n): preserve remaining Vietnamese localization * chore(quality): rebaseline file-size cap for 9 dashboard components (i18n wiring) Restoring the Vietnamese localization on 9 dashboard components (useTranslations wiring + t()/tc() call-site swaps for previously hardcoded strings) grows each file by a small, irreducible amount. Bumps the frozen file-size-baseline.json caps to match, with a justification entry per the project's own ratchet policy. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * fix(i18n): wire weekday localization + add missing qwen CLI description Two gaps left by this PR's own new contract tests, caught while reconciling the branch against the release tip: - CostOverviewTab.tsx added formatWeekdayLabel() but never called it; the Weekly Usage Pattern chart still showed raw English day abbreviations regardless of locale. Now maps weeklyPattern rows through it before handing them to WeeklyPatternCard. - cliTools.toolDescriptions was missing an entry for "qwen" (a baseUrlSupport:"full" tool) in both en.json and vi.json, failing the PR's own cli-catalog-display-contract.test.ts. Covered by the PR's existing tests/unit/dashboard-localization-contract.test.ts and tests/unit/cli-catalog-display-contract.test.ts (both now pass). Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> * i18n(vi): backfill the 4 proxySubscription keys #7299 added to en.json #7299 (proxy subscriptions) merged while this branch was rebasing, adding settings.proxySubscriptionsTab and settings.proxySubscription.error.{LOCAL_CORE_ENDPOINT_INVALID, NEEDS_CORE_NOT_CONFIGURED,NO_USABLE_NODES} to en.json. This PR's own i18n-vi-completeness contract asserts full en↔vi key parity, so the merge of the current release tip surfaced them as missing. Adds the Vietnamese translations, keeping the SS/VMess/Trojan/VLESS/SOCKS5 technical terms verbatim. Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: nguyenha935 <208228297+nguyenha935@users.noreply.github.com> Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> Co-authored-by: nguyenha935 <nguyenha935@users.noreply.github.com>
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useSyncExternalStore } from "react";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
|
|
const subscribePlatform = () => () => {};
|
|
const getPlatformIsMac = () => {
|
|
if (typeof navigator === "undefined") return false;
|
|
const platform = navigator.platform || navigator.userAgent;
|
|
return /Mac|iPhone|iPad|iPod/.test(platform);
|
|
};
|
|
const getPlatformIsMacServer = () => false;
|
|
import ThemeToggle from "./ThemeToggle";
|
|
import TokenHealthBadge from "./TokenHealthBadge";
|
|
import DegradationBadge from "./DegradationBadge";
|
|
import LanguageSelector from "./LanguageSelector";
|
|
import ProviderIcon from "./ProviderIcon";
|
|
import { useTranslations } from "next-intl";
|
|
import {
|
|
OAUTH_PROVIDERS,
|
|
APIKEY_PROVIDERS,
|
|
NOAUTH_PROVIDERS,
|
|
CLAUDE_CODE_COMPATIBLE_PREFIX,
|
|
OPENAI_COMPATIBLE_PREFIX,
|
|
ANTHROPIC_COMPATIBLE_PREFIX,
|
|
} from "@/shared/constants/providers";
|
|
import {
|
|
SIDEBAR_SECTIONS,
|
|
getSectionItems,
|
|
type SidebarItemDefinition,
|
|
type HideableSidebarItemId,
|
|
} from "@/shared/constants/sidebarVisibility";
|
|
import { useIsElectron } from "@/shared/hooks/useElectron";
|
|
|
|
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
|
|
|
|
// Map sidebar item id → header description i18n key
|
|
// "omni-skills" is an extended key for the /dashboard/omni-skills route (graceful fallback during deploy)
|
|
const HEADER_DESCRIPTIONS: Partial<Record<HideableSidebarItemId | "omni-skills", string>> = {
|
|
home: "homeDescription",
|
|
endpoints: "endpointDescription",
|
|
"api-manager": "apiManagerDescription",
|
|
providers: "providerDescription",
|
|
combos: "comboDescription",
|
|
batch: "batchDescription",
|
|
costs: "costsDescription",
|
|
analytics: "analyticsDescription",
|
|
cache: "cacheDescription",
|
|
quota: "limitsDescription",
|
|
runtime: "runtimeDescription",
|
|
media: "mediaDescription",
|
|
"cli-code": "cliToolsDescription",
|
|
"cli-agents": "agentsDescription",
|
|
"acp-agents": "agentsDescription",
|
|
"cloud-agents": "cloudAgentsDescription",
|
|
memory: "memoryDescription",
|
|
skills: "skillsDescription",
|
|
"agent-skills": "agentSkillsDescription",
|
|
"omni-skills": "omniSkillsDescription",
|
|
settings: "settingsDescription",
|
|
"context-caveman": "contextCavemanDescription",
|
|
"context-rtk": "contextRtkDescription",
|
|
"context-combos": "contextCombosDescription",
|
|
translator: "translatorDescription",
|
|
playground: "playgroundDescription",
|
|
"search-tools": "searchToolsDescription",
|
|
logs: "logsDescription",
|
|
audit: "auditDescription",
|
|
webhooks: "webhooksDescription",
|
|
health: "healthDescription",
|
|
proxy: "proxyDescription",
|
|
changelog: "changelogDescription",
|
|
// Protocols
|
|
mcp: "mcpDescription",
|
|
a2a: "a2aDescription",
|
|
"api-endpoints": "apiEndpointsDescription",
|
|
// Agents & AI sub-pages
|
|
"batch-files": "batchFilesDescription",
|
|
// Analytics sub-pages
|
|
"analytics-evals": "analyticsEvalsDescription",
|
|
"analytics-search": "analyticsSearchDescription",
|
|
"analytics-utilization": "analyticsUtilizationDescription",
|
|
"analytics-combo-health": "analyticsComboHealthDescription",
|
|
"analytics-compression": "analyticsCompressionDescription",
|
|
// Costs sub-pages
|
|
"costs-budget": "costsBudgetDescription",
|
|
"costs-pricing": "costsPricingDescription",
|
|
// Logs sub-pages
|
|
"logs-proxy": "logsProxyDescription",
|
|
"logs-console": "logsConsoleDescription",
|
|
"logs-activity": "logsActivityDescription",
|
|
// Audit sub-pages
|
|
"audit-mcp": "auditMcpDescription",
|
|
// Settings sub-pages
|
|
"settings-general": "settingsGeneralDescription",
|
|
"settings-appearance": "settingsAppearanceDescription",
|
|
"settings-ai": "settingsAiDescription",
|
|
"settings-security": "settingsSecurityDescription",
|
|
"settings-routing": "settingsRoutingDescription",
|
|
"settings-resilience": "settingsResilienceDescription",
|
|
"settings-advanced": "settingsAdvancedDescription",
|
|
// Proxy sub-pages
|
|
"mitm-proxy": "mitmProxyDescription",
|
|
"1proxy": "oneProxyDescription",
|
|
};
|
|
|
|
// Build href → sidebar item lookup (non-external items only)
|
|
const sidebarByHref = new Map<string, SidebarItemDefinition>();
|
|
for (const section of SIDEBAR_SECTIONS) {
|
|
for (const item of getSectionItems(section)) {
|
|
if (!item.external) sidebarByHref.set(item.href, item);
|
|
}
|
|
}
|
|
|
|
function getSidebarItem(pathname: string): SidebarItemDefinition | undefined {
|
|
const exact = sidebarByHref.get(pathname);
|
|
if (exact) return exact;
|
|
// Longest prefix match
|
|
let best: SidebarItemDefinition | undefined;
|
|
let bestLen = 0;
|
|
for (const [href, item] of sidebarByHref) {
|
|
if (pathname.startsWith(href) && href.length > bestLen) {
|
|
best = item;
|
|
bestLen = href.length;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
type HeaderProps = {
|
|
onMenuClick?: () => void;
|
|
onOpenCommandPalette?: () => void;
|
|
showMenuButton?: boolean;
|
|
};
|
|
|
|
type PageInfo = {
|
|
title: string;
|
|
description: string;
|
|
icon?: string;
|
|
providerId?: string;
|
|
};
|
|
|
|
function usePageInfo(pathname: string | null): PageInfo {
|
|
const ts = useTranslations("sidebar");
|
|
const th = useTranslations("header");
|
|
|
|
if (!pathname) return { title: "", description: "" };
|
|
|
|
// Special: provider detail page /dashboard/providers/[id]
|
|
const providerMatch = pathname.match(/\/providers\/([^/]+)$/);
|
|
if (providerMatch) {
|
|
const pid = providerMatch[1];
|
|
const info = OAUTH_PROVIDERS[pid] || NOAUTH_PROVIDERS[pid] || APIKEY_PROVIDERS[pid];
|
|
if (info) return { title: info.name, description: "", providerId: info.id };
|
|
if (pid.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX))
|
|
return { title: "CC Compatible", description: "", providerId: "claude" };
|
|
if (pid.startsWith(OPENAI_COMPATIBLE_PREFIX))
|
|
return { title: th("openaiCompatible"), description: "", providerId: "oai-cc" };
|
|
if (pid.startsWith(ANTHROPIC_COMPATIBLE_PREFIX))
|
|
return { title: th("anthropicCompatible"), description: "", providerId: "anthropic-m" };
|
|
}
|
|
|
|
// Derive from sidebar
|
|
const item = getSidebarItem(pathname);
|
|
if (item) {
|
|
const descKey = HEADER_DESCRIPTIONS[item.id];
|
|
return {
|
|
title: ts(item.i18nKey),
|
|
description: descKey ? th(descKey) : "",
|
|
icon: item.icon,
|
|
};
|
|
}
|
|
|
|
return { title: "", description: "" };
|
|
}
|
|
|
|
export default function Header({
|
|
onMenuClick,
|
|
onOpenCommandPalette,
|
|
showMenuButton = true,
|
|
}: HeaderProps) {
|
|
const isMac = useSyncExternalStore(subscribePlatform, getPlatformIsMac, getPlatformIsMacServer);
|
|
const pathname = usePathname();
|
|
const router = useRouter();
|
|
const isElectron = useIsElectron();
|
|
const t = useTranslations("header");
|
|
const { title, description, icon, providerId } = usePageInfo(pathname);
|
|
const isMacElectron =
|
|
isElectron &&
|
|
typeof window !== "undefined" &&
|
|
(window as any).electronAPI?.platform === "darwin";
|
|
|
|
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="sticky top-0 z-10 flex items-center justify-between border-b border-black/5 bg-bg px-8 py-4 dark:border-white/5"
|
|
style={{
|
|
paddingTop: isMacElectron ? "calc(1rem + var(--desktop-safe-top))" : undefined,
|
|
}}
|
|
>
|
|
{/* 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 icon - desktop */}
|
|
<div className="hidden lg:flex items-center gap-3">
|
|
{(icon || providerId) && (
|
|
<div className="flex items-center justify-center size-9 rounded-lg bg-primary/10 shrink-0">
|
|
{icon ? (
|
|
<span className="material-symbols-outlined text-primary text-[20px]">{icon}</span>
|
|
) : (
|
|
providerId && <ProviderIcon providerId={providerId} size={22} type="color" />
|
|
)}
|
|
</div>
|
|
)}
|
|
{title && (
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-text-main tracking-tight">{title}</h1>
|
|
{description && <p className="text-xs text-text-muted mt-0.5">{description}</p>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right actions */}
|
|
<div className="flex items-center gap-3 ml-auto">
|
|
{onOpenCommandPalette && (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={onOpenCommandPalette}
|
|
className="hidden md:inline-flex items-center gap-2 px-2.5 py-1.5 rounded-lg border border-black/10 dark:border-white/10 bg-bg-subtle text-text-muted hover:text-text-main hover:bg-black/[0.04] dark:hover:bg-white/[0.04] transition-colors"
|
|
title={t("quickNavigationTitle")}
|
|
aria-label={t("openQuickNavigation")}
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">search</span>
|
|
<span className="text-xs">{t("quickNavigation")}</span>
|
|
<kbd className="hidden lg:inline-flex font-mono text-[10px] px-1 py-0.5 rounded bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10">
|
|
{isMac ? "⌘K" : "Ctrl+K"}
|
|
</kbd>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onOpenCommandPalette}
|
|
className="md:hidden p-2 rounded-lg text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
|
aria-label={t("openQuickNavigation")}
|
|
>
|
|
<span className="material-symbols-outlined">search</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
<LanguageSelector />
|
|
<ThemeToggle />
|
|
{!isE2EMode && <DegradationBadge />}
|
|
{!isE2EMode && <TokenHealthBadge />}
|
|
<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={t("logout")}
|
|
aria-label={t("logout")}
|
|
>
|
|
<span className="material-symbols-outlined">logout</span>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|