Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useEffect, useMemo, useState } from "react"; | |
| import { usePathname } from "next/navigation"; | |
| import Link from "next/link"; | |
| import PropTypes from "prop-types"; | |
| import ProviderIcon from "@/shared/components/ProviderIcon"; | |
| import HeaderMenu from "@/shared/components/HeaderMenu"; | |
| import HeaderLanguage from "@/shared/components/HeaderLanguage"; | |
| import ThemeToggle from "@/shared/components/ThemeToggle"; | |
| import DonateModal from "@/shared/components/DonateModal"; | |
| import { useHeaderSearchStore } from "@/store/headerSearchStore"; | |
| import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; | |
| import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS } from "@/shared/constants/providers"; | |
| import { translate } from "@/i18n/runtime"; | |
| const getPageInfo = (pathname) => { | |
| if (!pathname) return { title: "", description: "", breadcrumbs: [] }; | |
| // Media provider detail: /dashboard/media-providers/[kind]/[id] | |
| const mediaDetailMatch = pathname.match(/\/media-providers\/([^/]+)\/([^/]+)$/); | |
| if (mediaDetailMatch) { | |
| const kindId = mediaDetailMatch[1]; | |
| const providerId = mediaDetailMatch[2]; | |
| const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kindId); | |
| const provider = AI_PROVIDERS[providerId]; | |
| return { | |
| title: provider?.name || providerId, | |
| description: "", | |
| breadcrumbs: [ | |
| { label: "Media Providers", href: `/dashboard/media-providers/${kindId}` }, | |
| { label: kindConfig?.label || kindId, href: `/dashboard/media-providers/${kindId}` }, | |
| { label: provider?.name || providerId, image: `/providers/${providerId}.png` }, | |
| ], | |
| }; | |
| } | |
| // Media provider kind: /dashboard/media-providers/[kind] | |
| const mediaKindMatch = pathname.match(/\/media-providers\/([^/]+)$/); | |
| if (mediaKindMatch) { | |
| const kindId = mediaKindMatch[1]; | |
| const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kindId); | |
| return { | |
| title: kindConfig?.label || kindId, | |
| description: `Manage your ${kindConfig?.label || kindId} providers`, | |
| icon: kindConfig?.icon || "perm_media", | |
| breadcrumbs: [], | |
| }; | |
| } | |
| // Provider detail page: /dashboard/providers/[id] | |
| const providerMatch = pathname.match(/\/providers\/([^/]+)$/); | |
| if (providerMatch) { | |
| const providerId = providerMatch[1]; | |
| const providerInfo = | |
| OAUTH_PROVIDERS[providerId] || APIKEY_PROVIDERS[providerId]; | |
| if (providerInfo) { | |
| return { | |
| title: providerInfo.name, | |
| description: "", | |
| breadcrumbs: [ | |
| { label: "Providers", href: "/dashboard/providers" }, | |
| { | |
| label: providerInfo.name, | |
| image: `/providers/${providerInfo.id}.png`, | |
| }, | |
| ], | |
| }; | |
| } | |
| } | |
| if (pathname.includes("/providers") && !pathname.includes("/media-providers")) | |
| return { | |
| title: "Providers", | |
| description: "Manage your AI provider connections", | |
| icon: "dns", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/combos")) | |
| return { | |
| title: "Combos", | |
| description: "Model combos with fallback", | |
| icon: "layers", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/usage")) | |
| return { | |
| title: "Usage & Analytics", | |
| description: | |
| "Monitor your API usage, token consumption, and request logs", | |
| icon: "bar_chart", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/auth-files")) | |
| return { | |
| title: "Auth Files", | |
| description: "Map provider credentials stored in the local database", | |
| icon: "vpn_key", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/quota")) | |
| return { | |
| title: "Quota Tracker", | |
| description: "Track and manage your API quota limits", | |
| icon: "data_usage", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/mitm")) | |
| return { | |
| title: "MITM Proxy", | |
| description: "Intercept CLI tool traffic and route through 9Router", | |
| icon: "security", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/token-saver")) | |
| return { | |
| title: "Token Saver", | |
| description: "Compress prompts and outputs to save tokens", | |
| icon: "savings", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/cli-tools")) | |
| return { | |
| title: "CLI Tools", | |
| description: "Configure CLI tools", | |
| icon: "terminal", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/proxy-pools")) | |
| return { | |
| title: "Proxy Pools", | |
| description: "Manage your proxy pool configurations", | |
| icon: "lan", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/skills")) | |
| return { | |
| title: "Agent Skills", | |
| description: "Copy a link and paste to your AI to use 9Router — no install needed", | |
| icon: "extension", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/endpoint")) | |
| return { | |
| title: "Endpoint", | |
| description: "API endpoint configuration", | |
| icon: "api", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/profile")) | |
| return { | |
| title: "Settings", | |
| description: "Manage your preferences", | |
| icon: "settings", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/translator")) | |
| return { | |
| title: "Translator", | |
| description: "Debug translation flow between formats", | |
| icon: "translate", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname.includes("/console-log")) | |
| return { | |
| title: "Console Log", | |
| description: "Live server console output", | |
| icon: "monitor", | |
| breadcrumbs: [], | |
| }; | |
| if (pathname === "/dashboard") | |
| return { | |
| title: "Endpoint", | |
| description: "API endpoint configuration", | |
| icon: "api", | |
| breadcrumbs: [], | |
| }; | |
| return { title: "", description: "", breadcrumbs: [] }; | |
| }; | |
| export default function Header({ onMenuClick, showMenuButton = true }) { | |
| const pathname = usePathname(); | |
| const [displayName, setDisplayName] = useState(""); | |
| const [loginMethod, setLoginMethod] = useState(""); | |
| const [donateOpen, setDonateOpen] = useState(false); | |
| // Memoize page info to prevent unnecessary recalculations | |
| const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]); | |
| const { title, description, icon, breadcrumbs } = pageInfo; | |
| useEffect(() => { | |
| let cancelled = false; | |
| async function loadAuthStatus() { | |
| try { | |
| const res = await fetch("/api/auth/status", { cache: "no-store" }); | |
| if (!res.ok) return; | |
| const data = await res.json(); | |
| if (!cancelled) { | |
| setDisplayName(data?.displayName || data?.oidcName || data?.oidcEmail || ""); | |
| setLoginMethod(data?.loginMethod || ""); | |
| } | |
| } catch { | |
| if (!cancelled) { | |
| setDisplayName(""); | |
| setLoginMethod(""); | |
| } | |
| } | |
| } | |
| loadAuthStatus(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, []); | |
| const handleLogout = async () => { | |
| try { | |
| const res = await fetch("/api/auth/logout", { method: "POST" }); | |
| if (res.ok) { | |
| window.location.assign("/login"); | |
| } | |
| } catch (err) { | |
| console.error("Failed to logout:", err); | |
| } | |
| }; | |
| return ( | |
| <header className="shrink-0 flex items-center justify-between gap-3 px-4 lg:px-8 pt-3 pb-2 border-b border-border-subtle bg-surface/60 backdrop-blur-xl lg:bg-transparent lg:backdrop-blur-none z-20"> | |
| {/* Mobile menu button */} | |
| <div className="flex items-center gap-3 lg:hidden shrink-0"> | |
| {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 breadcrumbs */} | |
| <div className="flex flex-col min-w-0 flex-1"> | |
| {breadcrumbs.length > 0 ? ( | |
| <div className="flex items-center gap-2"> | |
| {breadcrumbs.map((crumb, index) => ( | |
| <div | |
| key={`${crumb.label}-${crumb.href || "current"}`} | |
| className="flex items-center gap-2" | |
| > | |
| {index > 0 && ( | |
| <span className="material-symbols-outlined text-text-muted text-base"> | |
| chevron_right | |
| </span> | |
| )} | |
| {crumb.href ? ( | |
| <Link | |
| href={crumb.href} | |
| className="text-text-muted hover:text-primary transition-colors" | |
| > | |
| {crumb.label} | |
| </Link> | |
| ) : ( | |
| <div className="flex items-center gap-2"> | |
| {crumb.image && ( | |
| <ProviderIcon | |
| src={crumb.image} | |
| alt={crumb.label} | |
| size={28} | |
| className="object-contain rounded max-w-[28px] max-h-[28px]" | |
| fallbackText={crumb.label.slice(0, 2).toUpperCase()} | |
| /> | |
| )} | |
| <h1 className="text-base lg:text-2xl font-semibold text-text-main tracking-tight truncate"> | |
| {translate(crumb.label)} | |
| </h1> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| ) : title ? ( | |
| <div> | |
| <div className="flex items-center gap-2"> | |
| {icon && ( | |
| <span className="material-symbols-outlined text-primary text-xl lg:text-2xl"> | |
| {icon} | |
| </span> | |
| )} | |
| <h1 className="text-base lg:text-2xl font-semibold tracking-tight truncate"> | |
| {translate(title)} | |
| </h1> | |
| </div> | |
| {description && ( | |
| <p className="hidden lg:block text-sm text-text-muted truncate"> | |
| {translate(description)} | |
| </p> | |
| )} | |
| </div> | |
| ) : null} | |
| </div> | |
| {/* Right actions */} | |
| <div className="flex items-center gap-1 shrink-0"> | |
| {displayName && loginMethod === "OIDC" && ( | |
| <div className="hidden sm:flex items-center max-w-[220px] px-3 py-1.5 rounded-full border border-border bg-surface/70 text-xs text-text-muted truncate"> | |
| <span className="material-symbols-outlined text-[14px] mr-1.5 text-primary">person</span> | |
| <span className="truncate">{displayName}</span> | |
| <span className="ml-2 shrink-0 rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-primary"> | |
| OIDC | |
| </span> | |
| </div> | |
| )} | |
| <HeaderSearch /> | |
| <button | |
| onClick={() => setDonateOpen(true)} | |
| className="flex items-center gap-1.5 px-3 h-8 rounded-lg border border-pink-500/30 bg-pink-500/10 text-pink-600 dark:text-pink-400 hover:bg-pink-500/20 transition-colors text-sm font-medium" | |
| aria-label="Donate" | |
| > | |
| <span className="material-symbols-outlined text-[18px]">volunteer_activism</span> | |
| <span className="hidden sm:inline">Donate</span> | |
| </button> | |
| <ThemeToggle /> | |
| <HeaderLanguage /> | |
| <HeaderMenu onLogout={handleLogout} /> | |
| </div> | |
| <DonateModal isOpen={donateOpen} onClose={() => setDonateOpen(false)} /> | |
| </header> | |
| ); | |
| } | |
| function HeaderSearch() { | |
| const visible = useHeaderSearchStore((s) => s.visible); | |
| const query = useHeaderSearchStore((s) => s.query); | |
| const placeholder = useHeaderSearchStore((s) => s.placeholder); | |
| const setQuery = useHeaderSearchStore((s) => s.setQuery); | |
| if (!visible) return null; | |
| return ( | |
| <div className="relative w-[160px] sm:w-[220px]"> | |
| <span className="material-symbols-outlined absolute left-2 top-1/2 -translate-y-1/2 text-text-muted text-[16px] pointer-events-none"> | |
| search | |
| </span> | |
| <input | |
| type="text" | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| placeholder={placeholder} | |
| className="w-full h-8 pl-7 pr-7 rounded-lg border border-border bg-surface/60 text-sm focus:outline-none focus:border-primary/50 transition-colors" | |
| /> | |
| {query && ( | |
| <button | |
| type="button" | |
| onClick={() => setQuery("")} | |
| className="absolute right-1 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-main p-0.5 rounded" | |
| aria-label="Clear search" | |
| > | |
| <span className="material-symbols-outlined text-[16px]">close</span> | |
| </button> | |
| )} | |
| </div> | |
| ); | |
| } | |
| Header.propTypes = { | |
| onMenuClick: PropTypes.func, | |
| showMenuButton: PropTypes.bool, | |
| }; | |