| "use client"; |
|
|
| import { useEffect, useMemo, useState } from "react"; |
| import { usePathname, useRouter } 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: [] }; |
|
|
| |
| 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` }, |
| ], |
| }; |
| } |
|
|
| |
| 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: [], |
| }; |
| } |
|
|
| |
| 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("/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 router = useRouter(); |
| const [displayName, setDisplayName] = useState(""); |
| const [loginMethod, setLoginMethod] = useState(""); |
| const [donateOpen, setDonateOpen] = useState(false); |
|
|
| |
| 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) { |
| router.push("/login"); |
| router.refresh(); |
| } |
| } 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, |
| }; |
|
|