@djangocfg/ui-tools 2.1.268 → 2.1.271

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.
Files changed (41) hide show
  1. package/dist/PlaygroundLayout-G325I6HM.mjs +736 -0
  2. package/dist/PlaygroundLayout-G325I6HM.mjs.map +1 -0
  3. package/dist/PlaygroundLayout-ZO2LO7M5.cjs +743 -0
  4. package/dist/PlaygroundLayout-ZO2LO7M5.cjs.map +1 -0
  5. package/dist/{PrettyCode.client-OO3KAJSM.mjs → PrettyCode.client-DW5LTG47.mjs} +5 -5
  6. package/dist/PrettyCode.client-DW5LTG47.mjs.map +1 -0
  7. package/dist/{PrettyCode.client-V2ZN5DTH.cjs → PrettyCode.client-SGDGQTYT.cjs} +5 -5
  8. package/dist/PrettyCode.client-SGDGQTYT.cjs.map +1 -0
  9. package/dist/{chunk-SZ2CZEQZ.mjs → chunk-QZ55LYK2.mjs} +141 -169
  10. package/dist/chunk-QZ55LYK2.mjs.map +1 -0
  11. package/dist/{chunk-CRHHUOVJ.cjs → chunk-WM4RT5KX.cjs} +139 -169
  12. package/dist/chunk-WM4RT5KX.cjs.map +1 -0
  13. package/dist/index.cjs +8 -8
  14. package/dist/index.mjs +5 -5
  15. package/package.json +6 -6
  16. package/src/tools/OpenapiViewer/README.md +121 -0
  17. package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +228 -0
  18. package/src/tools/OpenapiViewer/components/PlaygroundLayout/RequestPanel.tsx +258 -0
  19. package/src/tools/OpenapiViewer/components/PlaygroundLayout/ResponsePanel.tsx +127 -0
  20. package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +107 -0
  21. package/src/tools/OpenapiViewer/components/PlaygroundLayout/ui.tsx +137 -0
  22. package/src/tools/OpenapiViewer/components/index.ts +0 -9
  23. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +198 -208
  24. package/src/tools/OpenapiViewer/types.ts +1 -0
  25. package/src/tools/PrettyCode/PrettyCode.client.tsx +17 -12
  26. package/dist/PlaygroundLayout-FKXSULJ3.cjs +0 -971
  27. package/dist/PlaygroundLayout-FKXSULJ3.cjs.map +0 -1
  28. package/dist/PlaygroundLayout-XMMHPZYP.mjs +0 -964
  29. package/dist/PlaygroundLayout-XMMHPZYP.mjs.map +0 -1
  30. package/dist/PrettyCode.client-OO3KAJSM.mjs.map +0 -1
  31. package/dist/PrettyCode.client-V2ZN5DTH.cjs.map +0 -1
  32. package/dist/chunk-CRHHUOVJ.cjs.map +0 -1
  33. package/dist/chunk-SZ2CZEQZ.mjs.map +0 -1
  34. package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +0 -149
  35. package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +0 -278
  36. package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +0 -91
  37. package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +0 -100
  38. package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +0 -157
  39. package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +0 -253
  40. package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +0 -173
  41. package/src/tools/OpenapiViewer/components/VersionSelector.tsx +0 -68
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-tools",
3
- "version": "2.1.268",
3
+ "version": "2.1.271",
4
4
  "description": "Heavy React tools with lazy loading - for Electron, Vite, CRA, Next.js apps",
5
5
  "keywords": [
6
6
  "ui-tools",
@@ -90,8 +90,8 @@
90
90
  "check": "tsc --noEmit"
91
91
  },
92
92
  "peerDependencies": {
93
- "@djangocfg/i18n": "^2.1.268",
94
- "@djangocfg/ui-core": "^2.1.268",
93
+ "@djangocfg/i18n": "^2.1.271",
94
+ "@djangocfg/ui-core": "^2.1.271",
95
95
  "consola": "^3.4.2",
96
96
  "lucide-react": "^0.545.0",
97
97
  "react": "^19.1.0",
@@ -133,10 +133,10 @@
133
133
  "@maplibre/maplibre-gl-geocoder": "^1.7.0"
134
134
  },
