Spaces:
Runtime error
Runtime error
| "use client"; | |
| import { useMemo, useState } from "react"; | |
| import { Message, TokenUsage, RateLimitInfo } from "@/types/chat"; | |
| // ── Groq production models (primary + fallback chain) ─────────────────────── | |
| const GROQ_MODEL_CHAIN = [ | |
| { id: "llama-3.1-8b-instant", label: "Llama 3.1 · 8B", note: "Primary — fastest inference" }, | |
| { id: "llama-3.3-70b-versatile", label: "Llama 3.3 · 70B", note: "Fallback 1 — most capable" }, | |
| { id: "openai/gpt-oss-20b", label: "GPT OSS · 20B", note: "Fallback 2 — 1,000 tok/s" }, | |
| { id: "openai/gpt-oss-120b", label: "GPT OSS · 120B", note: "Fallback 3 — strongest on Groq" }, | |
| { id: "qwen/qwen3-32b", label: "Qwen3 · 32B", note: "Fallback 4 — preview" }, | |
| ]; | |
| // ── OpenRouter free models (ordered best → smallest) ──────────────────────── | |
| const OPENROUTER_MODELS = [ | |
| { id: "openai/gpt-oss-120b:free", label: "GPT OSS 120B", provider: "OpenAI" }, | |
| { id: "nvidia/nemotron-3-ultra-550b-a55b:free", label: "Nemotron Ultra 550B", provider: "NVIDIA" }, | |
| { id: "nousresearch/hermes-3-llama-3.1-405b:free", label: "Hermes 3 · 405B", provider: "Nous" }, | |
| { id: "nvidia/nemotron-3-super-120b-a12b:free", label: "Nemotron Super 120B", provider: "NVIDIA" }, | |
| { id: "meta-llama/llama-3.3-70b-instruct:free", label: "Llama 3.3 · 70B", provider: "Meta" }, | |
| { id: "qwen/qwen3-next-80b-a3b-instruct:free", label: "Qwen3 · 80B", provider: "Qwen" }, | |
| { id: "qwen/qwen3-coder:free", label: "Qwen3 Coder", provider: "Qwen" }, | |
| { id: "moonshotai/kimi-k2.6:free", label: "Kimi K2.6", provider: "Moonshot" }, | |
| { id: "google/gemma-4-31b-it:free", label: "Gemma 4 · 31B", provider: "Google" }, | |
| { id: "google/gemma-4-26b-a4b-it:free", label: "Gemma 4 · 26B MoE", provider: "Google" }, | |
| { id: "nvidia/nemotron-3-nano-30b-a3b:free", label: "Nemotron Nano 30B", provider: "NVIDIA" }, | |
| { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", label: "Nemotron Nano 30B-R", provider: "NVIDIA" }, | |
| { id: "openai/gpt-oss-20b:free", label: "GPT OSS 20B", provider: "OpenAI" }, | |
| { id: "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", label: "Dolphin Mistral 24B", provider: "CogComp" }, | |
| { id: "nvidia/nemotron-nano-9b-v2:free", label: "Nemotron Nano 9B", provider: "NVIDIA" }, | |
| { id: "meta-llama/llama-3.2-3b-instruct:free", label: "Llama 3.2 · 3B", provider: "Meta" }, | |
| { id: "poolside/laguna-m.1:free", label: "Laguna M.1", provider: "Poolside" }, | |
| { id: "poolside/laguna-xs.2:free", label: "Laguna XS.2", provider: "Poolside" }, | |
| { id: "nex-agi/nex-n2-pro:free", label: "NEX N2 Pro", provider: "NexAGI" }, | |
| ]; | |
| const ALL_MODEL_LABELS: Record<string, string> = { | |
| ...Object.fromEntries(GROQ_MODEL_CHAIN.map((m) => [m.id, m.label])), | |
| ...Object.fromEntries(OPENROUTER_MODELS.map((m) => [m.id, m.label])), | |
| }; | |
| function shortLabel(model: string) { | |
| return ALL_MODEL_LABELS[model] ?? model.split("/").pop()?.replace(/:free$/, "") ?? model; | |
| } | |
| function isOpenRouterModel(id: string) { | |
| return OPENROUTER_MODELS.some((m) => m.id === id); | |
| } | |
| /** Parse Groq reset strings like "58.3s", "3m45.2s", "1h0m0s" → "58s", "3m 45s", "1h" */ | |
| function parseReset(reset: string | null | undefined): string { | |
| if (!reset || reset === "0s") return "now"; | |
| const m = reset.match(/(?:(\d+)h)?(?:(\d+)m)?(?:([\d.]+)s)?/); | |
| if (!m) return reset; | |
| const h = m[1] ? parseInt(m[1]) : 0; | |
| const min = m[2] ? parseInt(m[2]) : 0; | |
| const s = m[3] ? Math.ceil(parseFloat(m[3])) : 0; | |
| if (h > 0) return min > 0 ? `${h}h ${min}m` : `${h}h`; | |
| if (min > 0) return s > 0 ? `${min}m ${s}s` : `${min}m`; | |
| return s > 0 ? `${s}s` : "now"; | |
| } | |
| function pct(remaining: number | null, limit: number | null): number { | |
| if (!limit || remaining === null) return 0; | |
| return Math.max(0, Math.min(100, Math.round((remaining / limit) * 100))); | |
| } | |
| function barColor(remainingPct: number): string { | |
| if (remainingPct > 50) return "from-[#8FD15E] to-[#47C3A6]"; | |
| if (remainingPct > 20) return "from-[#F59E0B] to-[#FBBF24]"; | |
| return "from-[#EF4444] to-[#F87171]"; | |
| } | |
| interface Props { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| messages: Message[]; | |
| preferredModel: string; | |
| setPreferredModel: (model: string) => void; | |
| } | |
| export default function SettingsPanel({ | |
| isOpen, | |
| onClose, | |
| messages, | |
| preferredModel, | |
| setPreferredModel, | |
| }: Props) { | |
| const [showOpenRouter, setShowOpenRouter] = useState(false); | |
| const [showAdvanced, setShowAdvanced] = useState(false); | |
| const assistantMessages = useMemo( | |
| () => messages.filter((m) => m.role === "assistant" && m.tokenUsage), | |
| [messages] | |
| ); | |
| const sessionTotals = useMemo<TokenUsage>(() => | |
| assistantMessages.reduce( | |
| (acc, m) => ({ | |
| prompt_tokens: acc.prompt_tokens + (m.tokenUsage?.prompt_tokens ?? 0), | |
| completion_tokens: acc.completion_tokens + (m.tokenUsage?.completion_tokens ?? 0), | |
| total_tokens: acc.total_tokens + (m.tokenUsage?.total_tokens ?? 0), | |
| }), | |
| { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } | |
| ), | |
| [assistantMessages] | |
| ); | |
| const latestRateLimit = useMemo<RateLimitInfo | undefined>(() => { | |
| for (let i = assistantMessages.length - 1; i >= 0; i--) { | |
| if (assistantMessages[i].rateLimit) return assistantMessages[i].rateLimit!; | |
| } | |
| return undefined; | |
| }, [assistantMessages]); | |
| const latestTimestamp = useMemo(() => { | |
| if (!assistantMessages.length) return null; | |
| return assistantMessages[assistantMessages.length - 1].timestamp; | |
| }, [assistantMessages]); | |
| const inputRatio = sessionTotals.total_tokens > 0 | |
| ? Math.round((sessionTotals.prompt_tokens / sessionTotals.total_tokens) * 100) | |
| : 0; | |
| const usedModels = useMemo(() => { | |
| const set = new Set(assistantMessages.map((m) => m.modelUsed).filter(Boolean)); | |
| return Array.from(set) as string[]; | |
| }, [assistantMessages]); | |
| const reqPct = pct(latestRateLimit?.remaining_requests ?? null, latestRateLimit?.limit_requests ?? null); | |
| const tokPct = pct(latestRateLimit?.remaining_tokens ?? null, latestRateLimit?.limit_tokens ?? null); | |
| const isORActive = isOpenRouterModel(preferredModel); | |
| return ( | |
| <> | |
| {/* Backdrop */} | |
| <div | |
| className={`fixed inset-0 z-40 bg-black/20 backdrop-blur-[2px] transition-opacity duration-300 ${ | |
| isOpen ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none" | |
| }`} | |
| onClick={onClose} | |
| /> | |
| {/* Drawer */} | |
| <aside className={`fixed top-0 right-0 z-50 h-full w-80 bg-white border-l border-[#E8EDF2] shadow-2xl flex flex-col transition-transform duration-300 ease-in-out ${ | |
| isOpen ? "translate-x-0" : "translate-x-full" | |
| }`}> | |
| {/* ── Header ── */} | |
| <div className="flex items-center justify-between px-5 py-4 border-b border-[#E8EDF2]"> | |
| <div className="flex items-center gap-2"> | |
| <SettingsIcon /> | |
| <span className="text-sm font-semibold text-[#1E293B]">Active Session Status</span> | |
| </div> | |
| <button | |
| onClick={onClose} | |
| className="w-7 h-7 flex items-center justify-center rounded-md text-[#94A3B8] hover:text-[#1E293B] hover:bg-[#F1F5F9] transition-colors" | |
| aria-label="Close" | |
| > | |
| <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> | |
| <path d="M2 2l10 10M12 2L2 12" /> | |
| </svg> | |
| </button> | |
| </div> | |
| {/* ── Body ── */} | |
| <div className="flex-1 overflow-y-auto"> | |
| {/* ── 1. Groq Model Selection ── */} | |
| <section className="px-5 py-5 border-b border-[#F1F5F9]"> | |
| <SectionLabel>Groq Models</SectionLabel> | |
| <p className="text-[10px] text-[#94A3B8] mt-1 mb-3 leading-relaxed"> | |
| Select the primary Groq model. The remaining models act as automatic fallbacks when rate limits are hit. | |
| </p> | |
| <ol className="space-y-2"> | |
| {GROQ_MODEL_CHAIN.map((m) => { | |
| const isPreferred = m.id === preferredModel; | |
| const isRecentlyUsed = usedModels.includes(m.id); | |
| return ( | |
| <button | |
| key={m.id} | |
| onClick={() => setPreferredModel(m.id)} | |
| className={`w-full text-left flex items-start gap-3 rounded-lg px-3 py-2.5 border transition-all ${ | |
| isPreferred | |
| ? "bg-[#F0FDF8] border-[#47C3A6] shadow-sm" | |
| : "bg-[#F8FAFC] border-[#F1F5F9] hover:bg-[#F1F5F9] hover:border-[#E2E8F0]" | |
| }`} | |
| > | |
| <div className="flex flex-col items-center gap-1 pt-0.5 flex-shrink-0"> | |
| {isPreferred ? ( | |
| <div className="w-4 h-4 rounded-full bg-[#47C3A6] flex items-center justify-center text-white"> | |
| <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> | |
| <polyline points="20 6 9 17 4 12" /> | |
| </svg> | |
| </div> | |
| ) : ( | |
| <div className="w-4 h-4 rounded-full border-2 border-[#CBD5E1] bg-white" /> | |
| )} | |
| </div> | |
| <div className="min-w-0 flex-1"> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| <span className={`text-[11px] font-semibold ${isPreferred ? "text-[#059669]" : "text-[#334155]"}`}> | |
| {m.label} | |
| </span> | |
| {isPreferred && <Chip color="green">Active</Chip>} | |
| {isRecentlyUsed && !isPreferred && <Chip color="amber">Used</Chip>} | |
| </div> | |
| <p className="text-[10px] text-[#94A3B8] mt-0.5">{m.note}</p> | |
| </div> | |
| </button> | |
| ); | |
| })} | |
| </ol> | |
| </section> | |
| {/* ── 2. OpenRouter Free Models ── */} | |
| <section className="border-b border-[#F1F5F9]"> | |
| <button | |
| onClick={() => setShowOpenRouter(!showOpenRouter)} | |
| className="w-full flex items-center justify-between px-5 py-4 hover:bg-[#F8FAFC] transition-colors" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="w-0.5 h-3.5 rounded-full bg-gradient-to-b from-[#818CF8] to-[#6366F1]" /> | |
| <span className="text-[10px] font-bold uppercase tracking-[0.12em] text-[#94A3B8]"> | |
| OpenRouter Free Models | |
| </span> | |
| {isORActive && ( | |
| <span className="text-[8px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full text-[#4F46E5] bg-indigo-50 border border-indigo-200"> | |
| Active | |
| </span> | |
| )} | |
| <span className="text-[9px] text-[#CBD5E1] font-mono"> | |
| {OPENROUTER_MODELS.length} | |
| </span> | |
| </div> | |
| <svg | |
| width="10" height="6" viewBox="0 0 10 6" fill="none" | |
| stroke="currentColor" strokeWidth="1.5" | |
| className={`text-[#94A3B8] transition-transform duration-200 ${showOpenRouter ? "rotate-180" : ""}`} | |
| > | |
| <path d="M1 1l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" /> | |
| </svg> | |
| </button> | |
| {showOpenRouter && ( | |
| <div className="px-5 pb-5 pt-1 animate-slide-up"> | |
| <p className="text-[10px] text-[#94A3B8] mb-3 leading-relaxed"> | |
| Select any free OpenRouter model as primary. Groq models remain as fallback if OpenRouter is unavailable. | |
| </p> | |
| <div className="space-y-1 max-h-64 overflow-y-auto pr-1 -mr-1"> | |
| {OPENROUTER_MODELS.map((m) => { | |
| const isActive = m.id === preferredModel; | |
| const isUsed = usedModels.includes(m.id); | |
| return ( | |
| <button | |
| key={m.id} | |
| onClick={() => setPreferredModel(m.id)} | |
| className={`w-full text-left flex items-center gap-2.5 rounded-lg px-2.5 py-2 border transition-all ${ | |
| isActive | |
| ? "bg-indigo-50 border-indigo-300 shadow-sm" | |
| : "bg-[#F8FAFC] border-[#F1F5F9] hover:bg-[#F1F5F9] hover:border-[#E2E8F0]" | |
| }`} | |
| > | |
| {/* Radio */} | |
| {isActive ? ( | |
| <div className="w-3.5 h-3.5 rounded-full bg-[#6366F1] flex items-center justify-center flex-shrink-0"> | |
| <svg width="7" height="7" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3.5"> | |
| <polyline points="20 6 9 17 4 12" /> | |
| </svg> | |
| </div> | |
| ) : ( | |
| <div className="w-3.5 h-3.5 rounded-full border-2 border-[#CBD5E1] bg-white flex-shrink-0" /> | |
| )} | |
| {/* Label */} | |
| <span className={`text-[11px] font-semibold flex-1 min-w-0 truncate ${ | |
| isActive ? "text-[#4338CA]" : "text-[#334155]" | |
| }`}> | |
| {m.label} | |
| </span> | |
| {/* Badges */} | |
| <div className="flex items-center gap-1 flex-shrink-0"> | |
| {isUsed && !isActive && ( | |
| <span className="text-[8px] font-bold px-1 py-0.5 rounded text-amber-600 bg-amber-50 border border-amber-200"> | |
| used | |
| </span> | |
| )} | |
| <span className="text-[8px] font-medium px-1.5 py-0.5 rounded bg-[#F1F5F9] text-[#64748B] border border-[#E2E8F0]"> | |
| {m.provider} | |
| </span> | |
| </div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {/* Current OR selection summary */} | |
| {isORActive && ( | |
| <div className="mt-3 rounded-lg bg-indigo-50 border border-indigo-200 px-3 py-2"> | |
| <p className="text-[10px] text-[#4338CA] leading-relaxed"> | |
| <span className="font-semibold">Active:</span>{" "} | |
| {OPENROUTER_MODELS.find((m) => m.id === preferredModel)?.label} | |
| {" "}via OpenRouter.{" "} | |
| Groq models remain available as fallback. | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </section> | |
| {/* ── 3. Live API Quota Metrics ── */} | |
| <section className="px-5 py-5 border-b border-[#F1F5F9]"> | |
| <SectionLabel>API Quota & Limits</SectionLabel> | |
| {!latestRateLimit ? ( | |
| <div className="mt-3 bg-[#F8FAFC] border border-[#F1F5F9] rounded-lg p-3"> | |
| <div className="flex items-center gap-2 mb-1.5"> | |
| <div className="w-1.5 h-1.5 rounded-full bg-[#E2E8F0]" /> | |
| <span className="text-[11px] font-semibold text-[#64748B]"> | |
| Status: Ready (Pending message) | |
| </span> | |
| </div> | |
| <p className="text-[10px] text-[#94A3B8] leading-relaxed"> | |
| Remaining requests, token balances, and exact reset times are fetched live from response headers and will display after your first message. | |
| </p> | |
| </div> | |
| ) : ( | |
| <> | |
| <div className="flex items-center justify-between mt-3 mb-4"> | |
| <span className="text-[10px] font-semibold text-[#475569] bg-[#F1F5F9] px-2 py-0.5 rounded-full"> | |
| Last response: {shortLabel(latestRateLimit.model)} | |
| </span> | |
| {latestTimestamp && ( | |
| <span className="text-[10px] text-[#CBD5E1]"> | |
| updated {latestTimestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })} | |
| </span> | |
| )} | |
| </div> | |
| <div className="space-y-4"> | |
| <RateLimitRow | |
| label="Requests / min Quota" | |
| remaining={latestRateLimit.remaining_requests} | |
| limit={latestRateLimit.limit_requests} | |
| reset={latestRateLimit.reset_requests} | |
| pct={reqPct} | |
| /> | |
| <RateLimitRow | |
| label="Tokens / min Quota" | |
| remaining={latestRateLimit.remaining_tokens} | |
| limit={latestRateLimit.limit_tokens} | |
| reset={latestRateLimit.reset_tokens} | |
| pct={tokPct} | |
| formatValue={(n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)} | |
| /> | |
| </div> | |
| <p className="text-[10px] text-[#CBD5E1] mt-4 leading-relaxed"> | |
| Quotas are per-model and independent — switching models gives a fresh quota pool. | |
| </p> | |
| </> | |
| )} | |
| </section> | |
| {/* ── 4. Advanced Token Stats (collapsed) ── */} | |
| <section className="border-b border-[#F1F5F9]"> | |
| <button | |
| onClick={() => setShowAdvanced(!showAdvanced)} | |
| className="w-full flex items-center justify-between px-5 py-4 hover:bg-[#F8FAFC] transition-colors" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <div className="w-0.5 h-3.5 rounded-full bg-slate-300" /> | |
| <span className="text-[10px] font-bold uppercase tracking-[0.12em] text-[#64748B]"> | |
| Detailed Metrics | |
| </span> | |
| </div> | |
| <svg | |
| width="10" height="6" viewBox="0 0 10 6" fill="none" | |
| stroke="currentColor" strokeWidth="1.5" | |
| className={`text-[#94A3B8] transition-transform duration-200 ${showAdvanced ? "rotate-180" : ""}`} | |
| > | |
| <path d="M1 1l4 4 4-4" strokeLinecap="round" strokeLinejoin="round" /> | |
| </svg> | |
| </button> | |
| {showAdvanced && ( | |
| <div className="px-5 pb-5 pt-1 space-y-5 animate-slide-up bg-[#FAFAFB]"> | |
| <div> | |
| <span className="text-[10px] font-semibold text-[#94A3B8] uppercase tracking-wider"> | |
| Session Totals | |
| </span> | |
| {assistantMessages.length === 0 ? ( | |
| <p className="text-xs text-[#94A3B8] mt-2">No token records yet.</p> | |
| ) : ( | |
| <> | |
| <div className="grid grid-cols-3 gap-2 mt-2"> | |
| <StatTile label="Prompt" value={sessionTotals.prompt_tokens} color="#64748B" /> | |
| <StatTile label="Completion" value={sessionTotals.completion_tokens} color="#47C3A6" /> | |
| <StatTile label="Total" value={sessionTotals.total_tokens} color="#14B7CC" bold /> | |
| </div> | |
| <div className="mt-3"> | |
| <div className="flex justify-between text-[9px] text-[#94A3B8] mb-1"> | |
| <span>Prompt {inputRatio}%</span> | |
| <span>Completion {100 - inputRatio}%</span> | |
| </div> | |
| <div className="h-1.5 rounded-full bg-[#E8EDF2] overflow-hidden"> | |
| <div | |
| className="h-full rounded-full bg-gradient-to-r from-[#8FD15E] to-[#47C3A6]" | |
| style={{ width: `${inputRatio}%` }} | |
| /> | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {assistantMessages.length > 0 && ( | |
| <div> | |
| <span className="text-[10px] font-semibold text-[#94A3B8] uppercase tracking-wider block mb-2"> | |
| Prompt History Breakdown | |
| </span> | |
| <div className="space-y-1.5 max-h-48 overflow-y-auto pr-1"> | |
| {assistantMessages.map((msg, idx) => { | |
| const usage = msg.tokenUsage!; | |
| const model = msg.modelUsed ?? preferredModel; | |
| const isFallback = model !== preferredModel; | |
| const barWidth = sessionTotals.total_tokens > 0 | |
| ? Math.max(4, Math.round((usage.total_tokens / sessionTotals.total_tokens) * 100)) | |
| : 0; | |
| return ( | |
| <div key={msg.id} className="rounded-lg border border-[#E8EDF2] bg-white px-2.5 py-2"> | |
| <div className="flex items-center justify-between mb-1"> | |
| <div className="flex items-center gap-1.5"> | |
| <span className="text-[9px] font-bold text-[#CBD5E1]">#{idx + 1}</span> | |
| <span className={`text-[9px] font-semibold px-1.5 py-0.2 rounded-full ${ | |
| isFallback ? "text-amber-600 bg-amber-50 border border-amber-200" : "text-[#64748B] bg-[#F1F5F9]" | |
| }`}> | |
| {shortLabel(model)} | |
| </span> | |
| </div> | |
| <span className="text-[9px] font-mono font-semibold text-[#334155]"> | |
| {usage.total_tokens.toLocaleString()} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <div className="flex-1 h-1 bg-[#F1F5F9] rounded-full overflow-hidden"> | |
| <div | |
| className={`h-full rounded-full ${isFallback ? "bg-amber-400" : "bg-[#47C3A6]"}`} | |
| style={{ width: `${barWidth}%` }} | |
| /> | |
| </div> | |
| <span className="text-[8px] text-[#94A3B8] font-mono whitespace-nowrap"> | |
| ↑{usage.prompt_tokens} ↓{usage.completion_tokens} | |
| </span> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </section> | |
| </div> | |
| {/* ── Footer ── */} | |
| <div className="px-5 py-3 border-t border-[#E8EDF2] bg-[#F8FAFC]"> | |
| <p className="text-[10px] text-[#CBD5E1] text-center"> | |
| <span className="text-[#94A3B8] font-medium">Groq</span> | |
| {" · "} | |
| <span className="text-[#94A3B8] font-medium">OpenRouter</span> | |
| {" · "} | |
| <span className="text-[#94A3B8]">Free tier limits apply</span> | |
| </p> | |
| </div> | |
| </aside> | |
| </> | |
| ); | |
| } | |
| // ── Sub-components ─────────────────────────────────────── | |
| function RateLimitRow({ | |
| label, remaining, limit, reset, pct: pctValue, | |
| formatValue = (n) => n.toLocaleString(), | |
| }: { | |
| label: string; | |
| remaining: number | null | undefined; | |
| limit: number | null | undefined; | |
| reset: string | null | undefined; | |
| pct: number; | |
| formatValue?: (n: number) => string; | |
| }) { | |
| const color = barColor(pctValue); | |
| const hasData = remaining !== null && remaining !== undefined && limit; | |
| return ( | |
| <div> | |
| <div className="flex items-center justify-between mb-1"> | |
| <span className="text-[10px] font-semibold text-[#475569] uppercase tracking-wider">{label}</span> | |
| <div className="flex items-center gap-1.5"> | |
| {hasData && ( | |
| <span className="text-[10px] font-mono text-[#334155]"> | |
| <span className={pctValue <= 20 ? "text-red-500 font-bold" : pctValue <= 50 ? "text-amber-500 font-bold" : "text-[#059669] font-bold"}> | |
| {formatValue(remaining!)} | |
| </span> | |
| <span className="text-[#CBD5E1]"> / {formatValue(limit!)}</span> | |
| </span> | |
| )} | |
| {reset && ( | |
| <span className="text-[9px] text-[#94A3B8] bg-[#F1F5F9] px-1.5 py-0.5 rounded font-mono"> | |
| ↺ {parseReset(reset)} | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="h-1.5 rounded-full bg-[#F1F5F9] overflow-hidden"> | |
| {hasData && ( | |
| <div | |
| className={`h-full rounded-full bg-gradient-to-r ${color} transition-all duration-500`} | |
| style={{ width: `${pctValue}%` }} | |
| /> | |
| )} | |
| </div> | |
| {hasData && ( | |
| <div className="flex justify-between mt-1"> | |
| <span className="text-[8px] text-[#CBD5E1]">{pctValue}% remaining</span> | |
| <span className="text-[8px] text-[#CBD5E1]">resets in {parseReset(reset)}</span> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| function SectionLabel({ children }: { children: React.ReactNode }) { | |
| return ( | |
| <div className="flex items-center gap-2"> | |
| <div className="w-0.5 h-3.5 rounded-full bg-gradient-to-b from-[#47C3A6] to-[#14B7CC]" /> | |
| <span className="text-[10px] font-bold uppercase tracking-[0.12em] text-[#94A3B8]">{children}</span> | |
| </div> | |
| ); | |
| } | |
| function StatTile({ label, value, color, bold }: { label: string; value: number; color: string; bold?: boolean }) { | |
| return ( | |
| <div className="flex flex-col items-center bg-white border border-[#E8EDF2] rounded-lg py-2 px-1"> | |
| <span className={`text-sm font-mono tabular-nums leading-none ${bold ? "font-bold" : "font-semibold"}`} style={{ color }}> | |
| {value.toLocaleString()} | |
| </span> | |
| <span className="text-[8px] text-[#CBD5E1] uppercase tracking-wider mt-1">{label}</span> | |
| </div> | |
| ); | |
| } | |
| function Chip({ children, color }: { children: React.ReactNode; color: "green" | "amber" }) { | |
| const cls = color === "green" | |
| ? "text-[#059669] bg-[#D1FAE5]" | |
| : "text-amber-600 bg-amber-50 border border-amber-200"; | |
| return ( | |
| <span className={`text-[8px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full ${cls}`}> | |
| {children} | |
| </span> | |
| ); | |
| } | |
| function SettingsIcon() { | |
| return ( | |
| <svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" className="text-[#94A3B8]"> | |
| <path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" /> | |
| </svg> | |
| ); | |
| } | |