SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
9.1 KB · 191 lines tsx
Raw Blame History
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