135
135
  "devDependencies": {
136
- "@djangocfg/i18n": "^2.1.268",
136
+ "@djangocfg/i18n": "^2.1.271",
137
137
  "@djangocfg/playground": "workspace:*",
138
- "@djangocfg/typescript-config": "^2.1.268",
139
- "@djangocfg/ui-core": "^2.1.268",
138
+ "@djangocfg/typescript-config": "^2.1.271",
139
+ "@djangocfg/ui-core": "^2.1.271",
140
140
  "@types/mapbox__mapbox-gl-draw": "^1.4.8",
141
141
  "@types/node": "^24.7.2",
142
142
  "@types/react": "^19.1.0",
@@ -0,0 +1,121 @@
1
+ # OpenapiViewer
2
+
3
+ An interactive OpenAPI 3.x playground — browse endpoints, build requests, and inspect responses. Designed as a minimal, three-column developer tool (similar in spirit to Swagger UI / Scalar, but embedded in the app shell).
4
+
5
+ ## Usage
6
+
7
+ ```tsx
8
+ import { Playground } from '@djangocfg/ui-tools/openapi';
9
+
10
+ <Playground
11
+ config={{
12
+ schemas: [
13
+ { id: 'petstore', name: 'Petstore API', url: 'https://petstore3.swagger.io/api/v3/openapi.json' },
14
+ ],
15
+ defaultSchemaId: 'petstore',
16
+ }}
17
+ />
18
+ ```
19
+
20
+ For lazy-loading (recommended in production):
21
+
22
+ ```tsx
23
+ import { LazyOpenapiViewer } from '@djangocfg/ui-tools/openapi/lazy';
24
+
25
+ <LazyOpenapiViewer config={config} />
26
+ ```
27
+
28
+ ## Layout
29
+
30
+ ### Desktop (≥ 768 px) — three columns
31
+
32
+ ```
33
+ ┌──────────────────┬─────────────────────┬─────────────────────┐
34
+ │ ENDPOINTS │ REQUEST │ RESPONSE │
35
+ │ │ │ │
36
+ │ 🔍 Search [⊞] │ POST /api/v3/user │ 200 OK 0.4 KB │
37
+ │ │ ───────────────── │ ───────────────── │
38
+ │ POST /pet │ Path Parameters │ { "id": 1, ... } │
39
+ │ GET /pet/… │ Query Parameters │ │
40
+ │ PUT /pet/… │ Body │ │
41
+ │ … │ ▶ Auth & Headers │ │
42
+ │ │ ▶ cURL │ │
43
+ │ │ ───────────────── │ │
44
+ │ │ [Send Request] │ │
45
+ └──────────────────┴─────────────────────┴─────────────────────┘
46
+ ```
47
+
48
+ ### Mobile (< 768 px) — three tabs
49
+
50
+ Tab bar at the top: **Endpoints → Request → Response**.
51
+ Tabs switch automatically when an endpoint is selected and after a successful request.
52
+
53
+ ## File Structure
54
+
55
+ ```
56
+ OpenapiViewer/
57
+ ├── index.tsx # Main export: <Playground config={…} />
58
+ ├── lazy.tsx # Lazy-loaded variant: <LazyOpenapiViewer />
59
+ ├── types.ts # All TypeScript types
60
+ ├── constants.ts # HTTP method/status colour maps
61
+ ├── README.md # ← you are here
62
+
63
+ ├── context/
64
+ │ └── PlaygroundContext.tsx # Global state (endpoint, request, response, auth)
65
+
66
+ ├── hooks/
67
+ │ ├── useOpenApiSchema.ts # Fetches & parses OpenAPI schema; caches per URL
68
+ │ └── useMobile.ts # Thin wrapper around useIsMobile from ui-core
69
+
70
+ ├── utils/
71
+ │ ├── formatters.ts # getMethodColor, getStatusColor, isValidJson, …
72
+ │ ├── versionManager.ts # Endpoint version detection & deduplication
73
+ │ ├── apiKeyManager.ts # X-API-Key header helpers
74
+ │ └── index.ts
75
+
76
+ └── components/
77
+ ├── index.ts # Re-exports PlaygroundLayout
78
+ └── PlaygroundLayout/ # Three-panel layout (all panels live here)
79
+ ├── index.tsx # Root: DesktopView / MobileView router
80
+ ├── EndpointList.tsx # Left panel: search, filter, schema switcher, list
81
+ ├── RequestPanel.tsx # Middle panel: params, body, auth, cURL, send
82
+ ├── ResponsePanel.tsx # Right panel: status, JsonTree / raw fallback
83
+ └── ui.tsx # Shared atoms: MethodBadge, StatusBadge, Panel, …
84
+ ```
85
+
86
+ ## Key Design Decisions
87
+
88
+ | Topic | Decision |
89
+ |---|---|
90
+ | **No stepper** | All three panels visible simultaneously on desktop; tabs on mobile |
91
+ | **Collapsed sections** | Auth & Headers and cURL start collapsed to reduce visual noise |
92
+ | **JSON normalisation** | `ResponsePanel` always tries `JSON.parse` before rendering; falls back to `<pre>` for non-JSON bodies |
93
+ | **Hover-only toolbar** | `PrettyCode` language badge + copy button appear only on hover |
94
+ | **Data before JSX** | All derived values (`isFiltered`, `hasCurl`, `epPath`, …) computed before `return` — no logic in JSX |
95
+ | **Static configs** | `JSON_TREE_CONFIG`, `MOBILE_TABS`, style maps are module-level constants |
96
+ | **Lazy loading** | `PlaygroundLayout` is lazy-loaded via `Suspense` to keep the initial bundle small |
97
+
98
+ ## Config Reference
99
+
100
+ ```ts
101
+ interface PlaygroundConfig {
102
+ /** Array of OpenAPI 3.x schema URLs */
103
+ schemas: SchemaSource[];
104
+ /** Schema to select on first render */
105
+ defaultSchemaId?: string;
106
+ }
107
+
108
+ interface SchemaSource {
109
+ id: string; // unique key
110
+ name: string; // display name in the switcher combobox
111
+ url: string; // full URL to fetch the JSON schema from
112
+ }
113
+ ```
114
+
115
+ ## Auth
116
+
117
+ Bearer token priority (highest wins):
118
+
119
+ 1. Manual token entered in the **Auth & Headers** section
120
+ 2. JWT from `localStorage` key `auth_token`
121
+ 3. X-API-Key header (when an API key is selected — currently disabled pending CFG context)
@@ -0,0 +1,228 @@
1
+ 'use client';
2
+
3
+ import { ChevronRight, Filter, Search } from 'lucide-react';
4
+ import React, { useEffect, useMemo, useState } from 'react';
5
+
6
+ import {
7
+ Combobox,
8
+ DownloadButton,
9
+ DropdownMenu,
10
+ DropdownMenuContent,
11
+ DropdownMenuItem,
12
+ DropdownMenuTrigger,
13
+ Input,
14
+ Skeleton,
15
+ } from '@djangocfg/ui-core/components';
16
+ import { cn } from '@djangocfg/ui-core/lib';
17
+
18
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
19
+ import useOpenApiSchema from '../../hooks/useOpenApiSchema';
20
+ import { deduplicateEndpoints } from '../../utils/versionManager';
21
+ import { MethodBadge, ScrollArea, relativePath } from './ui';
22
+
23
+ // ─── Endpoint row ─────────────────────────────────────────────────────────────
24
+
25
+ function EndpointRow({
26
+ method,
27
+ path,
28
+ description,
29
+ isActive,
30
+ onClick,
31
+ }: {
32
+ method: string;
33
+ path: string;
34
+ description: string;
35
+ isActive: boolean;
36
+ onClick: () => void;
37
+ }) {
38
+ const displayPath = relativePath(path);
39
+ const rowCls = cn(
40
+ 'group w-full text-left flex items-start gap-2.5 px-3 py-2.5 transition-colors hover:bg-muted/40',
41
+ isActive && 'bg-primary/[0.06] hover:bg-primary/[0.09]',
42
+ );
43
+ const arrowCls = cn(
44
+ 'h-3.5 w-3.5 shrink-0 mt-px transition-opacity',
45
+ isActive ? 'text-primary opacity-100' : 'opacity-0 group-hover:opacity-30',
46
+ );
47
+
48
+ return (
49
+ <button className={rowCls} onClick={onClick}>
50
+ <MethodBadge method={method} />
51
+ <div className="flex-1 min-w-0">
52
+ <p className="font-mono text-[11px] text-foreground/75 truncate leading-tight">
53
+ {displayPath}
54
+ </p>
55
+ {description && (
56
+ <p className="text-[10px] text-muted-foreground/60 truncate leading-tight mt-0.5">
57
+ {description}
58
+ </p>
59
+ )}
60
+ </div>
61
+ <ChevronRight className={arrowCls} />
62
+ </button>
63
+ );
64
+ }
65
+
66
+ // ─── EndpointList ─────────────────────────────────────────────────────────────
67
+
68
+ export function EndpointList() {
69
+ const { state, config, setSelectedEndpoint, setSelectedCategory, setSearchTerm } =
70
+ usePlaygroundContext();
71
+ const { endpoints, categories, loading, error, schemas, currentSchema, setCurrentSchema } =
72
+ useOpenApiSchema({ schemas: config.schemas, defaultSchemaId: config.defaultSchemaId });
73
+
74
+ // ── Debounced search ──────────────────────────────────────────────────────
75
+ const [debouncedSearch, setDebouncedSearch] = useState(state.searchTerm);
76
+ useEffect(() => {
77
+ const id = setTimeout(() => setDebouncedSearch(state.searchTerm), 150);
78
+ return () => clearTimeout(id);
79
+ }, [state.searchTerm]);
80
+
81
+ // ── Data ──────────────────────────────────────────────────────────────────
82
+ const schemaOptions = useMemo(
83
+ () => schemas.map((s) => ({ value: s.id, label: s.name })),
84
+ [schemas],
85
+ );
86
+
87
+ const filtered = useMemo(() => {
88
+ let list = deduplicateEndpoints(endpoints, state.selectedVersion);
89
+ if (state.selectedCategory !== 'All') {
90
+ list = list.filter((e) => e.category === state.selectedCategory);
91
+ }
92
+ if (debouncedSearch) {
93
+ const q = debouncedSearch.toLowerCase();
94
+ list = list.filter((e) =>
95
+ e.name.toLowerCase().includes(q) ||
96
+ e.description.toLowerCase().includes(q) ||
97
+ e.path.toLowerCase().includes(q),
98
+ );
99
+ }
100
+ return list;
101
+ }, [endpoints, state.selectedCategory, debouncedSearch, state.selectedVersion]);
102
+
103
+ // ── Derived ───────────────────────────────────────────────────────────────
104
+ const isFiltered = state.selectedCategory !== 'All';
105
+ const hasCategories = categories.length > 0;
106
+ const hasMultipleSchemas = schemas.length > 1;
107
+ const endpointLabel = `${filtered.length} endpoint${filtered.length !== 1 ? 's' : ''}`;
108
+ const downloadFilename = currentSchema ? `${currentSchema.id}-openapi.json` : 'openapi.json';
109
+
110
+ // ── Early returns ─────────────────────────────────────────────────────────
111
+ if (loading) {
112
+ return (
113
+ <div className="p-3 space-y-1.5">
114
+ {Array.from({ length: 12 }).map((_, i) => (
115
+ <Skeleton key={i} className="h-10 w-full rounded" />
116
+ ))}
117
+ </div>
118
+ );
119
+ }
120
+
121
+ if (error) {
122
+ return (
123
+ <div className="p-4">
124
+ <p className="text-xs text-destructive">Failed to load schema: {error}</p>
125
+ </div>
126
+ );
127
+ }
128
+
129
+ // ── Render ────────────────────────────────────────────────────────────────
130
+ return (
131
+ <>
132
+ {/* Toolbar */}
133
+ <div className="shrink-0 border-b px-2.5 py-2 space-y-2">
134
+ {hasMultipleSchemas && (
135
+ <Combobox
136
+ options={schemaOptions}
137
+ value={currentSchema?.id ?? ''}
138
+ onValueChange={(id) => id && setCurrentSchema(id)}
139
+ placeholder="Select API"
140
+ searchPlaceholder="Search APIs…"
141
+ emptyText="No APIs found"
142
+ className="w-full h-8 text-xs"
143
+ />
144
+ )}
145
+
146
+ <div className="flex gap-1.5">
147
+ <div className="relative flex-1 min-w-0">
148
+ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground/50 pointer-events-none" />
149
+ <Input
150
+ placeholder="Search endpoints…"
151
+ value={state.searchTerm}
152
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchTerm(e.target.value)}
153
+ className="pl-8 h-8 text-xs"
154
+ />
155
+ </div>
156
+
157
+ {hasCategories && (
158
+ <DropdownMenu>
159
+ <DropdownMenuTrigger asChild>
160
+ <button className={cn(
161
+ 'relative shrink-0 flex items-center justify-center h-8 w-8 rounded-md border transition-colors',
162
+ isFiltered
163
+ ? 'border-primary bg-primary/10 text-primary'
164
+ : 'border-input bg-background text-muted-foreground hover:text-foreground hover:bg-muted/50',
165
+ )}>
166
+ <Filter className="h-3.5 w-3.5" />
167
+ {isFiltered && (
168
+ <span className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-primary" />
169
+ )}
170
+ </button>
171
+ </DropdownMenuTrigger>
172
+ <DropdownMenuContent align="end" className="min-w-[160px] max-h-72 overflow-y-auto">
173
+ {['All', ...categories].map((c) => (
174
+ <DropdownMenuItem
175
+ key={c}
176
+ onClick={() => setSelectedCategory(c)}
177
+ className={cn('text-xs', state.selectedCategory === c && 'bg-accent font-medium')}
178
+ >
179
+ {c}
180
+ </DropdownMenuItem>
181
+ ))}
182
+ </DropdownMenuContent>
183
+ </DropdownMenu>
184
+ )}
185
+ </div>
186
+ </div>
187
+
188
+ {/* Meta row */}
189
+ <div className="shrink-0 flex items-center justify-between px-3 py-1 border-b bg-muted/20">
190
+ <span className="text-[10px] text-muted-foreground/50 tabular-nums">{endpointLabel}</span>
191
+ {currentSchema && (
192
+ <DownloadButton
193
+ url={currentSchema.url}
194
+ filename={downloadFilename}
195
+ variant="ghost"
196
+ size="sm"
197
+ className="h-6 px-2 text-[10px] text-muted-foreground/50 hover:text-foreground"
198
+ >
199
+ JSON
200
+ </DownloadButton>
201
+ )}
202
+ </div>
203
+
204
+ {/* List */}
205
+ <ScrollArea>
206
+ {filtered.length === 0 ? (
207
+ <div className="py-10 text-center text-xs text-muted-foreground">No endpoints found</div>
208
+ ) : (
209
+ <div className="divide-y divide-border/40">
210
+ {filtered.map((ep) => (
211
+ <EndpointRow
212
+ key={`${ep.method}-${ep.path}`}
213
+ method={ep.method}
214
+ path={ep.path}
215
+ description={ep.description}
216
+ isActive={
217
+ state.selectedEndpoint?.path === ep.path &&
218
+ state.selectedEndpoint?.method === ep.method
219
+ }
220
+ onClick={() => setSelectedEndpoint(ep)}
221
+ />
222
+ ))}
223
+ </div>
224
+ )}
225
+ </ScrollArea>
226
+ </>
227
+ );
228
+ }
@@ -0,0 +1,258 @@
1
+ 'use client';
2
+
3
+ import { Key, Loader2, Send, Sparkles, Terminal } from 'lucide-react';
4
+ import React, { useMemo } from 'react';
5
+
6
+ import {
7
+ Button,
8
+ CopyButton,
9
+ Input,
10
+ Textarea,
11
+ } from '@djangocfg/ui-core/components';
12
+ import { cn } from '@djangocfg/ui-core/lib';
13
+
14
+ import PrettyCode from '../../../PrettyCode';
15
+ import { usePlaygroundContext } from '../../context/PlaygroundContext';
16
+ import { findApiKeyById, isValidJson, parseRequestHeaders } from '../../utils';
17
+ import {
18
+ CollapsibleSection,
19
+ EmptyState,
20
+ MethodBadge,
21
+ ScrollArea,
22
+ SectionLabel,
23
+ StatusBadge,
24
+ relativePath,
25
+ } from './ui';
26
+
27
+ // ─── Param fields ─────────────────────────────────────────────────────────────
28
+
29
+ type Param = { name: string; type: string; required: boolean; description?: string };
30
+
31
+ function ParamFields({ label, params }: { label: string; params: Param[] }) {
32
+ const { state, setParameters } = usePlaygroundContext();
33
+
34
+ function handleChange(name: string, value: string) {
35
+ setParameters({ ...state.parameters, [name]: value });
36
+ }
37
+
38
+ return (
39
+ <div className="space-y-2">
40
+ <SectionLabel>{label}</SectionLabel>
41
+ <div className="space-y-2">
42
+ {params.map((p) => {
43
+ const value = state.parameters[p.name] ?? '';
44
+ const placeholder = p.description || p.name;
45
+ return (
46
+ <div key={p.name} className="space-y-1">
47
+ <div className="flex items-center gap-1.5">
48
+ <span className="font-mono text-[11px] text-foreground/80">{p.name}</span>
49
+ {p.required && (
50
+ <span className="text-[9px] text-destructive font-bold leading-none">*</span>
51
+ )}
52
+ <span className="font-mono text-[10px] text-muted-foreground/50">{p.type}</span>
53
+ </div>
54
+ <Input
55
+ value={value}
56
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
57
+ handleChange(p.name, e.target.value)
58
+ }
59
+ placeholder={placeholder}
60
+ className="h-8 text-xs font-mono"
61
+ />
62
+ </div>
63
+ );
64
+ })}
65
+ </div>
66
+ </div>
67
+ );
68
+ }
69
+
70
+ // ─── RequestPanel ─────────────────────────────────────────────────────────────
71
+
72
+ export function RequestPanel() {
73
+ const { state, apiKeys, setRequestBody, setRequestHeaders, setManualApiToken, sendRequest } =
74
+ usePlaygroundContext();
75
+
76
+ const ep = state.selectedEndpoint;
77
+
78
+ // ── Data (hooks must not be conditional) ─────────────────────────────────
79
+ const isJsonValid = state.requestBody ? isValidJson(state.requestBody) : true;
80
+
81
+ const curlCommand = useMemo(() => {
82
+ if (!state.requestUrl) return '';
83
+ const apiKey = state.selectedApiKey ? findApiKeyById(apiKeys, state.selectedApiKey) : null;
84
+ const hdrs = parseRequestHeaders(state.requestHeaders);
85
+ if (apiKey) hdrs['X-API-Key'] = apiKey.id;
86
+ let cmd = `curl -X ${state.requestMethod} "${state.requestUrl}"`;
87
+ Object.entries(hdrs).forEach(([k, v]) => { cmd += ` \\\n -H "${k}: ${v}"`; });
88
+ if (state.requestBody && state.requestMethod !== 'GET' && isJsonValid) {
89
+ cmd += ` \\\n -d '${state.requestBody}'`;
90
+ }
91
+ return cmd;
92
+ }, [state, apiKeys, isJsonValid]);
93
+
94
+ const pathParams = useMemo(
95
+ () => ep?.parameters?.filter((p) => ep.path.includes(`{${p.name}}`)) ?? [],
96
+ [ep],
97
+ );
98
+ const queryParams = useMemo(
99
+ () => ep?.parameters?.filter((p) => !ep.path.includes(`{${p.name}}`)) ?? [],
100
+ [ep],
101
+ );
102
+
103
+ // ── Derived ───────────────────────────────────────────────────────────────
104
+ const isSendDisabled = state.loading || !state.requestUrl || !isJsonValid;
105
+ const displayUrl = state.requestUrl || ep?.path || '';
106
+ const hasBody = ep?.method !== 'GET';
107
+ const bodyType = ep?.requestBody?.type ?? '';
108
+ const hasPathParams = pathParams.length > 0;
109
+ const hasQueryParams = queryParams.length > 0;
110
+ const hasCurl = Boolean(curlCommand);
111
+ const epPath = ep ? relativePath(ep.path) : '';
112
+ const urlChanged = displayUrl !== epPath;
113
+
114
+ // ── Early return ──────────────────────────────────────────────────────────
115
+ if (!ep) {
116
+ return <EmptyState icon={Send} text="Select an endpoint to build a request" />;
117
+ }
118
+
119
+ // ── Render ────────────────────────────────────────────────────────────────
120
+ return (
121
+ <>
122
+ {/* Endpoint header */}
123
+ <div className="shrink-0 border-b px-4 py-3 bg-muted/20 space-y-1.5">
124
+ <div className="flex items-center gap-2">
125
+ <MethodBadge method={ep.method} />
126
+ <span className="font-mono text-xs text-foreground/70 truncate min-w-0 flex-1">{epPath}</span>
127
+ <Button
128
+ onClick={sendRequest}
129
+ disabled={isSendDisabled}
130
+ size="sm"
131
+ className="shrink-0 gap-1.5 h-7 text-xs px-3"
132
+ >
133
+ {state.loading
134
+ ? <><Loader2 className="h-3 w-3 animate-spin" /> Sending…</>
135
+ : <><Send className="h-3 w-3" /> Send</>
136
+ }
137
+ </Button>
138
+ </div>
139
+ {urlChanged && (
140
+ <div className="font-mono text-[10px] text-muted-foreground/50 break-all leading-snug pl-0.5">
141
+ {displayUrl}
142
+ </div>
143
+ )}
144
+ </div>
145
+
146
+ {/* Scrollable fields */}
147
+ <ScrollArea className="px-4 py-3 space-y-3">
148
+
149
+ {hasPathParams && <ParamFields label="Path Parameters" params={pathParams} />}
150
+ {hasQueryParams && <ParamFields label="Query Parameters" params={queryParams} />}
151
+
152
+ {/* Body */}
153
+ {hasBody && (
154
+ <div className="space-y-1.5">
155
+ <div className="flex items-center justify-between gap-2">
156
+ <div className="flex items-baseline gap-2">
157
+ <SectionLabel>Body</SectionLabel>
158
+ {bodyType && (
159
+ <span className="text-[10px] text-muted-foreground/40 font-mono">{bodyType}</span>
160
+ )}
161
+ </div>
162
+ {isJsonValid && state.requestBody && (
163
+ <button
164
+ type="button"
165
+ onClick={() => {
166
+ try {
167
+ setRequestBody(JSON.stringify(JSON.parse(state.requestBody), null, 2));
168
+ } catch {}
169
+ }}
170
+ className="inline-flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
171
+ >
172
+ <Sparkles className="h-2.5 w-2.5" />
173
+ Format
174
+ </button>
175
+ )}
176
+ </div>
177
+ <Textarea
178
+ placeholder={'{\n "key": "value"\n}'}
179
+ value={state.requestBody}
180
+ onChange={(e) => setRequestBody(e.target.value)}
181
+ className={cn(
182
+ 'font-mono text-[11px] min-h-[90px] resize-y',
183
+ !isJsonValid && 'border-destructive focus-visible:ring-destructive/30',
184
+ )}
185
+ rows={4}
186
+ />
187
+ {!isJsonValid && <p className="text-[10px] text-destructive">Invalid JSON</p>}
188
+ </div>
189
+ )}
190
+
191
+ {/* Auth & Headers — collapsed by default */}
192
+ <CollapsibleSection
193
+ label={
194
+ <span className="inline-flex items-center gap-1">
195
+ <Key className="h-2.5 w-2.5" />
196
+ Auth &amp; Headers
197
+ </span>
198
+ }
199
+ >
200
+ <div className="space-y-3 pt-2">
201
+ <div className="space-y-1.5">
202
+ <SectionLabel>Bearer Token</SectionLabel>
203
+ <Input
204
+ type="password"
205
+ placeholder="Leave empty to use JWT from localStorage"
206
+ value={state.manualApiToken}
207
+ onChange={(e) => setManualApiToken(e.target.value)}
208
+ className="font-mono text-xs h-8"
209
+ />
210
+ </div>
211
+ <div className="space-y-1.5">
212
+ <SectionLabel>Headers</SectionLabel>
213
+ <Textarea
214
+ value={state.requestHeaders}
215
+ onChange={(e) => setRequestHeaders(e.target.value)}
216
+ className="font-mono text-[11px] min-h-[60px] resize-y"
217
+ rows={3}
218
+ />
219
+ </div>
220
+ </div>
221
+ </CollapsibleSection>
222
+
223
+ {/* cURL — collapsed by default */}
224
+ {hasCurl && (
225
+ <CollapsibleSection
226
+ label={
227
+ <span className="inline-flex items-center gap-1">
228
+ <Terminal className="h-2.5 w-2.5" />
229
+ cURL
230
+ </span>
231
+ }
232
+ action={
233
+ <CopyButton value={curlCommand} variant="ghost" size="sm" className="h-5 px-2 text-[10px] text-muted-foreground">
234
+ Copy
235
+ </CopyButton>
236
+ }
237
+ >
238
+ <div className="rounded-md overflow-hidden mt-2">
239
+ <PrettyCode data={curlCommand} language="bash" isCompact />
240
+ </div>
241
+ </CollapsibleSection>
242
+ )}
243
+
244
+ <div className="h-1" />
245
+ </ScrollArea>
246
+
247
+ {/* Send footer */}
248
+ <div className="shrink-0 border-t px-4 py-3 bg-background/95 backdrop-blur-sm">
249
+ <Button onClick={sendRequest} disabled={isSendDisabled} size="sm" className="w-full gap-2 h-9">
250
+ {state.loading
251
+ ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Sending…</>
252
+ : <><Send className="h-3.5 w-3.5" /> Send Request</>
253
+ }
254
+ </Button>
255
+ </div>
256
+ </>
257
+ );
258
+ }