HTML 77.2%
TypeScript 10.5%
Python 9.6%
JavaScript 2.5%
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