spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1'use client';2import { Check, Copy, Play } from 'lucide-react';3import { useMemo, useState } from 'react';4import { t } from '@/i18n';5import { rawRequest } from '@/lib/client-api-platform';6import { cn } from '@/lib/cn';7import { compact } from '@/lib/format';89export interface ExplorerParam {10 name: string;11 /** `path` params are substituted in the template; `query` params appended. */12 in: 'path' | 'query';13 description: string;14 example?: string;15 required?: boolean;16 options?: string[];17}18export interface ExplorerEndpoint {19 id: string;20 group: string;21 /** e.g. "/countries/{id}/series/{indicator}" */22 template: string;23 summary: string;24 params: ExplorerParam[];25}2627const MAX_SHOW = 40_000;2829function buildPath(ep: ExplorerEndpoint, values: Record<string, string>): string {30 let path = ep.template;31 const q = new URLSearchParams();32 for (const p of ep.params) {33 const v = (values[p.name] ?? p.example ?? '').trim();34 if (p.in === 'path') path = path.replace(`{${p.name}}`, encodeURIComponent(v || p.example || ''));35 else if (v) q.append(p.name, v);36 }37 const s = q.toString();38 return `/api/v1${path}${s ? `?${s}` : ''}`;39}4041function snippets(url: string): Record<'curl' | 'js' | 'py', string> {42 return {43 curl: `curl -s "${url}" | jq .`,44 js: `const res = await fetch("${url}", { headers: { accept: "application/json" } });\nconst data = await res.json();\nconsole.log(data.meta, data);`,45 py: `import requests\n\nr = requests.get("${url}", headers={"accept": "application/json"}, timeout=30)\nr.raise_for_status()\ndata = r.json()\nprint(data["meta"], list(data)[:8])`,46 };47}4849/**50 * Interactive endpoint explorer: pick an endpoint, fill typed parameters, run it against the same-origin API,51 * see status/latency/size and the pretty JSON (capped), and copy the request as curl / JavaScript / Python.52 */53export function EndpointExplorer({ endpoints, base }: { endpoints: ExplorerEndpoint[]; base: string }) {54 const [id, setId] = useState(endpoints[0]?.id ?? '');55 const ep = useMemo(() => endpoints.find((e) => e.id === id) ?? endpoints[0]!, [endpoints, id]);56 const [values, setValues] = useState<Record<string, string>>({});57 const [lang, setLang] = useState<'curl' | 'js' | 'py'>('curl');58 const [copied, setCopied] = useState(false);59 const [state, setState] = useState<'idle' | 'running' | 'done' | 'error'>('idle');60 const [result, setResult] = useState<{ status: number; ms: number; bytes: number; text: string } | null>(null);61 const [error, setError] = useState<string | null>(null);62 const path = buildPath(ep, values);63 const url = `${base}${path}`;64 const snip = snippets(url);65 const groups = Array.from(new Set(endpoints.map((e) => e.group)));6667 const run = async () => {68 setState('running');69 setError(null);70 try {71 const r = await rawRequest(path);72 const pretty = r.json != null ? JSON.stringify(r.json, null, 2) : r.text;73 setResult({ status: r.status, ms: r.ms, bytes: r.bytes, text: pretty });74 setState('done');75 } catch (e) {76 setError((e as Error).message);77 setState('error');78 }79 };80 const copy = async () => {81 try {82 await navigator.clipboard.writeText(snip[lang]);83 setCopied(true);84 setTimeout(() => setCopied(false), 1600);85 } catch {86 /* ignore */87 }88 };89 const shown = result ? (result.text.length > MAX_SHOW ? result.text.slice(0, MAX_SHOW) : result.text) : '';9091 return (92 <div className="grid gap-x-10 gap-y-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">93 <div className="min-w-0">94 <label className="block">95 <span className="eyebrow mb-1.5 block">{t('apiPage.explorer.endpoint')}</span>96 <select97 value={ep.id}98 onChange={(e) => {99 setId(e.target.value);100 setValues({});101 setResult(null);102 setState('idle');103 }}104 className="h-11 w-full rounded-sm border border-rule bg-surface px-2 font-mono text-sm text-ink outline-none focus:border-accent md:h-10"105 aria-label={t('apiPage.explorer.endpoint')}106 >107 {groups.map((g) => (108 <optgroup key={g} label={g}>109 {endpoints110 .filter((e) => e.group === g)111 .map((e) => (112 <option key={e.id} value={e.id}>113 GET {e.template}114 </option>115 ))}116 </optgroup>117 ))}118 </select>119 </label>120 <p className="mt-1.5 text-sm text-ink-2">{ep.summary}</p>121122 {ep.params.length ? (123 <div className="mt-4">124 <div className="eyebrow mb-1.5">{t('apiPage.explorer.params')}</div>125 <ul className="divide-y divide-rule border-y border-rule">126 {ep.params.map((p) => (127 <li key={p.name} className="grid gap-x-3 gap-y-1 py-2 sm:grid-cols-[9rem_minmax(0,1fr)]">128 <label htmlFor={`p-${ep.id}-${p.name}`} className="pt-2 font-mono text-xs text-ink">129 {p.name}130 <span className="ml-1 font-ui text-2xs text-ink-3">{p.in === 'path' ? t('apiPage.explorer.pathParam') : p.required ? t('apiPage.explorer.required') : t('apiPage.explorer.optional')}</span>131 </label>132 <div className="min-w-0">133 {p.options ? (134 <select id={`p-${ep.id}-${p.name}`} value={values[p.name] ?? p.example ?? ''} onChange={(e) => setValues((v) => ({ ...v, [p.name]: e.target.value }))} className="h-11 w-full rounded-sm border border-rule bg-surface px-2 font-mono text-sm text-ink outline-none focus:border-accent md:h-9">135 {!p.required ? <option value="">—</option> : null}136 {p.options.map((o) => (137 <option key={o} value={o}>138 {o}139 </option>140 ))}141 </select>142 ) : (143 <input id={`p-${ep.id}-${p.name}`} type="text" value={values[p.name] ?? ''} placeholder={p.example ?? ''} onChange={(e) => setValues((v) => ({ ...v, [p.name]: e.target.value }))} className="h-11 w-full rounded-sm border border-rule bg-surface px-2 font-mono text-sm text-ink outline-none placeholder:text-ink-3 focus:border-accent md:h-9" />144 )}145 <p className="mt-0.5 text-xs text-ink-3">{p.description}</p>146 </div>147 </li>148 ))}149 </ul>150 </div>151 ) : null}152153 <div className="mt-4">154 <div className="eyebrow mb-1.5">{t('apiPage.explorer.request')}</div>155 <div className="flex flex-wrap items-center gap-1">156 {(['curl', 'js', 'py'] as const).map((l) => (157 <button key={l} type="button" onClick={() => setLang(l)} className={cn('inline-flex h-11 items-center rounded-sm px-2.5 text-xs md:h-8', lang === l ? 'bg-ink text-paper' : 'text-ink-2 hover:bg-surface-2 hover:text-ink')} aria-pressed={lang === l}>158 {t(`apiPage.explorer.copy.${l}` as 'apiPage.explorer.copy.curl')}159 </button>160 ))}161 <button type="button" onClick={copy} className="ml-auto inline-flex h-11 items-center gap-1 rounded-sm border border-rule px-2 text-xs text-ink-2 hover:bg-surface-2 hover:text-ink md:h-8" aria-live="polite">162 {copied ? <Check size={13} aria-hidden /> : <Copy size={13} aria-hidden />}163 {copied ? t('common.copied') : t('indicator.copy')}164 </button>165 </div>166 <pre className="mt-1 overflow-x-auto rounded-sm border border-rule bg-surface p-3 font-mono text-xs leading-relaxed text-ink">167 <code>{snip[lang]}</code>168 </pre>169 <button type="button" onClick={run} disabled={state === 'running'} className="mt-3 inline-flex h-11 items-center gap-2 rounded-sm bg-ink px-4 text-sm font-medium text-paper hover:bg-accent hover:text-accent-ink disabled:opacity-60 md:h-10">170 <Play size={14} aria-hidden /> {state === 'running' ? t('apiPage.explorer.running') : t('apiPage.explorer.run')}171 </button>172 </div>173 </div>174175 <div className="min-w-0">176 <div className="eyebrow mb-1.5">{t('apiPage.explorer.response')}</div>177 {state === 'error' ? <p className="text-sm text-down">{t('apiPage.explorer.error', { msg: error ?? '' })}</p> : null}178 {result ? (179 <div className="tnum mb-1 text-xs text-ink-3">180 <span className={result.status < 400 ? 'text-up' : 'text-down'}>{t('apiPage.explorer.status', { status: result.status, ms: result.ms, size: `${compact(result.bytes)}B` })}</span>181 {result.text.length > MAX_SHOW ? <span> · {t('apiPage.explorer.truncated', { n: compact(MAX_SHOW) })}</span> : null}182 </div>183 ) : null}184 <pre className={cn('max-h-[36rem] min-h-[12rem] overflow-auto rounded-sm border border-rule bg-surface-2/60 p-3 font-mono text-xs leading-relaxed text-ink-2', state === 'running' && 'opacity-60')} aria-live="polite" aria-busy={state === 'running'}>185 <code>{shown || `GET ${path}`}</code>186 </pre>187 </div>188 </div>189 );190}191