SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
7.6 KB · 156 lines tsx
Raw Blame History
1'use client';2import { Copy, Play } from 'lucide-react';3import { useMemo, useState } from 'react';4import { cn } from '@/lib/cn';5import { clientTry } from '@/lib/client-api';6import { PUBLIC_API_BASE } from '@/lib/site';7import { buildPath, ROUTES, type RouteDef } from './routes';89/*10  Interactive request builder (client): route picker (static catalogue of the 1.1 routes), parameter fields with11  examples, "Try" against the same origin `/api/v1` (rewritten to the API), copyable curl / Python / JavaScript snippets12  and the response-shape excerpt from docs/API.md. Nothing is fetched until the user asks.13*/1415type Lang = 'curl' | 'python' | 'js';16const MAX_BODY = 6000;1718function snippet(lang: Lang, url: string): string {19  if (lang === 'curl') return `curl -s "${url}" | jq .`;20  if (lang === 'python')21    return `import requests\n\nr = requests.get("${url}", headers={"User-Agent": "my-app/1.0 (contact@example.com)"}, timeout=30)\nr.raise_for_status()\ndata = r.json()  # numeric aggregates may be strings — coerce before arithmetic\nprint(data)`;22  return `const res = await fetch("${url}", { headers: { accept: "application/json" } });\nif (!res.ok) throw new Error(\`API \${res.status}\`);\nconst data = await res.json(); // null means "not stated by any source"\nconsole.log(data);`;23}2425export function RequestBuilder({ initialRoute = 'search' }: { initialRoute?: string }) {26  const [routeId, setRouteId] = useState(initialRoute);27  const route: RouteDef = ROUTES.find((r) => r.id === routeId) ?? ROUTES[0]!;28  const [values, setValues] = useState<Record<string, string>>({});29  const [lang, setLang] = useState<Lang>('curl');30  const [result, setResult] = useState<{ status: number; ms: number; body: unknown; headers: Record<string, string> } | null>(null);31  const [busy, setBusy] = useState(false);32  const [error, setError] = useState<string | null>(null);33  const [copied, setCopied] = useState(false);34  const path = useMemo(() => buildPath(route, values), [route, values]);35  const url = `${PUBLIC_API_BASE}${path}`;36  const groups = useMemo(() => [...new Set(ROUTES.map((r) => r.group))], []);3738  const pick = (id: string) => {39    setRouteId(id);40    setValues({});41    setResult(null);42    setError(null);43  };44  const tryIt = async () => {45    setBusy(true);46    setError(null);47    try {48      setResult(await clientTry(path));49    } catch (e) {50      setError((e as Error).message);51    } finally {52      setBusy(false);53    }54  };55  const copy = async () => {56    try {57      await navigator.clipboard.writeText(snippet(lang, url));58      setCopied(true);59      setTimeout(() => setCopied(false), 1500);60    } catch {61      /* ignore */62    }63  };64  const bodyText = result ? (typeof result.body === 'string' ? result.body : JSON.stringify(result.body, null, 2)) : '';65  const cls = 'h-10 w-full border border-rule bg-surface px-2 text-sm text-ink focus:border-accent focus:outline-none';6667  return (68    <div className="grid gap-6 lg:grid-cols-[18rem_minmax(0,1fr)]" data-request-builder>69      <div className="min-w-0">70        <label className="block">71          <span className="eyebrow block pb-1">Route</span>72          <select value={route.id} onChange={(e) => pick(e.target.value)} className={cls} data-route-picker>73            {groups.map((g) => (74              <optgroup key={g} label={g}>75                {ROUTES.filter((r) => r.group === g).map((r) => (76                  <option key={r.id} value={r.id}>77                    {r.path} — {r.summary}78                  </option>79                ))}80              </optgroup>81            ))}82          </select>83        </label>84        <p className="mono mt-2 break-all text-xs text-ink-2">85          {route.method} {route.path}86        </p>87        <p className="mt-1 text-xs text-ink-3">{route.summary}</p>88        {route.params.length > 0 && (89          <div className="mt-4 grid grid-cols-2 gap-2 lg:grid-cols-1">90            {route.params.map((p) => (91              <label key={p.name} className="block min-w-0">92                <span className="eyebrow block pb-1">93                  {p.name}94                  {p.path && <span className="normal-case tracking-normal text-ink-3"> · path</span>}95                </span>96                <input value={values[p.name] ?? ''} onChange={(e) => setValues((v) => ({ ...v, [p.name]: e.target.value }))} placeholder={p.example ?? p.hint ?? ''} className={cn(cls, 'mono text-xs')} aria-label={`${p.name} parameter`} />97                {p.hint && p.example && <span className="mt-0.5 block text-[11px] text-ink-3">{p.hint}</span>}98              </label>99            ))}100          </div>101        )}102      </div>103      <div className="min-w-0 space-y-4">104        <div className="flex flex-wrap items-center gap-2">105          <code className="mono min-w-0 flex-1 truncate border border-rule bg-surface px-2 py-2 text-xs text-ink" title={url} data-request-url>106            {url}107          </code>108          <button type="button" onClick={tryIt} disabled={busy} className="inline-flex h-10 items-center gap-1.5 bg-ink px-3 text-sm font-medium text-canvas hover:opacity-90 disabled:opacity-60" data-try>109            <Play className="size-4" aria-hidden /> {busy ? 'Requesting…' : 'Try'}110          </button>111        </div>112        <div>113          <div className="flex items-center gap-1 border-b border-rule" role="tablist" aria-label="Snippet language">114            {(['curl', 'python', 'js'] as Lang[]).map((l) => (115              <button key={l} type="button" role="tab" aria-selected={lang === l} onClick={() => setLang(l)} className={cn('h-9 border-b-2 px-3 text-sm', lang === l ? 'border-ink font-medium text-ink' : 'border-transparent text-ink-2 hover:text-ink')}>116                {l === 'js' ? 'JavaScript' : l === 'python' ? 'Python' : 'curl'}117              </button>118            ))}119            <button type="button" onClick={copy} className="ml-auto inline-flex h-9 items-center gap-1 px-2 text-xs text-ink-3 hover:text-ink">120              <Copy className="size-3.5" aria-hidden /> {copied ? 'Copied' : 'Copy'}121            </button>122          </div>123          <pre className="scrollbar-thin mt-2 overflow-x-auto border border-rule bg-surface p-3 text-[12.5px] leading-relaxed text-ink">124            <code>{snippet(lang, url)}</code>125          </pre>126        </div>127        <div>128          <p className="eyebrow mb-1">Response shape (docs/API.md)</p>129          <pre className="scrollbar-thin overflow-x-auto whitespace-pre-wrap border border-dashed border-rule p-3 text-[12px] leading-relaxed text-ink-2">130            <code>{route.returns}</code>131          </pre>132        </div>133        <div aria-live="polite" data-try-result>134          {error && <p className="text-sm text-danger">Request failed: {error}</p>}135          {result && (136            <>137              <p className="tnum flex flex-wrap items-center gap-x-3 text-xs text-ink-3">138                <span className={result.status < 400 ? 'text-positive' : 'text-danger'}>HTTP {result.status}</span>139                <span>{result.ms} ms</span>140                {Object.entries(result.headers).map(([k, v]) => (141                  <span key={k} className="mono">142                    {k}: {v.length > 40 ? `${v.slice(0, 40)}…` : v}143                  </span>144                ))}145              </p>146              <pre className="scrollbar-thin mt-2 max-h-[28rem] overflow-auto border border-rule bg-surface p-3 text-[12px] leading-relaxed text-ink">147                <code>{bodyText.length > MAX_BODY ? `${bodyText.slice(0, MAX_BODY)}\n… (${bodyText.length - MAX_BODY} more characters — open the URL for the full body)` : bodyText}</code>148              </pre>149            </>150          )}151        </div>152      </div>153    </div>154  );155}156