TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import * as React from "react";2import Link from "next/link";3import { ChevronLeft, ChevronRight, Search } from "lucide-react";4import { cn } from "@/lib/utils";5import { Badge } from "@/components/ui/badge";6import { Button } from "@/components/ui/button";7import { Input } from "@/components/ui/input";8import { Skeleton, TableSkeleton } from "@/components/ui/skeleton";9import { formatDate, timeAgo } from "@/lib/format";10import { RANGES, rangeLabel, type Range } from "@/lib/queries/admin";1112/** Dense table cell for numbers: right-aligned, tabular, mono. */13export function Num({ children, className, muted }: { children: React.ReactNode; className?: string; muted?: boolean }) {14 return <span className={cn("tabular font-mono text-[12.5px]", muted && "text-fg-subtle", className)}>{children}</span>;15}16export const numCell = "text-right tabular font-mono text-[12.5px] whitespace-nowrap";17export const monoCell = "font-mono text-[12.5px] whitespace-nowrap";1819export function Mono({ children, className, title }: { children: React.ReactNode; className?: string; title?: string }) {20 return (21 <span className={cn("font-mono text-[12.5px]", className)} title={title}>22 {children}23 </span>24 );25}2627/** Upstream provider name. Admin-only by construction: this component only lives in components/admin. */28export function ProviderBadge({ id, className }: { id: string | null | undefined; className?: string }) {29 if (!id) return <span className="text-fg-subtle">—</span>;30 return (31 <Badge variant="outline" className={cn("font-mono lowercase", className)}>32 {id}33 </Badge>34 );35}3637export function NetworkBadge({ network }: { network: string | null | undefined }) {38 if (!network) return <span className="text-fg-subtle">—</span>;39 return <Badge variant={network === "residential" ? "accent" : network === "mobile" ? "info" : "default"}>{network}</Badge>;40}4142/** Single-plan platform: `unlimited` is the only valid value; anything else is a legacy row not yet migrated. */43export function PlanBadge({ plan }: { plan: string }) {44 const v = plan === "unlimited" ? "solid" : "outline";45 return (46 <Badge variant={v} className="capitalize" title={plan === "unlimited" ? undefined : "Legacy plan value (treated as unlimited)"}>47 {plan}48 </Badge>49 );50}5152export function BoolBadge({ value, yes = "yes", no = "no", invert }: { value: boolean; yes?: string; no?: string; invert?: boolean }) {53 const good = invert ? !value : value;54 return (55 <Badge variant={good ? "success" : "outline"} dot>56 {value ? yes : no}57 </Badge>58 );59}6061export function DateCell({ value, relative = true }: { value: Date | string | null | undefined; relative?: boolean }) {62 if (!value) return <span className="text-fg-subtle">—</span>;63 return (64 <time dateTime={new Date(value).toISOString()} title={formatDate(value)} className="whitespace-nowrap text-[12.5px] text-fg-muted">65 {relative ? timeAgo(value) : formatDate(value)}66 </time>67 );68}6970export function Panel({ title, description, right, children, className, bodyClassName, flush }: { title?: React.ReactNode; description?: React.ReactNode; right?: React.ReactNode; children: React.ReactNode; className?: string; bodyClassName?: string; flush?: boolean }) {71 return (72 <section className={cn("rounded-lg border border-border bg-bg-elevated shadow-xs", className)}>73 {title ? (74 <header className="flex flex-wrap items-start justify-between gap-2 border-b border-border px-4 py-2.5">75 <div className="min-w-0">76 <h2 className="text-[13px] font-semibold tracking-tight">{title}</h2>77 {description ? <p className="mt-0.5 text-[12px] text-fg-muted leading-relaxed">{description}</p> : null}78 </div>79 {right ? <div className="flex shrink-0 items-center gap-2">{right}</div> : null}80 </header>81 ) : null}82 <div className={cn(!flush && "p-4", bodyClassName)}>{children}</div>83 </section>84 );85}8687/** Two-column key/value grid for detail pages. */88export function KV({ items, cols = 2, className }: { items: Array<{ label: React.ReactNode; value: React.ReactNode; mono?: boolean; span?: boolean }>; cols?: 1 | 2 | 3; className?: string }) {89 return (90 <dl className={cn("grid gap-x-6 gap-y-3 text-[13px]", cols === 2 && "sm:grid-cols-2", cols === 3 && "sm:grid-cols-2 lg:grid-cols-3", className)}>91 {items.map((it, i) => (92 <div key={i} className={cn("min-w-0", it.span && "sm:col-span-full")}>93 <dt className="text-[11.5px] font-medium uppercase tracking-wide text-fg-subtle">{it.label}</dt>94 <dd className={cn("mt-0.5 break-words text-fg", it.mono && "font-mono text-[12.5px]")}>{it.value ?? <span className="text-fg-subtle">—</span>}</dd>95 </div>96 ))}97 </dl>98 );99}100101/** Range selector (24h / 7d / 30d) rendered as links; preserves other query params. */102export function RangeTabs({ current, basePath, params }: { current: Range; basePath: string; params?: Record<string, string | undefined> }) {103 return (104 <div className="inline-flex h-8 items-center gap-0.5 rounded-md bg-bg-muted p-1 text-fg-muted" role="tablist" aria-label="Time range">105 {RANGES.map((r) => {106 const sp = new URLSearchParams();107 for (const [k, v] of Object.entries(params ?? {})) if (v) sp.set(k, v);108 sp.set("range", r);109 const active = r === current;110 return (111 <Link112 key={r}113 href={`${basePath}?${sp.toString()}`}114 role="tab"115 aria-selected={active}116 title={rangeLabel(r)}117 className={cn("inline-flex h-6 items-center rounded-[5px] px-2.5 text-[12.5px] font-medium transition-colors", active ? "bg-bg text-fg shadow-xs" : "hover:text-fg")}118 >119 {r}120 </Link>121 );122 })}123 </div>124 );125}126127export function Pagination({ page, pages, total, basePath, params, pageSize }: { page: number; pages: number; total: number; basePath: string; params?: Record<string, string | undefined>; pageSize: number }) {128 const href = (p: number) => {129 const sp = new URLSearchParams();130 for (const [k, v] of Object.entries(params ?? {})) if (v) sp.set(k, v);131 if (p > 1) sp.set("page", String(p));132 const q = sp.toString();133 return q ? `${basePath}?${q}` : basePath;134 };135 const from = total === 0 ? 0 : (page - 1) * pageSize + 1;136 const to = Math.min(total, page * pageSize);137 return (138 <div className="flex items-center justify-between gap-3 border-t border-border px-4 py-2 text-[12.5px] text-fg-muted">139 <span className="tabular">140 {from.toLocaleString()}–{to.toLocaleString()} of {total.toLocaleString()}141 </span>142 <div className="flex items-center gap-1">143 <Button asChild variant="outline" size="xs" disabled={page <= 1} aria-disabled={page <= 1} className={cn(page <= 1 && "pointer-events-none opacity-50")}>144 <Link href={href(Math.max(1, page - 1))} aria-label="Previous page">145 <ChevronLeft className="size-3.5" />146 </Link>147 </Button>148 <span className="px-2 tabular">149 {page} / {pages}150 </span>151 <Button asChild variant="outline" size="xs" className={cn(page >= pages && "pointer-events-none opacity-50")}>152 <Link href={href(Math.min(pages, page + 1))} aria-label="Next page" aria-disabled={page >= pages}>153 <ChevronRight className="size-3.5" />154 </Link>155 </Button>156 </div>157 </div>158 );159}160161/** Plain GET search form — no client JS needed. Extra hidden params are preserved. */162export function SearchForm({ action, q, placeholder, hidden, children }: { action: string; q?: string; placeholder?: string; hidden?: Record<string, string | undefined>; children?: React.ReactNode }) {163 return (164 <form action={action} method="get" className="flex flex-wrap items-center gap-2" role="search">165 {Object.entries(hidden ?? {}).map(([k, v]) => (v ? <input key={k} type="hidden" name={k} value={v} /> : null))}166 <div className="relative">167 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" aria-hidden />168 <Input name="q" defaultValue={q} placeholder={placeholder ?? "Search…"} className="h-8 w-[260px] pl-8 text-[13px]" aria-label="Search" />169 </div>170 {children}171 <Button type="submit" variant="outline" size="sm">172 Search173 </Button>174 {q ? (175 <Button asChild variant="ghost" size="sm">176 <Link href={action}>Clear</Link>177 </Button>178 ) : null}179 </form>180 );181}182183export function MarginText({ value, pct }: { value: number; pct: number | null }) {184 const neg = value < 0;185 return (186 <span className={cn("tabular font-mono text-[12.5px]", neg ? "text-danger" : "text-success")}>187 {neg ? "−" : ""}${Math.abs(value).toFixed(value !== 0 && Math.abs(value) < 0.01 ? 4 : 2)}188 {pct !== null ? <span className="ml-1 text-fg-subtle">({pct.toFixed(1)}%)</span> : null}189 </span>190 );191}192193export function AdminLoading({ stats = 4, rows = 8 }: { stats?: number; rows?: number }) {194 return (195 <div className="space-y-6" aria-busy="true" aria-live="polite">196 <div className="space-y-2">197 <Skeleton className="h-6 w-48" />198 <Skeleton className="h-3.5 w-80" />199 </div>200 {stats ? (201 <div className="grid gap-px overflow-hidden rounded-lg border border-border bg-border sm:grid-cols-2 lg:grid-cols-4">202 {Array.from({ length: stats }).map((_, i) => (203 <div key={i} className="space-y-2 bg-bg-elevated px-5 py-4">204 <Skeleton className="h-3 w-20" />205 <Skeleton className="h-6 w-28" />206 </div>207 ))}208 </div>209 ) : null}210 <div className="rounded-lg border border-border bg-bg-elevated">211 <TableSkeleton rows={rows} cols={6} />212 </div>213 </div>214 );215}216