@barodoc/theme-docs 5.0.0 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,789 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../lib/utils.js";
3
+
4
+ interface ParamDef {
5
+ name: string;
6
+ in: "query" | "path" | "header" | "body";
7
+ type?: string;
8
+ required?: boolean;
9
+ defaultValue?: string;
10
+ description?: string;
11
+ enum?: string[];
12
+ }
13
+
14
+ interface ApiPlaygroundProps {
15
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
16
+ url: string;
17
+ params?: ParamDef[];
18
+ body?: string;
19
+ headers?: Record<string, string>;
20
+ className?: string;
21
+ }
22
+
23
+ interface HistoryEntry {
24
+ id: number;
25
+ method: string;
26
+ url: string;
27
+ status: number;
28
+ statusText: string;
29
+ time: number;
30
+ size: number;
31
+ body: string;
32
+ headers: [string, string][];
33
+ timestamp: number;
34
+ }
35
+
36
+ const METHOD_COLORS: Record<string, string> = {
37
+ GET: "bd-method-get",
38
+ POST: "bd-method-post",
39
+ PUT: "bd-method-put",
40
+ PATCH: "bd-method-patch",
41
+ DELETE: "bd-method-delete",
42
+ };
43
+
44
+ type SnippetLang = "curl" | "javascript" | "python" | "node";
45
+ type ResponseTab = "body" | "headers" | "code";
46
+
47
+ const SNIPPET_LABELS: Record<SnippetLang, string> = {
48
+ curl: "cURL",
49
+ javascript: "JavaScript",
50
+ python: "Python",
51
+ node: "Node.js",
52
+ };
53
+
54
+ const DB_NAME = "barodoc-api-playground";
55
+ const DB_VERSION = 1;
56
+ const STORE_NAME = "history";
57
+ const MAX_HISTORY = 5;
58
+
59
+ function openDb(): Promise<IDBDatabase> {
60
+ return new Promise((resolve, reject) => {
61
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
62
+ req.onupgradeneeded = () => {
63
+ const db = req.result;
64
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
65
+ db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true });
66
+ }
67
+ };
68
+ req.onsuccess = () => resolve(req.result);
69
+ req.onerror = () => reject(req.error);
70
+ });
71
+ }
72
+
73
+ async function loadHistory(): Promise<HistoryEntry[]> {
74
+ try {
75
+ const db = await openDb();
76
+ return new Promise((resolve) => {
77
+ const tx = db.transaction(STORE_NAME, "readonly");
78
+ const store = tx.objectStore(STORE_NAME);
79
+ const req = store.getAll();
80
+ req.onsuccess = () => {
81
+ const items = (req.result as HistoryEntry[]).sort((a, b) => b.timestamp - a.timestamp);
82
+ resolve(items.slice(0, MAX_HISTORY));
83
+ };
84
+ req.onerror = () => resolve([]);
85
+ });
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ async function saveHistory(entry: Omit<HistoryEntry, "id">): Promise<void> {
92
+ try {
93
+ const db = await openDb();
94
+ const tx = db.transaction(STORE_NAME, "readwrite");
95
+ const store = tx.objectStore(STORE_NAME);
96
+ store.add(entry);
97
+
98
+ const allReq = store.getAll();
99
+ allReq.onsuccess = () => {
100
+ const items = (allReq.result as HistoryEntry[]).sort((a, b) => b.timestamp - a.timestamp);
101
+ const toDelete = items.slice(MAX_HISTORY);
102
+ for (const item of toDelete) {
103
+ store.delete(item.id);
104
+ }
105
+ };
106
+ } catch {
107
+ // IndexedDB unavailable — silent fail
108
+ }
109
+ }
110
+
111
+ function generateSnippet(
112
+ lang: SnippetLang,
113
+ method: string,
114
+ url: string,
115
+ headers: Record<string, string>,
116
+ body?: string,
117
+ ): string {
118
+ switch (lang) {
119
+ case "curl": {
120
+ const parts = [`curl -X ${method} '${url}'`];
121
+ for (const [k, v] of Object.entries(headers)) {
122
+ parts.push(` -H '${k}: ${v}'`);
123
+ }
124
+ if (body) parts.push(` -d '${body}'`);
125
+ return parts.join(" \\\n");
126
+ }
127
+ case "javascript": {
128
+ const opts: string[] = [` method: "${method}"`];
129
+ if (Object.keys(headers).length > 0) {
130
+ opts.push(` headers: ${JSON.stringify(headers, null, 4).replace(/\n/g, "\n ")}`);
131
+ }
132
+ if (body) opts.push(` body: JSON.stringify(${body})`);
133
+ return `const response = await fetch("${url}", {\n${opts.join(",\n")}\n});\nconst data = await response.json();\nconsole.log(data);`;
134
+ }
135
+ case "python": {
136
+ const lines = ["import requests", ""];
137
+ if (body) {
138
+ lines.push(`response = requests.${method.toLowerCase()}(`);
139
+ lines.push(` "${url}",`);
140
+ if (Object.keys(headers).length > 0) {
141
+ lines.push(` headers=${pythonDict(headers)},`);
142
+ }
143
+ lines.push(` json=${body}`);
144
+ lines.push(")");
145
+ } else {
146
+ lines.push(`response = requests.${method.toLowerCase()}(`);
147
+ lines.push(` "${url}"${Object.keys(headers).length > 0 ? "," : ""}`);
148
+ if (Object.keys(headers).length > 0) {
149
+ lines.push(` headers=${pythonDict(headers)}`);
150
+ }
151
+ lines.push(")");
152
+ }
153
+ lines.push("print(response.json())");
154
+ return lines.join("\n");
155
+ }
156
+ case "node": {
157
+ const lines = ['const axios = require("axios");', ""];
158
+ const config: string[] = [];
159
+ if (Object.keys(headers).length > 0) {
160
+ config.push(` headers: ${JSON.stringify(headers, null, 4).replace(/\n/g, "\n ")}`);
161
+ }
162
+ if (body) config.push(` data: ${body}`);
163
+ lines.push(`const { data } = await axios({`);
164
+ lines.push(` method: "${method}",`);
165
+ lines.push(` url: "${url}",`);
166
+ if (config.length > 0) lines.push(config.join(",\n") + ",");
167
+ lines.push("});");
168
+ lines.push("console.log(data);");
169
+ return lines.join("\n");
170
+ }
171
+ }
172
+ }
173
+
174
+ function pythonDict(obj: Record<string, string>): string {
175
+ const entries = Object.entries(obj).map(([k, v]) => `"${k}": "${v}"`);
176
+ if (entries.length <= 2) return `{${entries.join(", ")}}`;
177
+ return `{\n ${entries.join(",\n ")}\n }`;
178
+ }
179
+
180
+ function highlightJson(text: string): React.ReactNode[] {
181
+ const withValues = text.replace(
182
+ /("(?:[^"\\]|\\.)*")(\s*:)?|(\b(?:true|false|null)\b)|(\b-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)/g,
183
+ (match, str, colon, bool, num) => {
184
+ if (str && colon) return `<k>${str}</k>${colon}`;
185
+ if (str) return `<s>${str}</s>`;
186
+ if (bool) return `<b>${bool}</b>`;
187
+ if (num) return `<n>${num}</n>`;
188
+ return match;
189
+ },
190
+ );
191
+
192
+ const parts = withValues.split(/(<[ksnb]>.*?<\/[ksnb]>)/g);
193
+ return parts.map((part, i) => {
194
+ const keyM = part.match(/^<k>(.*)<\/k>$/);
195
+ if (keyM) return <span key={i} className="bd-json-key">{keyM[1]}</span>;
196
+ const strM = part.match(/^<s>(.*)<\/s>$/);
197
+ if (strM) return <span key={i} className="bd-json-str">{strM[1]}</span>;
198
+ const boolM = part.match(/^<b>(.*)<\/b>$/);
199
+ if (boolM) return <span key={i} className="bd-json-bool">{boolM[1]}</span>;
200
+ const numM = part.match(/^<n>(.*)<\/n>$/);
201
+ if (numM) return <span key={i} className="bd-json-num">{numM[1]}</span>;
202
+ return <React.Fragment key={i}>{part}</React.Fragment>;
203
+ });
204
+ }
205
+
206
+ function formatSize(bytes: number): string {
207
+ if (bytes < 1024) return `${bytes} B`;
208
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
209
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
210
+ }
211
+
212
+ function CopyButton({ text }: { text: string }) {
213
+ const [copied, setCopied] = React.useState(false);
214
+
215
+ return (
216
+ <button
217
+ className="bd-pg-copy"
218
+ onClick={() => {
219
+ navigator.clipboard.writeText(text).then(() => {
220
+ setCopied(true);
221
+ setTimeout(() => setCopied(false), 2000);
222
+ });
223
+ }}
224
+ title="Copy"
225
+ >
226
+ {copied ? (
227
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
228
+ <polyline points="20 6 9 17 4 12" />
229
+ </svg>
230
+ ) : (
231
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
232
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
233
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
234
+ </svg>
235
+ )}
236
+ </button>
237
+ );
238
+ }
239
+
240
+ export function ApiPlayground({
241
+ method = "GET",
242
+ url,
243
+ params = [],
244
+ body: initialBody,
245
+ headers: initialHeaders = {},
246
+ className,
247
+ }: ApiPlaygroundProps) {
248
+ const [paramValues, setParamValues] = React.useState<Record<string, string>>(() => {
249
+ const vals: Record<string, string> = {};
250
+ for (const p of params) vals[p.name] = p.defaultValue || "";
251
+ return vals;
252
+ });
253
+ const [bodyValue, setBodyValue] = React.useState(initialBody || "");
254
+ const [bodyError, setBodyError] = React.useState<string | null>(null);
255
+ const [response, setResponse] = React.useState<{
256
+ status: number;
257
+ statusText: string;
258
+ body: string;
259
+ headers: [string, string][];
260
+ time: number;
261
+ size: number;
262
+ } | null>(null);
263
+ const [loading, setLoading] = React.useState(false);
264
+ const [error, setError] = React.useState<string | null>(null);
265
+ const [responseTab, setResponseTab] = React.useState<ResponseTab>("body");
266
+ const [snippetLang, setSnippetLang] = React.useState<SnippetLang>("curl");
267
+ const [showAuth, setShowAuth] = React.useState(false);
268
+ const [authType, setAuthType] = React.useState<"bearer" | "apikey" | "basic">("bearer");
269
+ const [bearerToken, setBearerToken] = React.useState(() =>
270
+ typeof window !== "undefined" ? localStorage.getItem("bd-api-bearer") || "" : "",
271
+ );
272
+ const [apiKeyName, setApiKeyName] = React.useState(() =>
273
+ typeof window !== "undefined" ? localStorage.getItem("bd-api-keyname") || "X-API-Key" : "X-API-Key",
274
+ );
275
+ const [apiKeyValue, setApiKeyValue] = React.useState(() =>
276
+ typeof window !== "undefined" ? localStorage.getItem("bd-api-keyvalue") || "" : "",
277
+ );
278
+ const [basicUser, setBasicUser] = React.useState(() =>
279
+ typeof window !== "undefined" ? localStorage.getItem("bd-api-basic-user") || "" : "",
280
+ );
281
+ const [basicPass, setBasicPass] = React.useState(() =>
282
+ typeof window !== "undefined" ? localStorage.getItem("bd-api-basic-pass") || "" : "",
283
+ );
284
+ const [history, setHistory] = React.useState<HistoryEntry[]>([]);
285
+ const [showHistory, setShowHistory] = React.useState(false);
286
+ const [historyView, setHistoryView] = React.useState<HistoryEntry | null>(null);
287
+
288
+ React.useEffect(() => {
289
+ if (typeof window === "undefined") return;
290
+ localStorage.setItem("bd-api-bearer", bearerToken);
291
+ localStorage.setItem("bd-api-keyname", apiKeyName);
292
+ localStorage.setItem("bd-api-keyvalue", apiKeyValue);
293
+ localStorage.setItem("bd-api-basic-user", basicUser);
294
+ localStorage.setItem("bd-api-basic-pass", basicPass);
295
+ }, [bearerToken, apiKeyName, apiKeyValue, basicUser, basicPass]);
296
+
297
+ React.useEffect(() => {
298
+ loadHistory().then(setHistory);
299
+ }, []);
300
+
301
+ function buildUrl(): string {
302
+ let finalUrl = url;
303
+ const queryParams = new URLSearchParams();
304
+ for (const p of params) {
305
+ const val = paramValues[p.name] || "";
306
+ if (p.in === "path") {
307
+ finalUrl = finalUrl.replace(`{${p.name}}`, encodeURIComponent(val));
308
+ } else if (p.in === "query" && val) {
309
+ queryParams.set(p.name, val);
310
+ }
311
+ }
312
+ const qs = queryParams.toString();
313
+ return qs ? `${finalUrl}?${qs}` : finalUrl;
314
+ }
315
+
316
+ function buildLiveUrl(): string {
317
+ let finalUrl = url;
318
+ for (const p of params) {
319
+ const val = paramValues[p.name];
320
+ if (p.in === "path") {
321
+ finalUrl = val
322
+ ? finalUrl.replace(`{${p.name}}`, val)
323
+ : finalUrl;
324
+ }
325
+ }
326
+ const queryParts: string[] = [];
327
+ for (const p of params) {
328
+ if (p.in === "query" && paramValues[p.name]) {
329
+ queryParts.push(`${p.name}=${paramValues[p.name]}`);
330
+ }
331
+ }
332
+ return queryParts.length > 0 ? `${finalUrl}?${queryParts.join("&")}` : finalUrl;
333
+ }
334
+
335
+ function getRequestHeaders(): Record<string, string> {
336
+ const h: Record<string, string> = { ...initialHeaders };
337
+ for (const p of params) {
338
+ if (p.in === "header" && paramValues[p.name]) {
339
+ h[p.name] = paramValues[p.name];
340
+ }
341
+ }
342
+ if (bearerToken && authType === "bearer") {
343
+ h["Authorization"] = `Bearer ${bearerToken}`;
344
+ }
345
+ if (apiKeyValue && authType === "apikey") {
346
+ h[apiKeyName] = apiKeyValue;
347
+ }
348
+ if (basicUser && authType === "basic") {
349
+ h["Authorization"] = `Basic ${btoa(`${basicUser}:${basicPass}`)}`;
350
+ }
351
+ const hasBody = ["POST", "PUT", "PATCH"].includes(method) && bodyValue;
352
+ if (hasBody && !h["Content-Type"]) {
353
+ h["Content-Type"] = "application/json";
354
+ }
355
+ return h;
356
+ }
357
+
358
+ async function sendRequest() {
359
+ const hasBody = ["POST", "PUT", "PATCH"].includes(method) && bodyValue;
360
+
361
+ if (hasBody) {
362
+ try {
363
+ JSON.parse(bodyValue);
364
+ setBodyError(null);
365
+ } catch (e) {
366
+ setBodyError(e instanceof Error ? e.message : "Invalid JSON");
367
+ return;
368
+ }
369
+ }
370
+
371
+ setLoading(true);
372
+ setError(null);
373
+ setResponse(null);
374
+ setHistoryView(null);
375
+ setResponseTab("body");
376
+
377
+ const start = performance.now();
378
+ const reqUrl = buildUrl();
379
+ const headers = getRequestHeaders();
380
+
381
+ try {
382
+ const res = await fetch(reqUrl, {
383
+ method,
384
+ headers,
385
+ body: hasBody ? bodyValue : undefined,
386
+ });
387
+
388
+ const elapsed = Math.round(performance.now() - start);
389
+ const rawText = await res.text();
390
+ const size = new Blob([rawText]).size;
391
+ let text: string;
392
+ try {
393
+ const json = JSON.parse(rawText);
394
+ text = JSON.stringify(json, null, 2);
395
+ } catch {
396
+ text = rawText;
397
+ }
398
+
399
+ const resHeaders: [string, string][] = [];
400
+ res.headers.forEach((v, k) => resHeaders.push([k, v]));
401
+
402
+ const resObj = { status: res.status, statusText: res.statusText, body: text, headers: resHeaders, time: elapsed, size };
403
+ setResponse(resObj);
404
+
405
+ const historyEntry: Omit<HistoryEntry, "id"> = {
406
+ method,
407
+ url: reqUrl,
408
+ status: res.status,
409
+ statusText: res.statusText,
410
+ time: elapsed,
411
+ size,
412
+ body: text,
413
+ headers: resHeaders,
414
+ timestamp: Date.now(),
415
+ };
416
+ await saveHistory(historyEntry);
417
+ const updated = await loadHistory();
418
+ setHistory(updated);
419
+ } catch (err) {
420
+ setError(err instanceof Error ? err.message : "Request failed");
421
+ } finally {
422
+ setLoading(false);
423
+ }
424
+ }
425
+
426
+ const liveUrl = buildLiveUrl();
427
+ const reqHeaders = getRequestHeaders();
428
+ const hasBody = ["POST", "PUT", "PATCH"].includes(method);
429
+ const currentSnippet = generateSnippet(
430
+ snippetLang,
431
+ method,
432
+ liveUrl,
433
+ reqHeaders,
434
+ hasBody && bodyValue ? bodyValue : undefined,
435
+ );
436
+
437
+ const displayResponse = historyView || response;
438
+
439
+ return (
440
+ <div className={cn("bd-playground", className)}>
441
+ {/* Header with method + live URL + send */}
442
+ <div className="bd-playground-header">
443
+ <span className={cn("bd-playground-method", METHOD_COLORS[method])}>
444
+ {method}
445
+ </span>
446
+ <code className="bd-playground-url">{liveUrl}</code>
447
+ <button className="bd-playground-send" onClick={sendRequest} disabled={loading}>
448
+ {loading ? (
449
+ <>
450
+ <svg className="bd-pg-spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
451
+ <path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
452
+ </svg>
453
+ Sending...
454
+ </>
455
+ ) : (
456
+ <>
457
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
458
+ <path d="M5 12h14M12 5l7 7-7 7" />
459
+ </svg>
460
+ Send
461
+ </>
462
+ )}
463
+ </button>
464
+ </div>
465
+
466
+ {/* Auth toggle */}
467
+ <div className="bd-pg-auth-bar">
468
+ <button className="bd-pg-auth-toggle" onClick={() => setShowAuth(!showAuth)}>
469
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
470
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
471
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
472
+ </svg>
473
+ Authentication
474
+ <svg
475
+ width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
476
+ style={{ transform: showAuth ? "rotate(180deg)" : "none", transition: "transform 200ms" }}
477
+ >
478
+ <polyline points="6 9 12 15 18 9" />
479
+ </svg>
480
+ </button>
481
+ </div>
482
+
483
+ {showAuth && (
484
+ <div className="bd-pg-auth-panel">
485
+ <div className="bd-pg-auth-type">
486
+ <button
487
+ className={cn("bd-pg-auth-type-btn", authType === "bearer" && "active")}
488
+ onClick={() => setAuthType("bearer")}
489
+ >
490
+ Bearer Token
491
+ </button>
492
+ <button
493
+ className={cn("bd-pg-auth-type-btn", authType === "apikey" && "active")}
494
+ onClick={() => setAuthType("apikey")}
495
+ >
496
+ API Key
497
+ </button>
498
+ <button
499
+ className={cn("bd-pg-auth-type-btn", authType === "basic" && "active")}
500
+ onClick={() => setAuthType("basic")}
501
+ >
502
+ Basic Auth
503
+ </button>
504
+ </div>
505
+ {authType === "bearer" && (
506
+ <div className="bd-playground-param">
507
+ <label className="bd-playground-label">Token</label>
508
+ <input
509
+ type="text"
510
+ className="bd-playground-input"
511
+ value={bearerToken}
512
+ placeholder="Enter Bearer token..."
513
+ onChange={(e) => setBearerToken(e.target.value)}
514
+ />
515
+ </div>
516
+ )}
517
+ {authType === "apikey" && (
518
+ <>
519
+ <div className="bd-playground-param">
520
+ <label className="bd-playground-label">Header Name</label>
521
+ <input
522
+ type="text"
523
+ className="bd-playground-input"
524
+ value={apiKeyName}
525
+ placeholder="X-API-Key"
526
+ onChange={(e) => setApiKeyName(e.target.value)}
527
+ />
528
+ </div>
529
+ <div className="bd-playground-param">
530
+ <label className="bd-playground-label">Key Value</label>
531
+ <input
532
+ type="text"
533
+ className="bd-playground-input"
534
+ value={apiKeyValue}
535
+ placeholder="Enter API key..."
536
+ onChange={(e) => setApiKeyValue(e.target.value)}
537
+ />
538
+ </div>
539
+ </>
540
+ )}
541
+ {authType === "basic" && (
542
+ <>
543
+ <div className="bd-playground-param">
544
+ <label className="bd-playground-label">Username</label>
545
+ <input
546
+ type="text"
547
+ className="bd-playground-input"
548
+ value={basicUser}
549
+ placeholder="Username"
550
+ onChange={(e) => setBasicUser(e.target.value)}
551
+ />
552
+ </div>
553
+ <div className="bd-playground-param">
554
+ <label className="bd-playground-label">Password</label>
555
+ <input
556
+ type="password"
557
+ className="bd-playground-input"
558
+ value={basicPass}
559
+ placeholder="Password"
560
+ onChange={(e) => setBasicPass(e.target.value)}
561
+ />
562
+ </div>
563
+ </>
564
+ )}
565
+ </div>
566
+ )}
567
+
568
+ {/* Parameters */}
569
+ {params.length > 0 && (
570
+ <div className="bd-playground-params">
571
+ {params.map((p) => (
572
+ <div key={p.name} className="bd-playground-param">
573
+ <label className="bd-playground-label">
574
+ <span className="bd-pg-param-name">{p.name}</span>
575
+ <span className="bd-playground-param-meta">
576
+ {p.in}
577
+ {p.required && <span className="bd-playground-required">*</span>}
578
+ </span>
579
+ </label>
580
+ {p.enum && p.enum.length > 0 ? (
581
+ <select
582
+ className="bd-playground-input bd-playground-select"
583
+ value={paramValues[p.name] || ""}
584
+ onChange={(e) =>
585
+ setParamValues((prev) => ({ ...prev, [p.name]: e.target.value }))
586
+ }
587
+ >
588
+ <option value="">Select {p.name}...</option>
589
+ {p.enum.map((val) => (
590
+ <option key={val} value={val}>{val}</option>
591
+ ))}
592
+ </select>
593
+ ) : (
594
+ <input
595
+ type="text"
596
+ className="bd-playground-input"
597
+ value={paramValues[p.name] || ""}
598
+ placeholder={p.description || p.type || ""}
599
+ onChange={(e) =>
600
+ setParamValues((prev) => ({ ...prev, [p.name]: e.target.value }))
601
+ }
602
+ />
603
+ )}
604
+ </div>
605
+ ))}
606
+ </div>
607
+ )}
608
+
609
+ {/* Body */}
610
+ {hasBody && (
611
+ <div className="bd-playground-body-section">
612
+ <label className="bd-playground-label">Request Body</label>
613
+ <textarea
614
+ className={cn("bd-playground-textarea", bodyError && "bd-playground-textarea-error")}
615
+ value={bodyValue}
616
+ onChange={(e) => {
617
+ setBodyValue(e.target.value);
618
+ if (bodyError) setBodyError(null);
619
+ }}
620
+ rows={6}
621
+ placeholder='{ "key": "value" }'
622
+ />
623
+ {bodyError && (
624
+ <div className="bd-pg-body-error">
625
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
626
+ <circle cx="12" cy="12" r="10" />
627
+ <line x1="15" y1="9" x2="9" y2="15" />
628
+ <line x1="9" y1="9" x2="15" y2="15" />
629
+ </svg>
630
+ Invalid JSON: {bodyError}
631
+ </div>
632
+ )}
633
+ </div>
634
+ )}
635
+
636
+ {/* Code Snippets */}
637
+ <div className="bd-pg-code-section">
638
+ <div className="bd-pg-code-tabs">
639
+ {(Object.keys(SNIPPET_LABELS) as SnippetLang[]).map((lang) => (
640
+ <button
641
+ key={lang}
642
+ className={cn("bd-pg-code-tab", snippetLang === lang && "active")}
643
+ onClick={() => setSnippetLang(lang)}
644
+ >
645
+ {SNIPPET_LABELS[lang]}
646
+ </button>
647
+ ))}
648
+ <CopyButton text={currentSnippet} />
649
+ </div>
650
+ <pre className="bd-pg-code-pre">
651
+ <code>{currentSnippet}</code>
652
+ </pre>
653
+ </div>
654
+
655
+ {/* Response */}
656
+ {(displayResponse || error) && (
657
+ <div className="bd-playground-response">
658
+ {error ? (
659
+ <div className="bd-playground-error">{error}</div>
660
+ ) : displayResponse ? (
661
+ <>
662
+ <div className="bd-playground-response-header">
663
+ <div className="bd-pg-response-meta">
664
+ <span
665
+ className={cn(
666
+ "bd-playground-status",
667
+ displayResponse.status < 300 ? "bd-status-ok" : displayResponse.status < 500 ? "bd-status-warn" : "bd-status-err",
668
+ )}
669
+ >
670
+ {displayResponse.status}
671
+ </span>
672
+ <span className="bd-pg-status-text">{displayResponse.statusText}</span>
673
+ <span className="bd-playground-time">{displayResponse.time}ms</span>
674
+ <span className="bd-playground-size">{formatSize(displayResponse.size)}</span>
675
+ </div>
676
+ <div className="bd-pg-response-tabs">
677
+ <button
678
+ className={cn("bd-pg-resp-tab", responseTab === "body" && "active")}
679
+ onClick={() => setResponseTab("body")}
680
+ >
681
+ Body
682
+ </button>
683
+ <button
684
+ className={cn("bd-pg-resp-tab", responseTab === "headers" && "active")}
685
+ onClick={() => setResponseTab("headers")}
686
+ >
687
+ Headers
688
+ <span className="bd-pg-badge">{displayResponse.headers.length}</span>
689
+ </button>
690
+ </div>
691
+ </div>
692
+
693
+ {historyView && (
694
+ <div className="bd-pg-history-banner">
695
+ Viewing history entry
696
+ <button className="bd-pg-history-dismiss" onClick={() => setHistoryView(null)}>
697
+ Back to current
698
+ </button>
699
+ </div>
700
+ )}
701
+
702
+ {responseTab === "body" && (
703
+ <div className="bd-pg-response-body">
704
+ <CopyButton text={displayResponse.body} />
705
+ <pre className="bd-playground-pre">
706
+ <code>{highlightJson(displayResponse.body)}</code>
707
+ </pre>
708
+ </div>
709
+ )}
710
+
711
+ {responseTab === "headers" && (
712
+ <div className="bd-pg-headers-table">
713
+ <table>
714
+ <thead>
715
+ <tr>
716
+ <th>Header</th>
717
+ <th>Value</th>
718
+ </tr>
719
+ </thead>
720
+ <tbody>
721
+ {displayResponse.headers.map(([k, v]) => (
722
+ <tr key={k}>
723
+ <td><code>{k}</code></td>
724
+ <td>{v}</td>
725
+ </tr>
726
+ ))}
727
+ </tbody>
728
+ </table>
729
+ </div>
730
+ )}
731
+ </>
732
+ ) : null}
733
+ </div>
734
+ )}
735
+
736
+ {/* History */}
737
+ {history.length > 0 && (
738
+ <div className="bd-pg-history-section">
739
+ <button
740
+ className="bd-pg-history-toggle"
741
+ onClick={() => setShowHistory(!showHistory)}
742
+ >
743
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
744
+ <circle cx="12" cy="12" r="10" />
745
+ <polyline points="12 6 12 12 16 14" />
746
+ </svg>
747
+ History
748
+ <span className="bd-pg-badge">{history.length}</span>
749
+ <svg
750
+ width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
751
+ style={{ transform: showHistory ? "rotate(180deg)" : "none", transition: "transform 200ms", marginLeft: "auto" }}
752
+ >
753
+ <polyline points="6 9 12 15 18 9" />
754
+ </svg>
755
+ </button>
756
+ {showHistory && (
757
+ <div className="bd-pg-history-list">
758
+ {history.map((entry) => (
759
+ <button
760
+ key={entry.id}
761
+ className={cn("bd-pg-history-item", historyView?.id === entry.id && "active")}
762
+ onClick={() => {
763
+ setHistoryView(entry);
764
+ setResponseTab("body");
765
+ setError(null);
766
+ }}
767
+ >
768
+ <span className={cn("bd-pg-history-method", METHOD_COLORS[entry.method])}>
769
+ {entry.method}
770
+ </span>
771
+ <span className="bd-pg-history-url">{entry.url.replace(/^https?:\/\/[^/]+/, "")}</span>
772
+ <span
773
+ className={cn(
774
+ "bd-pg-history-status",
775
+ entry.status < 300 ? "bd-status-ok" : entry.status < 500 ? "bd-status-warn" : "bd-status-err",
776
+ )}
777
+ >
778
+ {entry.status}
779
+ </span>
780
+ <span className="bd-pg-history-time">{entry.time}ms</span>
781
+ </button>
782
+ ))}
783
+ </div>
784
+ )}
785
+ </div>
786
+ )}
787
+ </div>
788
+ );
789
+ }