| "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";
|
|
|
|
|
|
|
| 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",
|
|
|
| mcp: "mcpDescription",
|
| a2a: "a2aDescription",
|
| "api-endpoints": "apiEndpointsDescription",
|
|
|
| "batch-files": "batchFilesDescription",
|
|
|
| "analytics-evals": "analyticsEvalsDescription",
|
| "analytics-search": "analyticsSearchDescription",
|
| "analytics-utilization": "analyticsUtilizationDescription",
|
| "analytics-combo-health": "analyticsComboHealthDescription",
|
| "analytics-compression": "analyticsCompressionDescription",
|
|
|
| "costs-budget": "costsBudgetDescription",
|
| "costs-pricing": "costsPricingDescription",
|
|
|
| "logs-proxy": "logsProxyDescription",
|
| "logs-console": "logsConsoleDescription",
|
| "logs-activity": "logsActivityDescription",
|
|
|
| "audit-mcp": "auditMcpDescription",
|
|
|
| "settings-general": "settingsGeneralDescription",
|
| "settings-appearance": "settingsAppearanceDescription",
|
| "settings-ai": "settingsAiDescription",
|
| "settings-security": "settingsSecurityDescription",
|
| "settings-routing": "settingsRoutingDescription",
|
| "settings-resilience": "settingsResilienceDescription",
|
| "settings-advanced": "settingsAdvancedDescription",
|
|
|
| "mitm-proxy": "mitmProxyDescription",
|
| "1proxy": "oneProxyDescription",
|
| };
|
|
|
|
|
| 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;
|
|
|
| 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: "" };
|
|
|
|
|
| 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" };
|
| }
|
|
|
|
|
| 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="Quick navigation (⌘K / Ctrl+K)"
|
| aria-label="Open quick navigation"
|
| >
|
| <span className="material-symbols-outlined text-[16px]">search</span>
|
| <span className="text-xs">Quick nav</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="Open quick navigation"
|
| >
|
| <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")}
|
| >
|
| <span className="material-symbols-outlined">logout</span>
|
| </button>
|
| </div>
|
| </header>
|
| );
|
| }
|
|
|