@hustle-together/api-dev-tools 3.6.4 → 3.9.2
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/README.md +5307 -258
- package/bin/cli.js +348 -20
- package/commands/README.md +459 -71
- package/commands/hustle-api-continue.md +158 -0
- package/commands/{api-create.md → hustle-api-create.md} +22 -2
- package/commands/{api-env.md → hustle-api-env.md} +4 -4
- package/commands/{api-interview.md → hustle-api-interview.md} +1 -1
- package/commands/{api-research.md → hustle-api-research.md} +3 -3
- package/commands/hustle-api-sessions.md +149 -0
- package/commands/{api-status.md → hustle-api-status.md} +16 -16
- package/commands/{api-verify.md → hustle-api-verify.md} +2 -2
- package/commands/hustle-combine.md +763 -0
- package/commands/hustle-ui-create.md +825 -0
- package/hooks/api-workflow-check.py +385 -19
- package/hooks/cache-research.py +337 -0
- package/hooks/check-playwright-setup.py +103 -0
- package/hooks/check-storybook-setup.py +81 -0
- package/hooks/detect-interruption.py +165 -0
- package/hooks/enforce-brand-guide.py +131 -0
- package/hooks/enforce-documentation.py +60 -8
- package/hooks/enforce-freshness.py +184 -0
- package/hooks/enforce-questions-sourced.py +146 -0
- package/hooks/enforce-schema-from-interview.py +248 -0
- package/hooks/enforce-ui-disambiguation.py +108 -0
- package/hooks/enforce-ui-interview.py +130 -0
- package/hooks/generate-manifest-entry.py +981 -0
- package/hooks/session-logger.py +297 -0
- package/hooks/session-startup.py +65 -10
- package/hooks/track-scope-coverage.py +220 -0
- package/hooks/track-tool-use.py +81 -1
- package/hooks/update-api-showcase.py +149 -0
- package/hooks/update-registry.py +352 -0
- package/hooks/update-ui-showcase.py +148 -0
- package/package.json +8 -2
- package/templates/BRAND_GUIDE.md +299 -0
- package/templates/CLAUDE-SECTION.md +56 -24
- package/templates/SPEC.json +640 -0
- package/templates/api-dev-state.json +179 -161
- package/templates/api-showcase/APICard.tsx +153 -0
- package/templates/api-showcase/APIModal.tsx +375 -0
- package/templates/api-showcase/APIShowcase.tsx +231 -0
- package/templates/api-showcase/APITester.tsx +522 -0
- package/templates/api-showcase/page.tsx +41 -0
- package/templates/component/Component.stories.tsx +172 -0
- package/templates/component/Component.test.tsx +237 -0
- package/templates/component/Component.tsx +86 -0
- package/templates/component/Component.types.ts +55 -0
- package/templates/component/index.ts +15 -0
- package/templates/dev-tools/_components/DevToolsLanding.tsx +320 -0
- package/templates/dev-tools/page.tsx +10 -0
- package/templates/page/page.e2e.test.ts +218 -0
- package/templates/page/page.tsx +42 -0
- package/templates/performance-budgets.json +58 -0
- package/templates/registry.json +13 -0
- package/templates/settings.json +74 -0
- package/templates/shared/HeroHeader.tsx +261 -0
- package/templates/shared/index.ts +1 -0
- package/templates/ui-showcase/PreviewCard.tsx +315 -0
- package/templates/ui-showcase/PreviewModal.tsx +676 -0
- package/templates/ui-showcase/UIShowcase.tsx +262 -0
- package/templates/ui-showcase/page.tsx +26 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
|
|
5
|
+
interface ParameterDoc {
|
|
6
|
+
name: string;
|
|
7
|
+
type: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
required?: boolean;
|
|
10
|
+
default?: string | number | boolean;
|
|
11
|
+
enum?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface SchemaDoc {
|
|
15
|
+
request?: ParameterDoc[];
|
|
16
|
+
response?: ParameterDoc[];
|
|
17
|
+
queryParams?: ParameterDoc[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface APITesterProps {
|
|
21
|
+
id: string;
|
|
22
|
+
endpoint: string;
|
|
23
|
+
methods: string[];
|
|
24
|
+
selectedEndpoint?: string | null;
|
|
25
|
+
schemaPath?: string;
|
|
26
|
+
schema?: SchemaDoc;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface RequestState {
|
|
30
|
+
method: string;
|
|
31
|
+
body: string;
|
|
32
|
+
queryParams: string;
|
|
33
|
+
headers: Record<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface ResponseState {
|
|
37
|
+
status: number | null;
|
|
38
|
+
statusText: string;
|
|
39
|
+
body: string;
|
|
40
|
+
time: number | null;
|
|
41
|
+
error: string | null;
|
|
42
|
+
contentType: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Default request bodies for known APIs
|
|
46
|
+
const DEFAULT_BODIES: Record<string, Record<string, object>> = {
|
|
47
|
+
brandfetch: {
|
|
48
|
+
default: { domain: 'stripe.com' },
|
|
49
|
+
},
|
|
50
|
+
elevenlabs: {
|
|
51
|
+
tts: {
|
|
52
|
+
text: 'Hello, this is a test of the ElevenLabs text-to-speech API.',
|
|
53
|
+
voiceId: '21m00Tcm4TlvDq8ikWAM',
|
|
54
|
+
modelId: 'eleven_multilingual_v2',
|
|
55
|
+
outputFormat: 'mp3_44100_128',
|
|
56
|
+
responseFormat: 'json',
|
|
57
|
+
},
|
|
58
|
+
voices: {},
|
|
59
|
+
models: {},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Default query params for GET requests
|
|
64
|
+
const DEFAULT_QUERY_PARAMS: Record<string, Record<string, string>> = {
|
|
65
|
+
elevenlabs: {
|
|
66
|
+
voices: 'search=&pageSize=10',
|
|
67
|
+
models: '',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* API Tester Component
|
|
73
|
+
*
|
|
74
|
+
* Interactive form for testing API endpoints directly from the showcase.
|
|
75
|
+
* Features:
|
|
76
|
+
* - Method selection
|
|
77
|
+
* - Schema-driven default values
|
|
78
|
+
* - Query parameter support for GET requests
|
|
79
|
+
* - JSON body editor
|
|
80
|
+
* - Response display with timing
|
|
81
|
+
* - Audio playback for binary responses
|
|
82
|
+
*
|
|
83
|
+
* Created with Hustle API Dev Tools (v3.9.2)
|
|
84
|
+
*/
|
|
85
|
+
export function APITester({ id, endpoint, methods, selectedEndpoint, schemaPath, schema }: APITesterProps) {
|
|
86
|
+
// Get default body for this API/endpoint
|
|
87
|
+
const getDefaultBody = () => {
|
|
88
|
+
const apiDefaults = DEFAULT_BODIES[id];
|
|
89
|
+
if (apiDefaults) {
|
|
90
|
+
const endpointDefaults = apiDefaults[selectedEndpoint || 'default'];
|
|
91
|
+
if (endpointDefaults && Object.keys(endpointDefaults).length > 0) {
|
|
92
|
+
return JSON.stringify(endpointDefaults, null, 2);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return JSON.stringify({}, null, 2);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Get default query params for GET requests
|
|
99
|
+
const getDefaultQueryParams = () => {
|
|
100
|
+
const apiParams = DEFAULT_QUERY_PARAMS[id];
|
|
101
|
+
if (apiParams && selectedEndpoint) {
|
|
102
|
+
return apiParams[selectedEndpoint] || '';
|
|
103
|
+
}
|
|
104
|
+
return '';
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const [request, setRequest] = useState<RequestState>({
|
|
108
|
+
method: methods[0] || 'POST',
|
|
109
|
+
body: getDefaultBody(),
|
|
110
|
+
queryParams: getDefaultQueryParams(),
|
|
111
|
+
headers: {
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const [response, setResponse] = useState<ResponseState>({
|
|
117
|
+
status: null,
|
|
118
|
+
statusText: '',
|
|
119
|
+
body: '',
|
|
120
|
+
time: null,
|
|
121
|
+
error: null,
|
|
122
|
+
contentType: null,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
126
|
+
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
|
127
|
+
|
|
128
|
+
// Update defaults when endpoint changes
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
setRequest((prev) => ({
|
|
131
|
+
...prev,
|
|
132
|
+
method: methods[0] || 'POST',
|
|
133
|
+
body: getDefaultBody(),
|
|
134
|
+
queryParams: getDefaultQueryParams(),
|
|
135
|
+
}));
|
|
136
|
+
// Clear previous response
|
|
137
|
+
setResponse({
|
|
138
|
+
status: null,
|
|
139
|
+
statusText: '',
|
|
140
|
+
body: '',
|
|
141
|
+
time: null,
|
|
142
|
+
error: null,
|
|
143
|
+
contentType: null,
|
|
144
|
+
});
|
|
145
|
+
setAudioUrl(null);
|
|
146
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
147
|
+
}, [selectedEndpoint, id]);
|
|
148
|
+
|
|
149
|
+
const handleSubmit = async () => {
|
|
150
|
+
setIsLoading(true);
|
|
151
|
+
setResponse({ status: null, statusText: '', body: '', time: null, error: null, contentType: null });
|
|
152
|
+
setAudioUrl(null);
|
|
153
|
+
|
|
154
|
+
const startTime = performance.now();
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
// Build URL with query params for GET
|
|
158
|
+
let url = endpoint;
|
|
159
|
+
if (request.method === 'GET' && request.queryParams.trim()) {
|
|
160
|
+
url = `${endpoint}?${request.queryParams}`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const fetchOptions: RequestInit = {
|
|
164
|
+
method: request.method,
|
|
165
|
+
headers: request.headers,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Add body for non-GET requests
|
|
169
|
+
if (request.method !== 'GET' && request.body.trim()) {
|
|
170
|
+
fetchOptions.body = request.body;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const res = await fetch(url, fetchOptions);
|
|
174
|
+
const endTime = performance.now();
|
|
175
|
+
|
|
176
|
+
const contentType = res.headers.get('content-type') || '';
|
|
177
|
+
let responseBody = '';
|
|
178
|
+
|
|
179
|
+
// Handle different content types
|
|
180
|
+
if (contentType.includes('audio/') || contentType.includes('application/octet-stream')) {
|
|
181
|
+
// Binary audio response
|
|
182
|
+
const blob = await res.blob();
|
|
183
|
+
const url = URL.createObjectURL(blob);
|
|
184
|
+
setAudioUrl(url);
|
|
185
|
+
responseBody = `[Audio Response - ${blob.size} bytes]\nContent-Type: ${contentType}`;
|
|
186
|
+
} else if (contentType.includes('application/json')) {
|
|
187
|
+
const json = await res.json();
|
|
188
|
+
responseBody = JSON.stringify(json, null, 2);
|
|
189
|
+
|
|
190
|
+
// Check if JSON contains base64 audio
|
|
191
|
+
if (json.audio && typeof json.audio === 'string') {
|
|
192
|
+
try {
|
|
193
|
+
const format = json.format || 'mp3';
|
|
194
|
+
const audioData = atob(json.audio);
|
|
195
|
+
const bytes = new Uint8Array(audioData.length);
|
|
196
|
+
for (let i = 0; i < audioData.length; i++) {
|
|
197
|
+
bytes[i] = audioData.charCodeAt(i);
|
|
198
|
+
}
|
|
199
|
+
const blob = new Blob([bytes], { type: `audio/${format}` });
|
|
200
|
+
const url = URL.createObjectURL(blob);
|
|
201
|
+
setAudioUrl(url);
|
|
202
|
+
} catch {
|
|
203
|
+
// Not valid base64, ignore
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
} else {
|
|
207
|
+
responseBody = await res.text();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setResponse({
|
|
211
|
+
status: res.status,
|
|
212
|
+
statusText: res.statusText,
|
|
213
|
+
body: responseBody,
|
|
214
|
+
time: Math.round(endTime - startTime),
|
|
215
|
+
error: null,
|
|
216
|
+
contentType,
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
const endTime = performance.now();
|
|
220
|
+
setResponse({
|
|
221
|
+
status: null,
|
|
222
|
+
statusText: '',
|
|
223
|
+
body: '',
|
|
224
|
+
time: Math.round(endTime - startTime),
|
|
225
|
+
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
|
226
|
+
contentType: null,
|
|
227
|
+
});
|
|
228
|
+
} finally {
|
|
229
|
+
setIsLoading(false);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const getStatusColor = (status: number | null) => {
|
|
234
|
+
if (!status) return 'text-gray-500';
|
|
235
|
+
if (status >= 200 && status < 300) return 'text-green-500';
|
|
236
|
+
if (status >= 400 && status < 500) return 'text-yellow-500';
|
|
237
|
+
if (status >= 500) return 'text-red-500';
|
|
238
|
+
return 'text-gray-500';
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<div className="grid gap-6 lg:grid-cols-2">
|
|
243
|
+
{/* Request Panel */}
|
|
244
|
+
<div className="space-y-4">
|
|
245
|
+
<h3 className="text-lg font-bold text-black dark:text-white">Request</h3>
|
|
246
|
+
|
|
247
|
+
{/* Method Selection */}
|
|
248
|
+
<div>
|
|
249
|
+
<label className="mb-1 block text-sm font-bold text-black dark:text-white">Method</label>
|
|
250
|
+
<div className="flex gap-2">
|
|
251
|
+
{methods.map((method) => (
|
|
252
|
+
<button
|
|
253
|
+
key={method}
|
|
254
|
+
onClick={() => setRequest((prev) => ({ ...prev, method }))}
|
|
255
|
+
className={`border-2 px-4 py-2 text-sm font-medium transition-colors ${
|
|
256
|
+
request.method === method
|
|
257
|
+
? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
|
|
258
|
+
: 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-800 dark:text-white'
|
|
259
|
+
}`}
|
|
260
|
+
>
|
|
261
|
+
{method}
|
|
262
|
+
</button>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Endpoint Display */}
|
|
268
|
+
<div>
|
|
269
|
+
<label className="mb-1 block text-sm font-bold text-black dark:text-white">Endpoint</label>
|
|
270
|
+
<div className="flex items-center border-2 border-black bg-gray-50 px-3 py-2 dark:border-gray-600 dark:bg-gray-800">
|
|
271
|
+
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">{endpoint}</span>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Query Parameters (for GET requests) */}
|
|
276
|
+
{request.method === 'GET' && (
|
|
277
|
+
<div>
|
|
278
|
+
<label className="mb-1 block text-sm font-bold text-black dark:text-white">Query Parameters</label>
|
|
279
|
+
<input
|
|
280
|
+
type="text"
|
|
281
|
+
value={request.queryParams}
|
|
282
|
+
onChange={(e) => setRequest((prev) => ({ ...prev, queryParams: e.target.value }))}
|
|
283
|
+
className="w-full border-2 border-black bg-white px-3 py-2 font-mono text-sm focus:border-[#BA0C2F] focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
284
|
+
placeholder="key1=value1&key2=value2"
|
|
285
|
+
/>
|
|
286
|
+
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
|
287
|
+
Add query string parameters (without the ?)
|
|
288
|
+
</p>
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{/* Body Editor (hide for GET) */}
|
|
293
|
+
{request.method !== 'GET' && (
|
|
294
|
+
<div>
|
|
295
|
+
<div className="mb-1 flex items-center justify-between">
|
|
296
|
+
<label className="block text-sm font-bold text-black dark:text-white">Body (JSON)</label>
|
|
297
|
+
<button
|
|
298
|
+
onClick={() => setRequest((prev) => ({ ...prev, body: getDefaultBody() }))}
|
|
299
|
+
className="text-xs text-gray-600 hover:text-[#BA0C2F] dark:text-gray-400"
|
|
300
|
+
>
|
|
301
|
+
Reset to defaults
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
<textarea
|
|
305
|
+
value={request.body}
|
|
306
|
+
onChange={(e) => setRequest((prev) => ({ ...prev, body: e.target.value }))}
|
|
307
|
+
className="h-48 w-full border-2 border-black bg-white p-3 font-mono text-sm focus:border-[#BA0C2F] focus:outline-none dark:border-gray-600 dark:bg-gray-800 dark:text-white"
|
|
308
|
+
placeholder='{"key": "value"}'
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
|
|
313
|
+
{/* Parameter Documentation */}
|
|
314
|
+
{schema && (schema.request?.length || schema.queryParams?.length) ? (
|
|
315
|
+
<ParameterDocs
|
|
316
|
+
requestParams={schema.request}
|
|
317
|
+
queryParams={schema.queryParams}
|
|
318
|
+
isGetRequest={request.method === 'GET'}
|
|
319
|
+
/>
|
|
320
|
+
) : null}
|
|
321
|
+
|
|
322
|
+
{/* Headers */}
|
|
323
|
+
<div>
|
|
324
|
+
<label className="mb-1 block text-sm font-bold text-black dark:text-white">Headers</label>
|
|
325
|
+
<div className="border-2 border-black bg-gray-50 p-3 dark:border-gray-600 dark:bg-gray-800">
|
|
326
|
+
{Object.entries(request.headers).map(([key, value]) => (
|
|
327
|
+
<div key={key} className="flex items-center gap-2 text-sm">
|
|
328
|
+
<span className="font-bold text-black dark:text-white">{key}:</span>
|
|
329
|
+
<span className="text-gray-600 dark:text-gray-400">{value}</span>
|
|
330
|
+
</div>
|
|
331
|
+
))}
|
|
332
|
+
</div>
|
|
333
|
+
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
|
334
|
+
API keys loaded from .env automatically
|
|
335
|
+
</p>
|
|
336
|
+
</div>
|
|
337
|
+
|
|
338
|
+
{/* Submit Button */}
|
|
339
|
+
<button
|
|
340
|
+
onClick={handleSubmit}
|
|
341
|
+
disabled={isLoading}
|
|
342
|
+
className="w-full border-2 border-black bg-[#BA0C2F] py-3 font-bold text-white transition-colors hover:bg-[#8a0923] disabled:opacity-50"
|
|
343
|
+
>
|
|
344
|
+
{isLoading ? (
|
|
345
|
+
<span className="flex items-center justify-center gap-2">
|
|
346
|
+
<svg
|
|
347
|
+
className="h-4 w-4 animate-spin"
|
|
348
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
349
|
+
fill="none"
|
|
350
|
+
viewBox="0 0 24 24"
|
|
351
|
+
>
|
|
352
|
+
<circle
|
|
353
|
+
className="opacity-25"
|
|
354
|
+
cx="12"
|
|
355
|
+
cy="12"
|
|
356
|
+
r="10"
|
|
357
|
+
stroke="currentColor"
|
|
358
|
+
strokeWidth="4"
|
|
359
|
+
/>
|
|
360
|
+
<path
|
|
361
|
+
className="opacity-75"
|
|
362
|
+
fill="currentColor"
|
|
363
|
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
364
|
+
/>
|
|
365
|
+
</svg>
|
|
366
|
+
Sending...
|
|
367
|
+
</span>
|
|
368
|
+
) : (
|
|
369
|
+
`Send ${request.method} Request`
|
|
370
|
+
)}
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
{/* Response Panel */}
|
|
375
|
+
<div className="space-y-4">
|
|
376
|
+
<div className="flex items-center justify-between">
|
|
377
|
+
<h3 className="text-lg font-bold text-black dark:text-white">Response</h3>
|
|
378
|
+
{response.time !== null && (
|
|
379
|
+
<span className="text-sm text-gray-600 dark:text-gray-400">{response.time}ms</span>
|
|
380
|
+
)}
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
{/* Status */}
|
|
384
|
+
{response.status !== null && (
|
|
385
|
+
<div className="flex items-center gap-2">
|
|
386
|
+
<span className={`text-2xl font-bold ${getStatusColor(response.status)}`}>
|
|
387
|
+
{response.status}
|
|
388
|
+
</span>
|
|
389
|
+
<span className="text-gray-600 dark:text-gray-400">{response.statusText}</span>
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
|
|
393
|
+
{/* Error */}
|
|
394
|
+
{response.error && (
|
|
395
|
+
<div className="border-2 border-red-600 bg-red-50 p-4 dark:bg-red-900/20">
|
|
396
|
+
<p className="text-sm text-red-800 dark:text-red-300">{response.error}</p>
|
|
397
|
+
</div>
|
|
398
|
+
)}
|
|
399
|
+
|
|
400
|
+
{/* Audio Player */}
|
|
401
|
+
{audioUrl && (
|
|
402
|
+
<div className="border-2 border-black bg-gray-50 p-4 dark:border-gray-600 dark:bg-gray-800">
|
|
403
|
+
<p className="mb-2 text-sm font-bold text-black dark:text-white">Audio Response</p>
|
|
404
|
+
<audio controls className="w-full" src={audioUrl}>
|
|
405
|
+
Your browser does not support the audio element.
|
|
406
|
+
</audio>
|
|
407
|
+
</div>
|
|
408
|
+
)}
|
|
409
|
+
|
|
410
|
+
{/* Body */}
|
|
411
|
+
{response.body ? (
|
|
412
|
+
<div className="relative">
|
|
413
|
+
<pre className="max-h-96 overflow-auto border-2 border-black bg-zinc-900 p-4 text-sm text-zinc-100">
|
|
414
|
+
<code>{response.body}</code>
|
|
415
|
+
</pre>
|
|
416
|
+
<button
|
|
417
|
+
onClick={() => navigator.clipboard.writeText(response.body)}
|
|
418
|
+
className="absolute right-2 top-2 border border-zinc-600 bg-zinc-700 px-2 py-1 text-xs text-zinc-300 hover:bg-zinc-600"
|
|
419
|
+
>
|
|
420
|
+
Copy
|
|
421
|
+
</button>
|
|
422
|
+
</div>
|
|
423
|
+
) : (
|
|
424
|
+
<div className="flex h-48 items-center justify-center border-2 border-dashed border-black dark:border-gray-600">
|
|
425
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
426
|
+
{isLoading ? 'Waiting for response...' : 'Send a request to see the response'}
|
|
427
|
+
</p>
|
|
428
|
+
</div>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Parameter Documentation Component
|
|
437
|
+
* Displays request body and query parameter documentation in a collapsible panel.
|
|
438
|
+
*/
|
|
439
|
+
function ParameterDocs({
|
|
440
|
+
requestParams,
|
|
441
|
+
queryParams,
|
|
442
|
+
isGetRequest,
|
|
443
|
+
}: {
|
|
444
|
+
requestParams?: ParameterDoc[];
|
|
445
|
+
queryParams?: ParameterDoc[];
|
|
446
|
+
isGetRequest: boolean;
|
|
447
|
+
}) {
|
|
448
|
+
const [isExpanded, setIsExpanded] = useState(true);
|
|
449
|
+
|
|
450
|
+
const paramsToShow = isGetRequest ? queryParams : requestParams;
|
|
451
|
+
if (!paramsToShow?.length) return null;
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<div className="border-2 border-black dark:border-gray-600">
|
|
455
|
+
<button
|
|
456
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
457
|
+
className="flex w-full items-center justify-between bg-gray-50 px-3 py-2 text-left dark:bg-gray-800"
|
|
458
|
+
>
|
|
459
|
+
<span className="text-sm font-bold text-black dark:text-white">
|
|
460
|
+
{isGetRequest ? 'Query Parameters' : 'Request Body'} Documentation
|
|
461
|
+
</span>
|
|
462
|
+
<svg
|
|
463
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
464
|
+
width="16"
|
|
465
|
+
height="16"
|
|
466
|
+
viewBox="0 0 24 24"
|
|
467
|
+
fill="none"
|
|
468
|
+
stroke="currentColor"
|
|
469
|
+
strokeWidth="2"
|
|
470
|
+
strokeLinecap="round"
|
|
471
|
+
strokeLinejoin="round"
|
|
472
|
+
className={`text-gray-500 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
|
473
|
+
>
|
|
474
|
+
<polyline points="6 9 12 15 18 9" />
|
|
475
|
+
</svg>
|
|
476
|
+
</button>
|
|
477
|
+
|
|
478
|
+
{isExpanded && (
|
|
479
|
+
<div className="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
|
480
|
+
{paramsToShow.map((param) => (
|
|
481
|
+
<div key={param.name} className="px-3 py-2">
|
|
482
|
+
<div className="flex items-center gap-2">
|
|
483
|
+
<code className="text-sm font-bold text-[#BA0C2F]">{param.name}</code>
|
|
484
|
+
<span className="border border-gray-300 bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
|
485
|
+
{param.type}
|
|
486
|
+
</span>
|
|
487
|
+
{param.required && (
|
|
488
|
+
<span className="border border-red-300 bg-red-50 px-1.5 py-0.5 text-xs text-red-600 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400">
|
|
489
|
+
required
|
|
490
|
+
</span>
|
|
491
|
+
)}
|
|
492
|
+
</div>
|
|
493
|
+
{param.description && (
|
|
494
|
+
<p className="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
|
495
|
+
{param.description}
|
|
496
|
+
</p>
|
|
497
|
+
)}
|
|
498
|
+
{param.enum && (
|
|
499
|
+
<div className="mt-1 flex flex-wrap gap-1">
|
|
500
|
+
<span className="text-xs text-gray-500">Options:</span>
|
|
501
|
+
{param.enum.map((val) => (
|
|
502
|
+
<code
|
|
503
|
+
key={val}
|
|
504
|
+
className="border border-gray-200 bg-gray-50 px-1 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400"
|
|
505
|
+
>
|
|
506
|
+
{val}
|
|
507
|
+
</code>
|
|
508
|
+
))}
|
|
509
|
+
</div>
|
|
510
|
+
)}
|
|
511
|
+
{param.default !== undefined && (
|
|
512
|
+
<p className="mt-1 text-xs text-gray-500">
|
|
513
|
+
Default: <code className="text-gray-700 dark:text-gray-300">{String(param.default)}</code>
|
|
514
|
+
</p>
|
|
515
|
+
)}
|
|
516
|
+
</div>
|
|
517
|
+
))}
|
|
518
|
+
</div>
|
|
519
|
+
)}
|
|
520
|
+
</div>
|
|
521
|
+
);
|
|
522
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { HeroHeader } from '../shared/HeroHeader';
|
|
4
|
+
import { APIShowcase } from './_components/APIShowcase';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* API Showcase Page
|
|
8
|
+
*
|
|
9
|
+
* Auto-generated grid view of all APIs from registry.json.
|
|
10
|
+
* Click any API card to open interactive testing modal.
|
|
11
|
+
*
|
|
12
|
+
* Features:
|
|
13
|
+
* - Animated 3D grid hero header
|
|
14
|
+
* - Grid layout showing all registered APIs
|
|
15
|
+
* - Interactive "Try It" testing for each endpoint
|
|
16
|
+
* - Request/response schema display
|
|
17
|
+
* - Curl example generation
|
|
18
|
+
* - Test status indicators
|
|
19
|
+
*
|
|
20
|
+
* Created with Hustle API Dev Tools (v3.9.2)
|
|
21
|
+
*/
|
|
22
|
+
export default function APIShowcasePage() {
|
|
23
|
+
return (
|
|
24
|
+
<main className="min-h-screen bg-white dark:bg-[#050505]">
|
|
25
|
+
<HeroHeader
|
|
26
|
+
title="API Showcase"
|
|
27
|
+
badge="API Documentation"
|
|
28
|
+
description={
|
|
29
|
+
<>
|
|
30
|
+
Interactive testing and documentation for all{' '}
|
|
31
|
+
<strong>Hustle</strong> API endpoints.
|
|
32
|
+
</>
|
|
33
|
+
}
|
|
34
|
+
/>
|
|
35
|
+
|
|
36
|
+
<div className="container mx-auto px-4 py-8">
|
|
37
|
+
<APIShowcase />
|
|
38
|
+
</div>
|
|
39
|
+
</main>
|
|
40
|
+
);
|
|
41
|
+
}
|