@djangocfg/ui-tools 2.1.268 → 2.1.270
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.
- package/dist/PlaygroundLayout-FRKIMYVN.mjs +684 -0
- package/dist/PlaygroundLayout-FRKIMYVN.mjs.map +1 -0
- package/dist/PlaygroundLayout-LIAN63CZ.cjs +691 -0
- package/dist/PlaygroundLayout-LIAN63CZ.cjs.map +1 -0
- package/dist/{PrettyCode.client-OO3KAJSM.mjs → PrettyCode.client-DW5LTG47.mjs} +5 -5
- package/dist/PrettyCode.client-DW5LTG47.mjs.map +1 -0
- package/dist/{PrettyCode.client-V2ZN5DTH.cjs → PrettyCode.client-SGDGQTYT.cjs} +5 -5
- package/dist/PrettyCode.client-SGDGQTYT.cjs.map +1 -0
- package/dist/{chunk-SZ2CZEQZ.mjs → chunk-FX3GCEUL.mjs} +5 -26
- package/dist/chunk-FX3GCEUL.mjs.map +1 -0
- package/dist/{chunk-CRHHUOVJ.cjs → chunk-VAL2LCQD.cjs} +4 -27
- package/dist/chunk-VAL2LCQD.cjs.map +1 -0
- package/dist/index.cjs +8 -8
- package/dist/index.mjs +5 -5
- package/package.json +6 -6
- package/src/tools/OpenapiViewer/README.md +121 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/EndpointList.tsx +221 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/RequestPanel.tsx +231 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/ResponsePanel.tsx +112 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/index.tsx +107 -0
- package/src/tools/OpenapiViewer/components/PlaygroundLayout/ui.tsx +137 -0
- package/src/tools/OpenapiViewer/components/index.ts +0 -9
- package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +1 -1
- package/src/tools/PrettyCode/PrettyCode.client.tsx +17 -12
- package/dist/PlaygroundLayout-FKXSULJ3.cjs +0 -971
- package/dist/PlaygroundLayout-FKXSULJ3.cjs.map +0 -1
- package/dist/PlaygroundLayout-XMMHPZYP.mjs +0 -964
- package/dist/PlaygroundLayout-XMMHPZYP.mjs.map +0 -1
- package/dist/PrettyCode.client-OO3KAJSM.mjs.map +0 -1
- package/dist/PrettyCode.client-V2ZN5DTH.cjs.map +0 -1
- package/dist/chunk-CRHHUOVJ.cjs.map +0 -1
- package/dist/chunk-SZ2CZEQZ.mjs.map +0 -1
- package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +0 -149
- package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +0 -278
- package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +0 -91
- package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +0 -100
- package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +0 -157
- package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +0 -253
- package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +0 -173
- package/src/tools/OpenapiViewer/components/VersionSelector.tsx +0 -68
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Key, Loader2, Send, 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">{epPath}</span>
|
|
127
|
+
</div>
|
|
128
|
+
{urlChanged && (
|
|
129
|
+
<div className="font-mono text-[10px] text-muted-foreground/50 break-all leading-snug pl-0.5">
|
|
130
|
+
{displayUrl}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Scrollable fields */}
|
|
136
|
+
<ScrollArea className="px-4 py-3 space-y-3">
|
|
137
|
+
|
|
138
|
+
{hasPathParams && <ParamFields label="Path Parameters" params={pathParams} />}
|
|
139
|
+
{hasQueryParams && <ParamFields label="Query Parameters" params={queryParams} />}
|
|
140
|
+
|
|
141
|
+
{/* Body */}
|
|
142
|
+
{hasBody && (
|
|
143
|
+
<div className="space-y-1.5">
|
|
144
|
+
<div className="flex items-baseline gap-2">
|
|
145
|
+
<SectionLabel>Body</SectionLabel>
|
|
146
|
+
{bodyType && (
|
|
147
|
+
<span className="text-[10px] text-muted-foreground/40 font-mono">{bodyType}</span>
|
|
148
|
+
)}
|
|
149
|
+
</div>
|
|
150
|
+
<Textarea
|
|
151
|
+
placeholder={'{\n "key": "value"\n}'}
|
|
152
|
+
value={state.requestBody}
|
|
153
|
+
onChange={(e) => setRequestBody(e.target.value)}
|
|
154
|
+
className={cn(
|
|
155
|
+
'font-mono text-[11px] min-h-[90px] resize-y',
|
|
156
|
+
!isJsonValid && 'border-destructive focus-visible:ring-destructive/30',
|
|
157
|
+
)}
|
|
158
|
+
rows={4}
|
|
159
|
+
/>
|
|
160
|
+
{!isJsonValid && <p className="text-[10px] text-destructive">Invalid JSON</p>}
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{/* Auth & Headers — collapsed by default */}
|
|
165
|
+
<CollapsibleSection
|
|
166
|
+
label={
|
|
167
|
+
<span className="inline-flex items-center gap-1">
|
|
168
|
+
<Key className="h-2.5 w-2.5" />
|
|
169
|
+
Auth & Headers
|
|
170
|
+
</span>
|
|
171
|
+
}
|
|
172
|
+
>
|
|
173
|
+
<div className="space-y-3 pt-2">
|
|
174
|
+
<div className="space-y-1.5">
|
|
175
|
+
<SectionLabel>Bearer Token</SectionLabel>
|
|
176
|
+
<Input
|
|
177
|
+
type="password"
|
|
178
|
+
placeholder="Leave empty to use JWT from localStorage"
|
|
179
|
+
value={state.manualApiToken}
|
|
180
|
+
onChange={(e) => setManualApiToken(e.target.value)}
|
|
181
|
+
className="font-mono text-xs h-8"
|
|
182
|
+
/>
|
|
183
|
+
</div>
|
|
184
|
+
<div className="space-y-1.5">
|
|
185
|
+
<SectionLabel>Headers</SectionLabel>
|
|
186
|
+
<Textarea
|
|
187
|
+
value={state.requestHeaders}
|
|
188
|
+
onChange={(e) => setRequestHeaders(e.target.value)}
|
|
189
|
+
className="font-mono text-[11px] min-h-[60px] resize-y"
|
|
190
|
+
rows={3}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</CollapsibleSection>
|
|
195
|
+
|
|
196
|
+
{/* cURL — collapsed by default */}
|
|
197
|
+
{hasCurl && (
|
|
198
|
+
<CollapsibleSection
|
|
199
|
+
label={
|
|
200
|
+
<span className="inline-flex items-center gap-1">
|
|
201
|
+
<Terminal className="h-2.5 w-2.5" />
|
|
202
|
+
cURL
|
|
203
|
+
</span>
|
|
204
|
+
}
|
|
205
|
+
action={
|
|
206
|
+
<CopyButton value={curlCommand} variant="ghost" size="sm" className="h-5 px-2 text-[10px] text-muted-foreground">
|
|
207
|
+
Copy
|
|
208
|
+
</CopyButton>
|
|
209
|
+
}
|
|
210
|
+
>
|
|
211
|
+
<div className="rounded-md overflow-hidden mt-2">
|
|
212
|
+
<PrettyCode data={curlCommand} language="bash" isCompact />
|
|
213
|
+
</div>
|
|
214
|
+
</CollapsibleSection>
|
|
215
|
+
)}
|
|
216
|
+
|
|
217
|
+
<div className="h-1" />
|
|
218
|
+
</ScrollArea>
|
|
219
|
+
|
|
220
|
+
{/* Send footer */}
|
|
221
|
+
<div className="shrink-0 border-t px-4 py-3 bg-background/95 backdrop-blur-sm">
|
|
222
|
+
<Button onClick={sendRequest} disabled={isSendDisabled} size="sm" className="w-full gap-2 h-9">
|
|
223
|
+
{state.loading
|
|
224
|
+
? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Sending…</>
|
|
225
|
+
: <><Send className="h-3.5 w-3.5" /> Send Request</>
|
|
226
|
+
}
|
|
227
|
+
</Button>
|
|
228
|
+
</div>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Loader2, Send, Terminal } from 'lucide-react';
|
|
4
|
+
import { useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
import { CopyButton } from '@djangocfg/ui-core/components';
|
|
7
|
+
|
|
8
|
+
import JsonTree from '../../../JsonTree';
|
|
9
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
10
|
+
import { EmptyState, ScrollArea, StatusBadge } from './ui';
|
|
11
|
+
|
|
12
|
+
// ─── JsonTree config (static, no re-creation on render) ──────────────────────
|
|
13
|
+
|
|
14
|
+
const JSON_TREE_CONFIG = {
|
|
15
|
+
maxAutoExpandDepth: 2,
|
|
16
|
+
maxAutoExpandArrayItems: 10,
|
|
17
|
+
maxAutoExpandObjectKeys: 5,
|
|
18
|
+
maxStringLength: 200,
|
|
19
|
+
collectionLimit: 50,
|
|
20
|
+
showCollectionInfo: true,
|
|
21
|
+
showExpandControls: true,
|
|
22
|
+
showActionButtons: false,
|
|
23
|
+
preserveKeyOrder: true,
|
|
24
|
+
className: 'border-0 rounded-none',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
// ─── ResponsePanel ────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
export function ResponsePanel() {
|
|
30
|
+
const { state } = usePlaygroundContext();
|
|
31
|
+
const { response, loading, selectedEndpoint } = state;
|
|
32
|
+
|
|
33
|
+
// ── Normalise response data ───────────────────────────────────────────────
|
|
34
|
+
// Always try to parse as JSON first so JsonTree gets an object, not a string.
|
|
35
|
+
// Falls back to raw text for non-JSON responses (HTML errors, plain text, etc.)
|
|
36
|
+
const { treeData, rawText } = useMemo(() => {
|
|
37
|
+
const d = response?.data;
|
|
38
|
+
if (d == null) return { treeData: null, rawText: '' };
|
|
39
|
+
|
|
40
|
+
if (typeof d === 'string') {
|
|
41
|
+
try {
|
|
42
|
+
return { treeData: JSON.parse(d), rawText: d };
|
|
43
|
+
} catch {
|
|
44
|
+
return { treeData: null, rawText: d };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { treeData: d, rawText: JSON.stringify(d, null, 2) };
|
|
49
|
+
}, [response?.data]);
|
|
50
|
+
|
|
51
|
+
// ── Derived ───────────────────────────────────────────────────────────────
|
|
52
|
+
const sizeKb = rawText ? `${(rawText.length / 1024).toFixed(1)} KB` : '';
|
|
53
|
+
const hasError = Boolean(response?.error);
|
|
54
|
+
const hasStatus = response?.status != null;
|
|
55
|
+
const hasCopy = Boolean(rawText);
|
|
56
|
+
|
|
57
|
+
// ── Early returns ─────────────────────────────────────────────────────────
|
|
58
|
+
if (loading) {
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex items-center justify-center h-full gap-2">
|
|
61
|
+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
62
|
+
<span className="text-xs text-muted-foreground">Sending…</span>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!selectedEndpoint) return <EmptyState icon={Terminal} text="Response will appear here" />;
|
|
68
|
+
if (!response) return <EmptyState icon={Send} text='Press "Send Request" to see the response' />;
|
|
69
|
+
|
|
70
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
71
|
+
return (
|
|
72
|
+
<>
|
|
73
|
+
{/* Status bar */}
|
|
74
|
+
<div className="shrink-0 border-b px-4 py-2 flex items-center justify-between gap-3 bg-muted/20">
|
|
75
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
76
|
+
{hasStatus && <StatusBadge status={response.status!} />}
|
|
77
|
+
{response.statusText && (
|
|
78
|
+
<span className="text-xs text-muted-foreground truncate">{response.statusText}</span>
|
|
79
|
+
)}
|
|
80
|
+
{sizeKb && (
|
|
81
|
+
<span className="text-[10px] text-muted-foreground/50 tabular-nums shrink-0">{sizeKb}</span>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
{hasCopy && (
|
|
85
|
+
<CopyButton value={rawText} variant="ghost" size="sm" className="h-6 px-2 text-[10px] text-muted-foreground shrink-0">
|
|
86
|
+
Copy
|
|
87
|
+
</CopyButton>
|
|
88
|
+
)}
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Network/request error */}
|
|
92
|
+
{hasError && (
|
|
93
|
+
<div className="shrink-0 mx-4 mt-3 rounded border border-destructive/20 bg-destructive/5 px-3 py-2">
|
|
94
|
+
<p className="text-xs text-destructive">{response.error}</p>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{/* Body */}
|
|
99
|
+
<ScrollArea>
|
|
100
|
+
{treeData != null ? (
|
|
101
|
+
<JsonTree title="Response Body" data={treeData} config={JSON_TREE_CONFIG} />
|
|
102
|
+
) : rawText ? (
|
|
103
|
+
<pre className="p-4 text-[11px] font-mono text-foreground/70 whitespace-pre-wrap break-all leading-relaxed">
|
|
104
|
+
{rawText}
|
|
105
|
+
</pre>
|
|
106
|
+
) : (
|
|
107
|
+
<div className="py-10 text-center text-xs text-muted-foreground">Empty response body</div>
|
|
108
|
+
)}
|
|
109
|
+
</ScrollArea>
|
|
110
|
+
</>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import { useMobile } from '../../hooks/useMobile';
|
|
8
|
+
import { usePlaygroundContext } from '../../context/PlaygroundContext';
|
|
9
|
+
import { EndpointList } from './EndpointList';
|
|
10
|
+
import { RequestPanel } from './RequestPanel';
|
|
11
|
+
import { ResponsePanel } from './ResponsePanel';
|
|
12
|
+
import { Panel, PanelHeader } from './ui';
|
|
13
|
+
|
|
14
|
+
// ─── Mobile tab layout ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
type MobileTab = 'endpoints' | 'request' | 'response';
|
|
17
|
+
|
|
18
|
+
const MOBILE_TABS: { id: MobileTab; label: string }[] = [
|
|
19
|
+
{ id: 'endpoints', label: 'Endpoints' },
|
|
20
|
+
{ id: 'request', label: 'Request' },
|
|
21
|
+
{ id: 'response', label: 'Response' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function MobileView() {
|
|
25
|
+
const { state } = usePlaygroundContext();
|
|
26
|
+
const [tab, setTab] = React.useState<MobileTab>('endpoints');
|
|
27
|
+
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (state.selectedEndpoint) setTab('request');
|
|
30
|
+
}, [state.selectedEndpoint?.path, state.selectedEndpoint?.method]);
|
|
31
|
+
|
|
32
|
+
React.useEffect(() => {
|
|
33
|
+
if (state.response && !state.loading) setTab('response');
|
|
34
|
+
}, [state.response, state.loading]);
|
|
35
|
+
|
|
36
|
+
const hasResponse = Boolean(state.response) && !state.loading;
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Panel className="h-full">
|
|
40
|
+
<div className="shrink-0 flex border-b">
|
|
41
|
+
{MOBILE_TABS.map((t) => {
|
|
42
|
+
const isActive = tab === t.id;
|
|
43
|
+
const showDot = t.id === 'response' && hasResponse;
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
key={t.id}
|
|
47
|
+
onClick={() => setTab(t.id)}
|
|
48
|
+
className={cn(
|
|
49
|
+
'flex-1 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px relative',
|
|
50
|
+
isActive
|
|
51
|
+
? 'border-primary text-foreground'
|
|
52
|
+
: 'border-transparent text-muted-foreground hover:text-foreground',
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{t.label}
|
|
56
|
+
{showDot && (
|
|
57
|
+
<span className="absolute top-2 right-[calc(50%-16px)] h-1.5 w-1.5 rounded-full bg-primary" />
|
|
58
|
+
)}
|
|
59
|
+
</button>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<Panel className="flex-1">
|
|
65
|
+
{tab === 'endpoints' && <EndpointList />}
|
|
66
|
+
{tab === 'request' && <RequestPanel />}
|
|
67
|
+
{tab === 'response' && <ResponsePanel />}
|
|
68
|
+
</Panel>
|
|
69
|
+
</Panel>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Desktop 3-column layout ─────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
function DesktopView() {
|
|
76
|
+
return (
|
|
77
|
+
<div className="grid grid-cols-[260px_1fr_1fr] divide-x h-full min-h-0 overflow-hidden">
|
|
78
|
+
<Panel>
|
|
79
|
+
<PanelHeader title="Endpoints" />
|
|
80
|
+
<EndpointList />
|
|
81
|
+
</Panel>
|
|
82
|
+
<Panel>
|
|
83
|
+
<PanelHeader title="Request" />
|
|
84
|
+
<RequestPanel />
|
|
85
|
+
</Panel>
|
|
86
|
+
<Panel>
|
|
87
|
+
<PanelHeader title="Response" />
|
|
88
|
+
<ResponsePanel />
|
|
89
|
+
</Panel>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Root ─────────────────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export const PlaygroundLayout: React.FC = () => {
|
|
97
|
+
const { isMobile } = useMobile();
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div
|
|
101
|
+
className="flex flex-col overflow-hidden"
|
|
102
|
+
style={{ height: 'calc(100dvh - var(--navbar-height, 64px))' }}
|
|
103
|
+
>
|
|
104
|
+
{isMobile ? <MobileView /> : <DesktopView />}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
};
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared primitive UI components used across all PlaygroundLayout panels.
|
|
5
|
+
* Keep this file free of any business logic or context reads.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ChevronRight } from 'lucide-react';
|
|
9
|
+
import React from 'react';
|
|
10
|
+
|
|
11
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
12
|
+
|
|
13
|
+
// ─── Style helpers ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const METHOD_STYLES: Record<string, string> = {
|
|
16
|
+
GET: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25',
|
|
17
|
+
POST: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25',
|
|
18
|
+
PUT: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25',
|
|
19
|
+
PATCH: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/25',
|
|
20
|
+
DELETE: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/25',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
const METHOD_FALLBACK = 'bg-muted text-muted-foreground border-border';
|
|
24
|
+
|
|
25
|
+
export function getMethodStyle(method: string): string {
|
|
26
|
+
return METHOD_STYLES[method.toUpperCase()] ?? METHOD_FALLBACK;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getStatusStyle(status: number): string {
|
|
30
|
+
if (status >= 500) return 'bg-red-500/10 text-red-500 dark:text-red-400 border-red-500/25';
|
|
31
|
+
if (status >= 400) return 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/25';
|
|
32
|
+
if (status >= 300) return 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/25';
|
|
33
|
+
return 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/25';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function relativePath(full: string): string {
|
|
37
|
+
try { return new URL(full).pathname; } catch { return full; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Atoms ────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export function MethodBadge({ method }: { method: string }) {
|
|
43
|
+
return (
|
|
44
|
+
<span className={cn(
|
|
45
|
+
'inline-flex shrink-0 items-center rounded border px-1.5 py-px',
|
|
46
|
+
'font-mono text-[10px] font-bold uppercase tracking-wider leading-none',
|
|
47
|
+
getMethodStyle(method),
|
|
48
|
+
)}>
|
|
49
|
+
{method}
|
|
50
|
+
</span>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function StatusBadge({ status }: { status: number }) {
|
|
55
|
+
return (
|
|
56
|
+
<span className={cn(
|
|
57
|
+
'inline-flex items-center rounded border px-1.5 py-px',
|
|
58
|
+
'font-mono text-[11px] font-bold leading-none',
|
|
59
|
+
getStatusStyle(status),
|
|
60
|
+
)}>
|
|
61
|
+
{status}
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function SectionLabel({ children }: { children: React.ReactNode }) {
|
|
67
|
+
return (
|
|
68
|
+
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 select-none">
|
|
69
|
+
{children}
|
|
70
|
+
</p>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function Panel({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
75
|
+
return (
|
|
76
|
+
<div className={cn('flex flex-col min-h-0 overflow-hidden', className)}>
|
|
77
|
+
{children}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function ScrollArea({ children, className }: { children: React.ReactNode; className?: string }) {
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn('flex-1 overflow-y-auto min-h-0', className)}>
|
|
85
|
+
{children}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function PanelHeader({ title }: { title: string }) {
|
|
91
|
+
return (
|
|
92
|
+
<div className="shrink-0 border-b px-4 h-10 flex items-center">
|
|
93
|
+
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground/50">
|
|
94
|
+
{title}
|
|
95
|
+
</span>
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function EmptyState({ icon: Icon, text }: { icon: React.ElementType; text: string }) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="flex flex-col items-center justify-center h-full gap-3 px-6 text-center">
|
|
103
|
+
<Icon className="h-7 w-7 text-muted-foreground/25" />
|
|
104
|
+
<p className="text-xs text-muted-foreground">{text}</p>
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function CollapsibleSection({
|
|
110
|
+
label,
|
|
111
|
+
action,
|
|
112
|
+
children,
|
|
113
|
+
defaultOpen = false,
|
|
114
|
+
}: {
|
|
115
|
+
label: React.ReactNode;
|
|
116
|
+
action?: React.ReactNode;
|
|
117
|
+
children: React.ReactNode;
|
|
118
|
+
defaultOpen?: boolean;
|
|
119
|
+
}) {
|
|
120
|
+
const [open, setOpen] = React.useState(defaultOpen);
|
|
121
|
+
return (
|
|
122
|
+
<div className="space-y-0">
|
|
123
|
+
<div className="flex items-center justify-between">
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => setOpen((v) => !v)}
|
|
127
|
+
className="flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60 hover:text-muted-foreground transition-colors py-1"
|
|
128
|
+
>
|
|
129
|
+
<ChevronRight className={cn('h-3 w-3 transition-transform', open && 'rotate-90')} />
|
|
130
|
+
{label}
|
|
131
|
+
</button>
|
|
132
|
+
{action && <div className="shrink-0">{action}</div>}
|
|
133
|
+
</div>
|
|
134
|
+
{open && <div>{children}</div>}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Playground Components
|
|
3
|
-
*
|
|
4
|
-
* Centralized exports for all playground components
|
|
5
3
|
*/
|
|
6
4
|
|
|
7
5
|
export { PlaygroundLayout } from './PlaygroundLayout';
|
|
8
|
-
export { PlaygroundStepper } from './PlaygroundStepper';
|
|
9
|
-
export { EndpointsLibrary } from './EndpointsLibrary';
|
|
10
|
-
export { EndpointInfo } from './EndpointInfo';
|
|
11
|
-
export { RequestBuilder } from './RequestBuilder';
|
|
12
|
-
export { RequestParametersForm } from './RequestParametersForm';
|
|
13
|
-
export { ResponseViewer } from './ResponseViewer';
|
|
14
|
-
export { VersionSelector } from './VersionSelector';
|
|
@@ -170,18 +170,23 @@ const PrettyCode = ({ data, language, className, mode, inline = false, customBg,
|
|
|
170
170
|
const borderClass = isDarkMode ? 'border-zinc-700' : 'border-border';
|
|
171
171
|
|
|
172
172
|
return (
|
|
173
|
-
<div ref={containerRef} className={`relative h-full ${bgClass} rounded-lg border ${borderClass} ${className || ''}`}>
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
{
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
173
|
+
<div ref={containerRef} className={`group relative h-full ${bgClass} rounded-lg border ${borderClass} ${className || ''}`}>
|
|
174
|
+
{/* Toolbar: hidden by default, appears on hover. Absolute overlay so it doesn't shift layout. */}
|
|
175
|
+
<div className="absolute inset-0 pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity duration-150 z-30">
|
|
176
|
+
<div className="pointer-events-auto">
|
|
177
|
+
<FloatingToolbar
|
|
178
|
+
containerRef={containerRef}
|
|
179
|
+
scrollIsolation={scrollIsolation}
|
|
180
|
+
label={
|
|
181
|
+
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-muted/80 text-muted-foreground border border-border/50 backdrop-blur-sm">
|
|
182
|
+
{displayLanguage}
|
|
183
|
+
</span>
|
|
184
|
+
}
|
|
185
|
+
>
|
|
186
|
+
<CopyAction value={contentJson} title={labels.copyCode} />
|
|
187
|
+
</FloatingToolbar>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
185
190
|
|
|
186
191
|
<div className="h-full overflow-auto">
|
|
187
192
|
<Highlight theme={prismTheme} code={contentJson} language={normalizedLanguage as Language}>
|