From 9fe4726f342977be91eb6ac756f2962623e6416f Mon Sep 17 00:00:00 2001 From: Andrew Peltekci Date: Sun, 22 Mar 2026 19:16:10 -0700 Subject: [PATCH] fix(ui): restore provider assets and model availability endpoint (#367) * fix(usage): track lifetime request total beyond history cap * fix(ui): restore provider assets and model availability endpoint --- public/providers/assemblyai.png | Bin 0 -> 1117 bytes public/providers/deepgram.png | Bin 0 -> 2736 bytes public/providers/hyperbolic.png | Bin 0 -> 8824 bytes public/providers/nanobanana.png | Bin 0 -> 9927 bytes .../(dashboard)/dashboard/providers/page.js | 430 ++++++++++++------ .../ProviderLimits/ProviderLimitCard.js | 50 +- .../usage/components/ProviderLimits/index.js | 88 ++-- src/app/api/models/availability/route.js | 103 +++++ src/app/landing/components/FlowAnimation.js | 132 ++++-- src/shared/components/Header.js | 111 +++-- src/shared/components/ProviderIcon.js | 51 +++ 11 files changed, 707 insertions(+), 258 deletions(-) create mode 100644 public/providers/assemblyai.png create mode 100644 public/providers/deepgram.png create mode 100644 public/providers/hyperbolic.png create mode 100644 public/providers/nanobanana.png create mode 100644 src/app/api/models/availability/route.js create mode 100644 src/shared/components/ProviderIcon.js diff --git a/public/providers/assemblyai.png b/public/providers/assemblyai.png new file mode 100644 index 0000000000000000000000000000000000000000..d4367af077d01c68ef65af01c2089236583ffc19 GIT binary patch literal 1117 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 BvyuP+ literal 0 HcmV?d00001 diff --git a/public/providers/hyperbolic.png b/public/providers/hyperbolic.png new file mode 100644 index 0000000000000000000000000000000000000000..0b4802d1d47791aa518c362f60192741ffcc7955 GIT binary patch literal 8824 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 Fe*p!Xz{q3UP|DVh8K`V8kD8Gq)}o4=~@u! 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 literal 0 HcmV?d00001 diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index 8b0bb12..f23edea 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -1,11 +1,24 @@ "use client"; import { useState, useEffect } from "react"; -import Image from "next/image"; import PropTypes from "prop-types"; -import { Card, CardSkeleton, Badge, Button, Input, Modal, Select, Toggle } from "@/shared/components"; +import { + Card, + CardSkeleton, + Badge, + Button, + Input, + Modal, + Select, + Toggle, +} from "@/shared/components"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; -import { FREE_PROVIDERS, OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX } from "@/shared/constants/providers"; +import { + FREE_PROVIDERS, + OPENAI_COMPATIBLE_PREFIX, + ANTHROPIC_COMPATIBLE_PREFIX, +} from "@/shared/constants/providers"; import Link from "next/link"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; import { useNotificationStore } from "@/store/notificationStore"; @@ -17,15 +30,17 @@ function getStatusDisplay(connected, error, errorCode) { parts.push( {connected} Connected - + , ); } if (error > 0) { - const errText = errorCode ? `${error} Error (${errorCode})` : `${error} Error`; + const errText = errorCode + ? `${error} Error (${errorCode})` + : `${error} Error`; parts.push( {errText} - + , ); } if (parts.length === 0) { @@ -44,21 +59,34 @@ function getConnectionErrorTag(connection) { explicitType === "auth_missing" || explicitType === "token_refresh_failed" || explicitType === "token_expired" - ) return "AUTH"; + ) + return "AUTH"; if (explicitType === "upstream_rate_limited") return "429"; if (explicitType === "upstream_unavailable") return "5XX"; if (explicitType === "network_error") return "NET"; const numericCode = Number(connection.errorCode); - if (Number.isFinite(numericCode) && numericCode >= 400) return String(numericCode); + if (Number.isFinite(numericCode) && numericCode >= 400) + return String(numericCode); const fromMessage = getErrorCode(connection.lastError); if (fromMessage === "401" || fromMessage === "403") return "AUTH"; if (fromMessage && fromMessage !== "ERR") return fromMessage; const msg = (connection.lastError || "").toLowerCase(); - if (msg.includes("runtime") || msg.includes("not runnable") || msg.includes("not installed")) return "RUNTIME"; - if (msg.includes("invalid api key") || msg.includes("token invalid") || msg.includes("revoked") || msg.includes("unauthorized")) return "AUTH"; + if ( + msg.includes("runtime") || + msg.includes("not runnable") || + msg.includes("not installed") + ) + return "RUNTIME"; + if ( + msg.includes("invalid api key") || + msg.includes("token invalid") || + msg.includes("revoked") || + msg.includes("unauthorized") + ) + return "AUTH"; return "ERR"; } @@ -68,7 +96,8 @@ export default function ProvidersPage() { const [providerNodes, setProviderNodes] = useState([]); const [loading, setLoading] = useState(true); const [showAddCompatibleModal, setShowAddCompatibleModal] = useState(false); - const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = useState(false); + const [showAddAnthropicCompatibleModal, setShowAddAnthropicCompatibleModal] = + useState(false); const [testingMode, setTestingMode] = useState(null); const [testResults, setTestResults] = useState(null); const notify = useNotificationStore(); @@ -82,7 +111,8 @@ export default function ProvidersPage() { ]); const connectionsData = await connectionsRes.json(); const nodesData = await nodesRes.json(); - if (connectionsRes.ok) setConnections(connectionsData.connections || []); + if (connectionsRes.ok) + setConnections(connectionsData.connections || []); if (nodesRes.ok) setProviderNodes(nodesData.nodes || []); } catch (error) { console.log("Error fetching data:", error); @@ -95,13 +125,17 @@ export default function ProvidersPage() { const getProviderStats = (providerId, authType) => { const providerConnections = connections.filter( - (c) => c.provider === providerId && c.authType === authType + (c) => c.provider === providerId && c.authType === authType, ); const getEffectiveStatus = (conn) => { - const isCooldown = Object.entries(conn) - .some(([k, v]) => k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now()); - return conn.testStatus === "unavailable" && !isCooldown ? "active" : conn.testStatus; + const isCooldown = Object.entries(conn).some( + ([k, v]) => + k.startsWith("modelLock_") && v && new Date(v).getTime() > Date.now(), + ); + return conn.testStatus === "unavailable" && !isCooldown + ? "active" + : conn.testStatus; }; const connected = providerConnections.filter((c) => { @@ -111,18 +145,23 @@ export default function ProvidersPage() { const errorConns = providerConnections.filter((c) => { const status = getEffectiveStatus(c); - return status === "error" || status === "expired" || status === "unavailable"; + return ( + status === "error" || status === "expired" || status === "unavailable" + ); }); const error = errorConns.length; const total = providerConnections.length; - const allDisabled = total > 0 && providerConnections.every((c) => c.isActive === false); + const allDisabled = + total > 0 && providerConnections.every((c) => c.isActive === false); const latestError = errorConns.sort( - (a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0) + (a, b) => new Date(b.lastErrorAt || 0) - new Date(a.lastErrorAt || 0), )[0]; const errorCode = latestError ? getConnectionErrorTag(latestError) : null; - const errorTime = latestError?.lastErrorAt ? getRelativeTime(latestError.lastErrorAt) : null; + const errorTime = latestError?.lastErrorAt + ? getRelativeTime(latestError.lastErrorAt) + : null; return { connected, error, total, errorCode, errorTime, allDisabled }; }; @@ -130,12 +169,14 @@ export default function ProvidersPage() { // Toggle all connections for a provider on/off const handleToggleProvider = async (providerId, authType, newActive) => { const providerConns = connections.filter( - (c) => c.provider === providerId && c.authType === authType + (c) => c.provider === providerId && c.authType === authType, ); setConnections((prev) => prev.map((c) => - c.provider === providerId && c.authType === authType ? { ...c, isActive: newActive } : c - ) + c.provider === providerId && c.authType === authType + ? { ...c, isActive: newActive } + : c, + ), ); await Promise.allSettled( providerConns.map((c) => @@ -143,8 +184,8 @@ export default function ProvidersPage() { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ isActive: newActive }), - }) - ) + }), + ), ); }; @@ -214,14 +255,17 @@ export default function ProvidersPage() { )} */} - {renderValidationResult()}
- - +
@@ -830,7 +948,12 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { }, [isOpen]); const handleSubmit = async () => { - if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + if ( + !formData.name.trim() || + !formData.prefix.trim() || + !formData.baseUrl.trim() + ) + return; setSubmitting(true); try { const res = await fetch("/api/provider-nodes", { @@ -846,7 +969,11 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { const data = await res.json(); if (res.ok) { onCreated(data.node); - setFormData({ name: "", prefix: "", baseUrl: "https://api.anthropic.com/v1" }); + setFormData({ + name: "", + prefix: "", + baseUrl: "https://api.anthropic.com/v1", + }); setCheckKey(""); setValidationResult(null); } @@ -867,7 +994,7 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { baseUrl: formData.baseUrl, apiKey: checkKey, type: "anthropic-compatible", - modelId: checkModelId.trim() || undefined + modelId: checkModelId.trim() || undefined, }), }); const data = await res.json(); @@ -888,7 +1015,11 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { return ( <> Valid - {method === "chat" && (via inference test)} + {method === "chat" && ( + + (via inference test) + + )} ); } @@ -920,7 +1051,9 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { setFormData({ ...formData, baseUrl: e.target.value })} + onChange={(e) => + setFormData({ ...formData, baseUrl: e.target.value }) + } placeholder="https://api.anthropic.com/v1" hint="Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages." /> @@ -938,16 +1071,31 @@ function AddAnthropicCompatibleModal({ isOpen, onClose, onCreated }) { hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead." />
- {renderValidationResult()}
- - +
@@ -964,7 +1112,9 @@ function ProviderTestResultsView({ results }) { if (results.error && !results.results) { return (
- error + + error +

{results.error}

); @@ -972,7 +1122,14 @@ function ProviderTestResultsView({ results }) { const { summary, mode } = results; const items = results.results || []; - const modeLabel = { oauth: "OAuth", free: "Free", apikey: "API Key", provider: "Provider", all: "All" }[mode] || mode; + const modeLabel = + { + oauth: "OAuth", + free: "Free", + apikey: "API Key", + provider: "Provider", + all: "All", + }[mode] || mode; return (
@@ -987,7 +1144,9 @@ function ProviderTestResultsView({ results }) { {summary.failed} failed )} - {summary.total} tested + + {summary.total} tested +
)} {items.map((r, i) => ( @@ -995,7 +1154,9 @@ function ProviderTestResultsView({ results }) { key={r.connectionId || i} className="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-black/[0.03] dark:bg-white/[0.03]" > - + {r.valid ? "check_circle" : "error"}
@@ -1003,11 +1164,16 @@ function ProviderTestResultsView({ results }) { ({r.provider})
{r.latencyMs !== undefined && ( - {r.latencyMs}ms + + {r.latencyMs}ms + )} {r.valid ? "OK" : r.diagnosis?.type || "ERROR"} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js index 6c6176b..e018699 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/ProviderLimitCard.js @@ -1,8 +1,8 @@ "use client"; import { useState } from "react"; -import Image from "next/image"; import Card from "@/shared/components/Card"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import Badge from "@/shared/components/Badge"; import QuotaProgressBar from "./QuotaProgressBar"; import { calculatePercentage } from "./utils"; @@ -25,11 +25,10 @@ export default function ProviderLimitCard({ onRefresh, }) { const [refreshing, setRefreshing] = useState(false); - const [imgError, setImgError] = useState(false); const handleRefresh = async () => { if (!onRefresh || refreshing) return; - + setRefreshing(true); try { await onRefresh(); @@ -63,28 +62,20 @@ export default function ProviderLimitCard({ className="size-10 rounded-lg flex items-center justify-center p-1.5" style={{ backgroundColor: `${providerColor}15` }} > - {imgError ? ( - - {provider?.slice(0, 2).toUpperCase() || "PR"} - - ) : ( - {provider setImgError(true)} - /> - )} + - +
-

{name || provider}

+

+ {name || provider} +

{plan && ( info -

{message}

+

+ {message} +

)} @@ -156,11 +149,12 @@ export default function ProviderLimitCard({
{quotas.map((quota, index) => { // For Antigravity, use remainingPercentage if available, otherwise calculate - const percentage = quota.remainingPercentage !== undefined - ? Math.round((quota.total - quota.used) / quota.total * 100) - : calculatePercentage(quota.used, quota.total); + const percentage = + quota.remainingPercentage !== undefined + ? Math.round(((quota.total - quota.used) / quota.total) * 100) + : calculatePercentage(quota.used, quota.total); const unlimited = quota.total === 0 || quota.total === null; - + return ( ({ ...prev, [connectionId]: null })); try { - console.log(`[ProviderLimits] Fetching quota for ${provider} (${connectionId})`); + console.log( + `[ProviderLimits] Fetching quota for ${provider} (${connectionId})`, + ); const response = await fetch(`/api/usage/${connectionId}`); - + if (!response.ok) { const errorData = await response.json().catch(() => ({})); const errorMsg = errorData.error || response.statusText; - + // Handle different error types gracefully if (response.status === 404) { // Connection not found - skip silently - console.warn(`[ProviderLimits] Connection not found for ${provider}, skipping`); + console.warn( + `[ProviderLimits] Connection not found for ${provider}, skipping`, + ); return; } - + if (response.status === 401) { // Auth error - show message instead of throwing - console.warn(`[ProviderLimits] Auth error for ${provider}:`, errorMsg); + console.warn( + `[ProviderLimits] Auth error for ${provider}:`, + errorMsg, + ); setQuotaData((prev) => ({ ...prev, [connectionId]: { @@ -74,16 +81,16 @@ export default function ProviderLimits() { })); return; } - + throw new Error(`HTTP ${response.status}: ${errorMsg}`); } const data = await response.json(); console.log(`[ProviderLimits] Got quota for ${provider}:`, data); - + // Parse quota data using provider-specific parser const parsedQuotas = parseQuotaData(provider, data); - + setQuotaData((prev) => ({ ...prev, [connectionId]: { @@ -94,7 +101,10 @@ export default function ProviderLimits() { }, })); } catch (error) { - console.error(`[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`, error); + console.error( + `[ProviderLimits] Error fetching quota for ${provider} (${connectionId}):`, + error, + ); setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to fetch quota", @@ -110,7 +120,7 @@ export default function ProviderLimits() { await fetchQuota(connectionId, provider); setLastUpdated(new Date()); }, - [fetchQuota] + [fetchQuota], ); // Refresh all providers @@ -122,15 +132,17 @@ export default function ProviderLimits() { try { const conns = await fetchConnections(); - + // Filter only supported OAuth providers const oauthConnections = conns.filter( - (conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth" + (conn) => + USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && + conn.authType === "oauth", ); - + // Fetch quota for supported OAuth connections only await Promise.all( - oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)) + oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)), ); setLastUpdated(new Date()); @@ -149,16 +161,20 @@ export default function ProviderLimits() { setConnectionsLoading(false); const oauthConnections = conns.filter( - (conn) => USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth" + (conn) => + USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && + conn.authType === "oauth", ); // Mark all as loading before fetching const loadingState = {}; - oauthConnections.forEach((conn) => { loadingState[conn.id] = true; }); + oauthConnections.forEach((conn) => { + loadingState[conn.id] = true; + }); setLoading(loadingState); await Promise.all( - oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)) + oauthConnections.map((conn) => fetchQuota(conn.id, conn.provider)), ); setLastUpdated(new Date()); }; @@ -243,8 +259,10 @@ export default function ProviderLimits() { }, [lastUpdated]); // Filter only supported providers - const filteredConnections = connections.filter((conn) => - USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && conn.authType === "oauth" + const filteredConnections = connections.filter( + (conn) => + USAGE_SUPPORTED_PROVIDERS.includes(conn.provider) && + conn.authType === "oauth", ); // Sort providers by USAGE_SUPPORTED_PROVIDERS order, then alphabetically @@ -258,18 +276,18 @@ export default function ProviderLimits() { // Calculate summary stats const totalProviders = sortedConnections.length; const activeWithLimits = Object.values(quotaData).filter( - (data) => data?.quotas?.length > 0 + (data) => data?.quotas?.length > 0, ).length; - + // Count low quotas (remaining < 30%) const lowQuotasCount = Object.values(quotaData).reduce((count, data) => { if (!data?.quotas) return count; - + const hasLowQuota = data.quotas.some((quota) => { const percentage = calculatePercentage(quota.used, quota.total); return percentage < 30 && quota.total > 0; }); - + return count + (hasLowQuota ? 1 : 0); }, 0); @@ -285,7 +303,8 @@ export default function ProviderLimits() { No Providers Connected

- Connect to providers with OAuth to track your API quota limits and usage. + Connect to providers with OAuth to track your API quota limits and + usage.

@@ -353,13 +372,14 @@ export default function ProviderLimits() {
- {conn.provider}
@@ -371,14 +391,16 @@ export default function ProviderLimits() { )}
- + diff --git a/src/app/api/models/availability/route.js b/src/app/api/models/availability/route.js new file mode 100644 index 0000000..cceacda --- /dev/null +++ b/src/app/api/models/availability/route.js @@ -0,0 +1,103 @@ +import { NextResponse } from "next/server"; +import { + getProviderConnections, + updateProviderConnection, +} from "@/lib/localDb"; + +const MODEL_LOCK_PREFIX = "modelLock_"; + +function getActiveModelLocks(connection) { + const now = Date.now(); + return Object.entries(connection) + .filter(([key, value]) => key.startsWith(MODEL_LOCK_PREFIX) && value) + .map(([key, value]) => ({ + key, + model: key.slice(MODEL_LOCK_PREFIX.length) || "__all", + until: value, + active: new Date(value).getTime() > now, + })) + .filter((lock) => lock.active); +} + +export async function GET() { + try { + const connections = await getProviderConnections(); + const models = []; + + for (const connection of connections) { + const locks = getActiveModelLocks(connection); + for (const lock of locks) { + models.push({ + provider: connection.provider, + model: lock.model, + status: "cooldown", + until: lock.until, + connectionId: connection.id, + connectionName: connection.name || connection.email || connection.id, + lastError: connection.lastError || null, + }); + } + + if (locks.length === 0 && connection.testStatus === "unavailable") { + models.push({ + provider: connection.provider, + model: "__all", + status: "unavailable", + connectionId: connection.id, + connectionName: connection.name || connection.email || connection.id, + lastError: connection.lastError || null, + }); + } + } + + return NextResponse.json({ + models, + unavailableCount: models.length, + }); + } catch (error) { + console.error("[API] Failed to get model availability:", error); + return NextResponse.json( + { error: "Failed to fetch model availability" }, + { status: 500 }, + ); + } +} + +export async function POST(request) { + try { + const { action, provider, model } = await request.json(); + + if (action !== "clearCooldown" || !provider || !model) { + return NextResponse.json({ error: "Invalid request" }, { status: 400 }); + } + + const connections = await getProviderConnections({ provider }); + const lockKey = `${MODEL_LOCK_PREFIX}${model}`; + + await Promise.all( + connections + .filter((connection) => connection[lockKey]) + .map((connection) => + updateProviderConnection(connection.id, { + [lockKey]: null, + ...(connection.testStatus === "unavailable" + ? { + testStatus: "active", + lastError: null, + lastErrorAt: null, + backoffLevel: 0, + } + : {}), + }), + ), + ); + + return NextResponse.json({ ok: true }); + } catch (error) { + console.error("[API] Failed to clear model cooldown:", error); + return NextResponse.json( + { error: "Failed to clear cooldown" }, + { status: 500 }, + ); + } +} diff --git a/src/app/landing/components/FlowAnimation.js b/src/app/landing/components/FlowAnimation.js index 482e7a1..4209cc0 100644 --- a/src/app/landing/components/FlowAnimation.js +++ b/src/app/landing/components/FlowAnimation.js @@ -1,6 +1,6 @@ "use client"; import { useEffect, useState } from "react"; -import Image from "next/image"; +import ProviderIcon from "@/shared/components/ProviderIcon"; const CLI_TOOLS = [ { id: "claude", name: "Claude Code", image: "/providers/claude.png" }, @@ -10,10 +10,30 @@ const CLI_TOOLS = [ ]; const PROVIDERS = [ - { id: "openai", name: "OpenAI", color: "bg-emerald-500", textColor: "text-white" }, - { id: "anthropic", name: "Anthropic", color: "bg-orange-400", textColor: "text-white" }, - { id: "gemini", name: "Gemini", color: "bg-blue-500", textColor: "text-white" }, - { id: "github", name: "GitHub Copilot", color: "bg-gray-700", textColor: "text-white" }, + { + id: "openai", + name: "OpenAI", + color: "bg-emerald-500", + textColor: "text-white", + }, + { + id: "anthropic", + name: "Anthropic", + color: "bg-orange-400", + textColor: "text-white", + }, + { + id: "gemini", + name: "Gemini", + color: "bg-blue-500", + textColor: "text-white", + }, + { + id: "github", + name: "GitHub Copilot", + color: "bg-gray-700", + textColor: "text-white", + }, ]; export default function FlowAnimation() { @@ -30,26 +50,29 @@ export default function FlowAnimation() {
{/* 9Router Hub - Center */}
- hub - 9Router + + hub + + + 9Router +
{/* CLI Tools - Left side */}
{CLI_TOOLS.map((tool) => ( -
- {tool.name}
@@ -57,40 +80,70 @@ export default function FlowAnimation() {
{/* SVG Lines from CLI to 9Router */} - - - - - + + + + + {/* SVG Lines from 9Router to Providers */} - - + - - - @@ -99,7 +152,7 @@ export default function FlowAnimation() { {/* AI Providers - Right side */}
{PROVIDERS.map((provider, idx) => ( -
-

Interactive diagram visible on desktop

+

+ Interactive diagram visible on desktop +

); } - diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index dd01a37..66eccce 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -3,49 +3,104 @@ import { usePathname, useRouter } from "next/navigation"; import { useMemo } from "react"; import Link from "next/link"; -import Image from "next/image"; import PropTypes from "prop-types"; +import ProviderIcon from "@/shared/components/ProviderIcon"; import { ThemeToggle, LanguageSwitcher } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/config"; import { translate } from "@/i18n/runtime"; const getPageInfo = (pathname) => { if (!pathname) return { title: "", description: "", 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]; + 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` } - ] + { + label: providerInfo.name, + image: `/providers/${providerInfo.id}.png`, + }, + ], }; } } - - if (pathname.includes("/providers")) return { title: "Providers", description: "Manage your AI provider connections", breadcrumbs: [] }; - if (pathname.includes("/combos")) return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] }; - if (pathname.includes("/usage")) return { title: "Usage & Analytics", description: "Monitor your API usage, token consumption, and request logs", breadcrumbs: [] }; - if (pathname.includes("/mitm")) return { title: "MITM Proxy", description: "Intercept CLI tool traffic and route through 9Router", breadcrumbs: [] }; - if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] }; - if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] }; - if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] }; - if (pathname.includes("/translator")) return { title: "Translator", description: "Debug translation flow between formats", breadcrumbs: [] }; - if (pathname.includes("/console-log")) return { title: "Console Log", description: "Live server console output", breadcrumbs: [] }; - if (pathname === "/dashboard") return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] }; + + if (pathname.includes("/providers")) + return { + title: "Providers", + description: "Manage your AI provider connections", + breadcrumbs: [], + }; + if (pathname.includes("/combos")) + return { + title: "Combos", + description: "Model combos with fallback", + breadcrumbs: [], + }; + if (pathname.includes("/usage")) + return { + title: "Usage & Analytics", + description: + "Monitor your API usage, token consumption, and request logs", + breadcrumbs: [], + }; + if (pathname.includes("/mitm")) + return { + title: "MITM Proxy", + description: "Intercept CLI tool traffic and route through 9Router", + breadcrumbs: [], + }; + if (pathname.includes("/cli-tools")) + return { + title: "CLI Tools", + description: "Configure CLI tools", + breadcrumbs: [], + }; + if (pathname.includes("/endpoint")) + return { + title: "Endpoint", + description: "API endpoint configuration", + breadcrumbs: [], + }; + if (pathname.includes("/profile")) + return { + title: "Settings", + description: "Manage your preferences", + breadcrumbs: [], + }; + if (pathname.includes("/translator")) + return { + title: "Translator", + description: "Debug translation flow between formats", + breadcrumbs: [], + }; + if (pathname.includes("/console-log")) + return { + title: "Console Log", + description: "Live server console output", + breadcrumbs: [], + }; + if (pathname === "/dashboard") + return { + title: "Endpoint", + description: "API endpoint configuration", + breadcrumbs: [], + }; return { title: "", description: "", breadcrumbs: [] }; }; export default function Header({ onMenuClick, showMenuButton = true }) { const pathname = usePathname(); const router = useRouter(); - + // Memoize page info to prevent unnecessary recalculations const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]); const { title, description, breadcrumbs } = pageInfo; @@ -81,7 +136,10 @@ export default function Header({ onMenuClick, showMenuButton = true }) { {breadcrumbs.length > 0 ? (
{breadcrumbs.map((crumb, index) => ( -
+
{index > 0 && ( chevron_right @@ -97,14 +155,12 @@ export default function Header({ onMenuClick, showMenuButton = true }) { ) : (
{crumb.image && ( - {crumb.label} { e.currentTarget.style.display = "none"; }} + fallbackText={crumb.label.slice(0, 2).toUpperCase()} /> )}

@@ -117,9 +173,13 @@ export default function Header({ onMenuClick, showMenuButton = true }) {

) : title ? (
-

{translate(title)}

+

+ {translate(title)} +

{description && ( -

{translate(description)}

+

+ {translate(description)} +

)}
) : null} @@ -150,4 +210,3 @@ Header.propTypes = { onMenuClick: PropTypes.func, showMenuButton: PropTypes.bool, }; - diff --git a/src/shared/components/ProviderIcon.js b/src/shared/components/ProviderIcon.js new file mode 100644 index 0000000..ca55850 --- /dev/null +++ b/src/shared/components/ProviderIcon.js @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; + +export default function ProviderIcon({ + src, + alt, + size = 32, + className = "", + fallbackText = "?", + fallbackColor, +}) { + const [errored, setErrored] = useState(false); + + if (!src || errored) { + return ( + + {fallbackText} + + ); + } + + return ( + {alt} setErrored(true)} + /> + ); +} + +ProviderIcon.propTypes = { + src: PropTypes.string, + alt: PropTypes.string, + size: PropTypes.number, + className: PropTypes.string, + fallbackText: PropTypes.string, + fallbackColor: PropTypes.string, +};