SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
9.7 KB · 216 lines tsx
Raw Blame History
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