@hustle-together/api-dev-tools 3.6.5 → 3.10.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.
- package/README.md +5599 -258
- package/bin/cli.js +395 -20
- package/commands/README.md +459 -71
- package/commands/hustle-api-continue.md +158 -0
- package/commands/{api-create.md → hustle-api-create.md} +35 -15
- 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-page.md +933 -0
- package/commands/hustle-ui-create.md +825 -0
- package/hooks/api-workflow-check.py +545 -21
- package/hooks/cache-research.py +337 -0
- package/hooks/check-api-routes.py +168 -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-a11y-audit.py +202 -0
- package/hooks/enforce-brand-guide.py +241 -0
- package/hooks/enforce-documentation.py +60 -8
- package/hooks/enforce-freshness.py +184 -0
- package/hooks/enforce-page-components.py +186 -0
- package/hooks/enforce-page-data-schema.py +155 -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 +1161 -0
- package/hooks/session-logger.py +297 -0
- package/hooks/session-startup.py +160 -15
- 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 +212 -0
- package/package.json +8 -3
- 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 +217 -161
- package/templates/api-showcase/_components/APICard.tsx +153 -0
- package/templates/api-showcase/_components/APIModal.tsx +375 -0
- package/templates/api-showcase/_components/APIShowcase.tsx +231 -0
- package/templates/api-showcase/_components/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 +90 -0
- package/templates/shared/HeroHeader.tsx +261 -0
- package/templates/shared/index.ts +1 -0
- package/templates/ui-showcase/_components/PreviewCard.tsx +315 -0
- package/templates/ui-showcase/_components/PreviewModal.tsx +676 -0
- package/templates/ui-showcase/_components/UIShowcase.tsx +262 -0
- package/templates/ui-showcase/page.tsx +26 -0
- package/demo/hustle-together/blog/gemini-vs-claude-widgets.html +0 -959
- package/demo/hustle-together/blog/interview-driven-api-development.html +0 -1146
- package/demo/hustle-together/blog/tdd-for-ai.html +0 -982
- package/demo/hustle-together/index.html +0 -1312
- package/demo/workflow-demo-v3.5-backup.html +0 -5008
- package/demo/workflow-demo.html +0 -6202
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useCallback, useState } from 'react';
|
|
4
|
+
import { Sandpack, SandpackTheme } from '@codesandbox/sandpack-react';
|
|
5
|
+
|
|
6
|
+
interface RegistryItem {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
file?: string;
|
|
10
|
+
route?: string;
|
|
11
|
+
story?: string;
|
|
12
|
+
tests?: string;
|
|
13
|
+
variants?: string[];
|
|
14
|
+
status?: string;
|
|
15
|
+
created_at?: string;
|
|
16
|
+
uses_components?: string[];
|
|
17
|
+
props_interface?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PreviewModalProps {
|
|
21
|
+
id: string;
|
|
22
|
+
type: 'component' | 'page';
|
|
23
|
+
data: RegistryItem;
|
|
24
|
+
onClose: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ViewportSize = 'desktop' | 'tablet' | 'mobile';
|
|
28
|
+
|
|
29
|
+
const VIEWPORT_WIDTHS: Record<ViewportSize, string> = {
|
|
30
|
+
desktop: '100%',
|
|
31
|
+
tablet: '768px',
|
|
32
|
+
mobile: '375px',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Preview Modal Component
|
|
37
|
+
*
|
|
38
|
+
* Displays a modal with live preview of a component or page.
|
|
39
|
+
* Features:
|
|
40
|
+
* - Components: Dynamic import with error boundary isolation
|
|
41
|
+
* - Pages: Iframe with responsive viewport controls
|
|
42
|
+
* - Variant switching for components
|
|
43
|
+
*
|
|
44
|
+
* Created with Hustle UI Create workflow (v3.9.2)
|
|
45
|
+
*/
|
|
46
|
+
export function PreviewModal({ id, type, data, onClose }: PreviewModalProps) {
|
|
47
|
+
// Close on Escape key
|
|
48
|
+
const handleKeyDown = useCallback(
|
|
49
|
+
(e: KeyboardEvent) => {
|
|
50
|
+
if (e.key === 'Escape') {
|
|
51
|
+
onClose();
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[onClose]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
59
|
+
document.body.style.overflow = 'hidden';
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
63
|
+
document.body.style.overflow = '';
|
|
64
|
+
};
|
|
65
|
+
}, [handleKeyDown]);
|
|
66
|
+
|
|
67
|
+
// Get page route from file path
|
|
68
|
+
const getPageRoute = () => {
|
|
69
|
+
if (data.route) return data.route;
|
|
70
|
+
if (data.file?.includes('src/app/')) {
|
|
71
|
+
const match = data.file.match(/src\/app\/(.+?)\/page\.tsx?$/);
|
|
72
|
+
if (match) return `/${match[1]}`;
|
|
73
|
+
}
|
|
74
|
+
return `/${id}`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="fixed inset-0 z-50 flex items-center justify-center"
|
|
80
|
+
role="dialog"
|
|
81
|
+
aria-modal="true"
|
|
82
|
+
aria-labelledby="modal-title"
|
|
83
|
+
>
|
|
84
|
+
{/* Backdrop */}
|
|
85
|
+
<div
|
|
86
|
+
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
|
87
|
+
onClick={onClose}
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
/>
|
|
90
|
+
|
|
91
|
+
{/* Modal Content */}
|
|
92
|
+
<div className="relative z-10 flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden border-2 border-black bg-white shadow-[8px_8px_0px_0px_rgba(0,0,0,0.1)] dark:border-gray-700 dark:bg-gray-900">
|
|
93
|
+
{/* Header - Hustle accent bar */}
|
|
94
|
+
<div className="h-1 w-full bg-[#BA0C2F]" />
|
|
95
|
+
<header className="flex items-center justify-between border-b-2 border-black px-6 py-4 dark:border-gray-700">
|
|
96
|
+
<div>
|
|
97
|
+
<h2 id="modal-title" className="text-lg font-bold text-black dark:text-white">
|
|
98
|
+
{data.name || id}
|
|
99
|
+
</h2>
|
|
100
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
101
|
+
{type === 'component' ? 'Component Preview' : 'Page Preview'}
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
<button
|
|
105
|
+
onClick={onClose}
|
|
106
|
+
className="border-2 border-black p-2 transition-colors hover:border-[#BA0C2F] hover:bg-gray-50 dark:border-gray-600 dark:text-white dark:hover:bg-gray-800"
|
|
107
|
+
aria-label="Close preview"
|
|
108
|
+
>
|
|
109
|
+
<svg
|
|
110
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
111
|
+
width="20"
|
|
112
|
+
height="20"
|
|
113
|
+
viewBox="0 0 24 24"
|
|
114
|
+
fill="none"
|
|
115
|
+
stroke="currentColor"
|
|
116
|
+
strokeWidth="2"
|
|
117
|
+
strokeLinecap="round"
|
|
118
|
+
strokeLinejoin="round"
|
|
119
|
+
>
|
|
120
|
+
<path d="M18 6 6 18" />
|
|
121
|
+
<path d="m6 6 12 12" />
|
|
122
|
+
</svg>
|
|
123
|
+
</button>
|
|
124
|
+
</header>
|
|
125
|
+
|
|
126
|
+
{/* Preview Area */}
|
|
127
|
+
<div className="flex-1 overflow-auto">
|
|
128
|
+
{type === 'component' ? (
|
|
129
|
+
<ComponentPreview id={id} data={data} />
|
|
130
|
+
) : (
|
|
131
|
+
<PagePreview route={getPageRoute()} />
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Footer */}
|
|
136
|
+
<footer className="border-t-2 border-black bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-800">
|
|
137
|
+
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
138
|
+
{/* Info */}
|
|
139
|
+
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
140
|
+
{data.description && (
|
|
141
|
+
<p className="line-clamp-1">{data.description}</p>
|
|
142
|
+
)}
|
|
143
|
+
{data.created_at && (
|
|
144
|
+
<p className="mt-1">Created: {data.created_at}</p>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Actions */}
|
|
149
|
+
<div className="flex gap-2">
|
|
150
|
+
{type === 'page' && (
|
|
151
|
+
<a
|
|
152
|
+
href={getPageRoute()}
|
|
153
|
+
target="_blank"
|
|
154
|
+
rel="noopener noreferrer"
|
|
155
|
+
className="border-2 border-black bg-white px-3 py-1.5 text-sm font-medium text-black transition-colors hover:border-[#BA0C2F] hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
|
|
156
|
+
>
|
|
157
|
+
Open Full Page
|
|
158
|
+
</a>
|
|
159
|
+
)}
|
|
160
|
+
<button
|
|
161
|
+
onClick={() => {
|
|
162
|
+
const importPath = data.file?.replace(/^src\//, '@/').replace(/\.tsx?$/, '');
|
|
163
|
+
if (importPath) {
|
|
164
|
+
navigator.clipboard.writeText(
|
|
165
|
+
`import { ${data.name || id} } from '${importPath}';`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}}
|
|
169
|
+
className="border-2 border-black bg-[#BA0C2F] px-3 py-1.5 text-sm font-bold text-white transition-colors hover:bg-[#8a0923]"
|
|
170
|
+
>
|
|
171
|
+
Copy Import
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
</footer>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Hustle Together theme for Sandpack
|
|
182
|
+
const hustleTheme: SandpackTheme = {
|
|
183
|
+
colors: {
|
|
184
|
+
surface1: '#ffffff',
|
|
185
|
+
surface2: '#f8f8f8',
|
|
186
|
+
surface3: '#f0f0f0',
|
|
187
|
+
clickable: '#666666',
|
|
188
|
+
base: '#000000',
|
|
189
|
+
disabled: '#cccccc',
|
|
190
|
+
hover: '#BA0C2F',
|
|
191
|
+
accent: '#BA0C2F',
|
|
192
|
+
error: '#ef4444',
|
|
193
|
+
errorSurface: '#fef2f2',
|
|
194
|
+
},
|
|
195
|
+
syntax: {
|
|
196
|
+
plain: '#000000',
|
|
197
|
+
comment: { color: '#666666', fontStyle: 'italic' },
|
|
198
|
+
keyword: '#BA0C2F',
|
|
199
|
+
tag: '#BA0C2F',
|
|
200
|
+
punctuation: '#000000',
|
|
201
|
+
definition: '#000000',
|
|
202
|
+
property: '#BA0C2F',
|
|
203
|
+
static: '#BA0C2F',
|
|
204
|
+
string: '#22c55e',
|
|
205
|
+
},
|
|
206
|
+
font: {
|
|
207
|
+
body: '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", system-ui, sans-serif',
|
|
208
|
+
mono: '"SF Mono", Monaco, Inconsolata, "Fira Code", monospace',
|
|
209
|
+
size: '13px',
|
|
210
|
+
lineHeight: '1.5',
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Generate example code for different component types
|
|
215
|
+
function generateComponentCode(name: string, variants: string[], selectedVariant: string | null): string {
|
|
216
|
+
const variant = selectedVariant || variants[0] || 'primary';
|
|
217
|
+
|
|
218
|
+
// Generate code based on component type
|
|
219
|
+
switch (name.toLowerCase()) {
|
|
220
|
+
case 'button':
|
|
221
|
+
return `export default function App() {
|
|
222
|
+
return (
|
|
223
|
+
<div style={{ padding: '2rem', display: 'flex', flexDirection: 'column', gap: '1rem', alignItems: 'flex-start' }}>
|
|
224
|
+
<h2 style={{ margin: 0, fontFamily: 'system-ui' }}>Button - ${variant}</h2>
|
|
225
|
+
|
|
226
|
+
{/* ${variant} variant */}
|
|
227
|
+
<button style={{
|
|
228
|
+
padding: '0.75rem 1.5rem',
|
|
229
|
+
fontSize: '14px',
|
|
230
|
+
fontWeight: 'bold',
|
|
231
|
+
border: '2px solid ${variant === 'ghost' ? '#000' : '#BA0C2F'}',
|
|
232
|
+
background: '${variant === 'ghost' ? 'transparent' : variant === 'secondary' ? '#fff' : '#BA0C2F'}',
|
|
233
|
+
color: '${variant === 'ghost' || variant === 'secondary' ? '#000' : '#fff'}',
|
|
234
|
+
cursor: 'pointer',
|
|
235
|
+
}}>
|
|
236
|
+
Click Me
|
|
237
|
+
</button>
|
|
238
|
+
|
|
239
|
+
{/* All variants */}
|
|
240
|
+
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
241
|
+
${variants.map(v => `<button style={{
|
|
242
|
+
padding: '0.5rem 1rem',
|
|
243
|
+
fontSize: '12px',
|
|
244
|
+
fontWeight: 'bold',
|
|
245
|
+
border: '2px solid ${v === 'ghost' ? '#000' : '#BA0C2F'}',
|
|
246
|
+
background: '${v === 'ghost' ? 'transparent' : v === 'secondary' ? '#fff' : '#BA0C2F'}',
|
|
247
|
+
color: '${v === 'ghost' || v === 'secondary' ? '#000' : '#fff'}',
|
|
248
|
+
cursor: 'pointer',
|
|
249
|
+
}}>${v}</button>`).join('\n ')}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}`;
|
|
254
|
+
|
|
255
|
+
case 'card':
|
|
256
|
+
return `export default function App() {
|
|
257
|
+
return (
|
|
258
|
+
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
|
|
259
|
+
<h2 style={{ margin: '0 0 1rem' }}>Card - ${variant}</h2>
|
|
260
|
+
|
|
261
|
+
<div style={{
|
|
262
|
+
border: '${variant === 'bordered' ? '2px solid #000' : '1px solid #ccc'}',
|
|
263
|
+
boxShadow: '${variant === 'elevated' ? '4px 4px 0 rgba(0,0,0,0.1)' : 'none'}',
|
|
264
|
+
background: '#fff',
|
|
265
|
+
maxWidth: '320px',
|
|
266
|
+
}}>
|
|
267
|
+
{/* Header */}
|
|
268
|
+
<div style={{ padding: '1rem', borderBottom: '1px solid #eee' }}>
|
|
269
|
+
<h3 style={{ margin: 0, fontWeight: 'bold' }}>Card Title</h3>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{/* Body */}
|
|
273
|
+
<div style={{ padding: '1rem' }}>
|
|
274
|
+
<p style={{ margin: 0, color: '#666' }}>
|
|
275
|
+
This is a ${variant} card variant. Cards are used to group related content.
|
|
276
|
+
</p>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* Footer */}
|
|
280
|
+
<div style={{ padding: '1rem', borderTop: '1px solid #eee', background: '#f8f8f8' }}>
|
|
281
|
+
<button style={{
|
|
282
|
+
padding: '0.5rem 1rem',
|
|
283
|
+
background: '#BA0C2F',
|
|
284
|
+
color: '#fff',
|
|
285
|
+
border: 'none',
|
|
286
|
+
fontWeight: 'bold',
|
|
287
|
+
cursor: 'pointer',
|
|
288
|
+
}}>Action</button>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
);
|
|
293
|
+
}`;
|
|
294
|
+
|
|
295
|
+
case 'formfield':
|
|
296
|
+
return `import { useState } from 'react';
|
|
297
|
+
|
|
298
|
+
export default function App() {
|
|
299
|
+
const [value, setValue] = useState('');
|
|
300
|
+
const [error, setError] = useState('');
|
|
301
|
+
|
|
302
|
+
const handleChange = (e) => {
|
|
303
|
+
setValue(e.target.value);
|
|
304
|
+
setError(e.target.value.length < 3 ? 'Must be at least 3 characters' : '');
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
return (
|
|
308
|
+
<div style={{ padding: '2rem', fontFamily: 'system-ui', maxWidth: '320px' }}>
|
|
309
|
+
<h2 style={{ margin: '0 0 1rem' }}>FormField - ${variant}</h2>
|
|
310
|
+
|
|
311
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
312
|
+
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold', fontSize: '14px' }}>
|
|
313
|
+
${variant === 'email' ? 'Email Address' : variant === 'password' ? 'Password' : variant === 'textarea' ? 'Message' : 'Username'}
|
|
314
|
+
</label>
|
|
315
|
+
|
|
316
|
+
${variant === 'textarea' ? `<textarea
|
|
317
|
+
value={value}
|
|
318
|
+
onChange={handleChange}
|
|
319
|
+
placeholder="Enter your message..."
|
|
320
|
+
rows={4}
|
|
321
|
+
style={{
|
|
322
|
+
width: '100%',
|
|
323
|
+
padding: '0.75rem',
|
|
324
|
+
border: error ? '2px solid #ef4444' : '2px solid #000',
|
|
325
|
+
fontSize: '14px',
|
|
326
|
+
fontFamily: 'inherit',
|
|
327
|
+
boxSizing: 'border-box',
|
|
328
|
+
}}
|
|
329
|
+
/>` : `<input
|
|
330
|
+
type="${variant}"
|
|
331
|
+
value={value}
|
|
332
|
+
onChange={handleChange}
|
|
333
|
+
placeholder="${variant === 'email' ? 'you@example.com' : variant === 'password' ? '••••••••' : 'Enter text...'}"
|
|
334
|
+
style={{
|
|
335
|
+
width: '100%',
|
|
336
|
+
padding: '0.75rem',
|
|
337
|
+
border: error ? '2px solid #ef4444' : '2px solid #000',
|
|
338
|
+
fontSize: '14px',
|
|
339
|
+
boxSizing: 'border-box',
|
|
340
|
+
}}
|
|
341
|
+
/>`}
|
|
342
|
+
|
|
343
|
+
{error && (
|
|
344
|
+
<p style={{ color: '#ef4444', fontSize: '12px', marginTop: '0.5rem' }}>
|
|
345
|
+
{error}
|
|
346
|
+
</p>
|
|
347
|
+
)}
|
|
348
|
+
|
|
349
|
+
<p style={{ color: '#666', fontSize: '12px', marginTop: '0.5rem' }}>
|
|
350
|
+
Helper text for the input field
|
|
351
|
+
</p>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}`;
|
|
356
|
+
|
|
357
|
+
default:
|
|
358
|
+
// Generic component preview
|
|
359
|
+
return `export default function App() {
|
|
360
|
+
return (
|
|
361
|
+
<div style={{ padding: '2rem', fontFamily: 'system-ui' }}>
|
|
362
|
+
<h2 style={{ margin: '0 0 1rem' }}>${name}</h2>
|
|
363
|
+
|
|
364
|
+
<div style={{
|
|
365
|
+
border: '2px solid #000',
|
|
366
|
+
padding: '2rem',
|
|
367
|
+
textAlign: 'center',
|
|
368
|
+
background: '#f8f8f8',
|
|
369
|
+
}}>
|
|
370
|
+
<div style={{
|
|
371
|
+
width: '64px',
|
|
372
|
+
height: '64px',
|
|
373
|
+
margin: '0 auto 1rem',
|
|
374
|
+
border: '2px solid #BA0C2F',
|
|
375
|
+
display: 'flex',
|
|
376
|
+
alignItems: 'center',
|
|
377
|
+
justifyContent: 'center',
|
|
378
|
+
background: '#fff',
|
|
379
|
+
}}>
|
|
380
|
+
<span style={{ fontSize: '24px' }}>⬛</span>
|
|
381
|
+
</div>
|
|
382
|
+
|
|
383
|
+
<p style={{ margin: 0, fontWeight: 'bold' }}>${name} Component</p>
|
|
384
|
+
${selectedVariant ? `<p style={{ margin: '0.5rem 0 0', color: '#BA0C2F', fontSize: '14px' }}>Variant: ${selectedVariant}</p>` : ''}
|
|
385
|
+
|
|
386
|
+
<p style={{ margin: '1rem 0 0', color: '#666', fontSize: '14px' }}>
|
|
387
|
+
Edit the code on the left to customize this component
|
|
388
|
+
</p>
|
|
389
|
+
</div>
|
|
390
|
+
</div>
|
|
391
|
+
);
|
|
392
|
+
}`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Component Preview with Sandpack
|
|
398
|
+
*
|
|
399
|
+
* Uses CodeSandbox's Sandpack to render live, editable component previews.
|
|
400
|
+
* No server/client boundary issues - runs entirely in the browser.
|
|
401
|
+
*/
|
|
402
|
+
function ComponentPreview({
|
|
403
|
+
id,
|
|
404
|
+
data,
|
|
405
|
+
}: {
|
|
406
|
+
id: string;
|
|
407
|
+
data: RegistryItem;
|
|
408
|
+
}) {
|
|
409
|
+
const [selectedVariant, setSelectedVariant] = useState<string | null>(
|
|
410
|
+
data.variants?.[0] || null
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const componentCode = generateComponentCode(
|
|
414
|
+
data.name || id,
|
|
415
|
+
data.variants || [],
|
|
416
|
+
selectedVariant
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<div className="p-4">
|
|
421
|
+
{/* Variant Controls */}
|
|
422
|
+
{data.variants && data.variants.length > 0 && (
|
|
423
|
+
<div className="mb-4">
|
|
424
|
+
<h3 className="mb-3 text-sm font-bold text-black dark:text-white">Variants</h3>
|
|
425
|
+
<div className="flex flex-wrap gap-2">
|
|
426
|
+
{data.variants.map((variant) => (
|
|
427
|
+
<button
|
|
428
|
+
key={variant}
|
|
429
|
+
onClick={() => setSelectedVariant(variant)}
|
|
430
|
+
className={`border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
431
|
+
selectedVariant === variant
|
|
432
|
+
? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
|
|
433
|
+
: 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
|
434
|
+
}`}
|
|
435
|
+
>
|
|
436
|
+
{variant}
|
|
437
|
+
</button>
|
|
438
|
+
))}
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
)}
|
|
442
|
+
|
|
443
|
+
{/* Sandpack Live Preview */}
|
|
444
|
+
<div className="border-2 border-black dark:border-gray-700">
|
|
445
|
+
<Sandpack
|
|
446
|
+
template="react"
|
|
447
|
+
theme={hustleTheme}
|
|
448
|
+
files={{
|
|
449
|
+
'/App.js': componentCode,
|
|
450
|
+
}}
|
|
451
|
+
options={{
|
|
452
|
+
showNavigator: false,
|
|
453
|
+
showTabs: true,
|
|
454
|
+
showLineNumbers: true,
|
|
455
|
+
showInlineErrors: true,
|
|
456
|
+
editorHeight: 350,
|
|
457
|
+
}}
|
|
458
|
+
/>
|
|
459
|
+
</div>
|
|
460
|
+
|
|
461
|
+
{/* Component Info */}
|
|
462
|
+
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
463
|
+
{data.props_interface && (
|
|
464
|
+
<div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
|
465
|
+
<h3 className="mb-2 text-sm font-bold text-black dark:text-white">Props Interface</h3>
|
|
466
|
+
<code className="font-mono text-sm text-gray-700 dark:text-gray-300">{data.props_interface}</code>
|
|
467
|
+
</div>
|
|
468
|
+
)}
|
|
469
|
+
|
|
470
|
+
{data.file && (
|
|
471
|
+
<div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
|
472
|
+
<h3 className="mb-2 text-sm font-bold text-black dark:text-white">File Location</h3>
|
|
473
|
+
<code className="font-mono text-sm text-gray-700 dark:text-gray-300">{data.file}</code>
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
{data.uses_components && data.uses_components.length > 0 && (
|
|
478
|
+
<div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
|
479
|
+
<h3 className="mb-2 text-sm font-bold text-black dark:text-white">Uses Components</h3>
|
|
480
|
+
<div className="flex flex-wrap gap-1">
|
|
481
|
+
{data.uses_components.map((comp) => (
|
|
482
|
+
<span
|
|
483
|
+
key={comp}
|
|
484
|
+
className="border border-gray-300 bg-gray-50 px-2 py-0.5 font-mono text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
|
485
|
+
>
|
|
486
|
+
{comp}
|
|
487
|
+
</span>
|
|
488
|
+
))}
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
|
|
493
|
+
<div className="border-2 border-black bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
|
494
|
+
<h3 className="mb-2 text-sm font-bold text-black dark:text-white">Powered by</h3>
|
|
495
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">
|
|
496
|
+
<a
|
|
497
|
+
href="https://sandpack.codesandbox.io/"
|
|
498
|
+
target="_blank"
|
|
499
|
+
rel="noopener noreferrer"
|
|
500
|
+
className="text-[#BA0C2F] hover:underline"
|
|
501
|
+
>
|
|
502
|
+
Sandpack
|
|
503
|
+
</a> by CodeSandbox - Edit the code live!
|
|
504
|
+
</p>
|
|
505
|
+
</div>
|
|
506
|
+
</div>
|
|
507
|
+
</div>
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Page Preview
|
|
513
|
+
*
|
|
514
|
+
* Renders the page in an iframe with responsive viewport controls.
|
|
515
|
+
* Checks if the route exists before rendering to avoid 404s.
|
|
516
|
+
*/
|
|
517
|
+
function PagePreview({ route }: { route: string }) {
|
|
518
|
+
const [viewport, setViewport] = useState<ViewportSize>('desktop');
|
|
519
|
+
const [routeStatus, setRouteStatus] = useState<'checking' | 'exists' | 'not-found'>('checking');
|
|
520
|
+
|
|
521
|
+
// Check if the route exists
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
const checkRoute = async () => {
|
|
524
|
+
try {
|
|
525
|
+
const res = await fetch(route, { method: 'HEAD' });
|
|
526
|
+
setRouteStatus(res.ok ? 'exists' : 'not-found');
|
|
527
|
+
} catch {
|
|
528
|
+
setRouteStatus('not-found');
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
checkRoute();
|
|
532
|
+
}, [route]);
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<div className="p-4">
|
|
536
|
+
{/* Responsive Size Controls */}
|
|
537
|
+
<div className="mb-4 flex justify-center gap-2">
|
|
538
|
+
<button
|
|
539
|
+
onClick={() => setViewport('desktop')}
|
|
540
|
+
className={`flex items-center gap-1.5 border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
541
|
+
viewport === 'desktop'
|
|
542
|
+
? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
|
|
543
|
+
: 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
|
544
|
+
}`}
|
|
545
|
+
>
|
|
546
|
+
<svg
|
|
547
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
548
|
+
width="16"
|
|
549
|
+
height="16"
|
|
550
|
+
viewBox="0 0 24 24"
|
|
551
|
+
fill="none"
|
|
552
|
+
stroke="currentColor"
|
|
553
|
+
strokeWidth="2"
|
|
554
|
+
strokeLinecap="round"
|
|
555
|
+
strokeLinejoin="round"
|
|
556
|
+
>
|
|
557
|
+
<rect width="20" height="14" x="2" y="3" rx="2" />
|
|
558
|
+
<line x1="8" x2="16" y1="21" y2="21" />
|
|
559
|
+
<line x1="12" x2="12" y1="17" y2="21" />
|
|
560
|
+
</svg>
|
|
561
|
+
Desktop
|
|
562
|
+
</button>
|
|
563
|
+
<button
|
|
564
|
+
onClick={() => setViewport('tablet')}
|
|
565
|
+
className={`flex items-center gap-1.5 border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
566
|
+
viewport === 'tablet'
|
|
567
|
+
? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
|
|
568
|
+
: 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
|
569
|
+
}`}
|
|
570
|
+
>
|
|
571
|
+
<svg
|
|
572
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
573
|
+
width="16"
|
|
574
|
+
height="16"
|
|
575
|
+
viewBox="0 0 24 24"
|
|
576
|
+
fill="none"
|
|
577
|
+
stroke="currentColor"
|
|
578
|
+
strokeWidth="2"
|
|
579
|
+
strokeLinecap="round"
|
|
580
|
+
strokeLinejoin="round"
|
|
581
|
+
>
|
|
582
|
+
<rect width="16" height="20" x="4" y="2" rx="2" />
|
|
583
|
+
<line x1="12" x2="12.01" y1="18" y2="18" />
|
|
584
|
+
</svg>
|
|
585
|
+
Tablet
|
|
586
|
+
</button>
|
|
587
|
+
<button
|
|
588
|
+
onClick={() => setViewport('mobile')}
|
|
589
|
+
className={`flex items-center gap-1.5 border-2 px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
590
|
+
viewport === 'mobile'
|
|
591
|
+
? 'border-[#BA0C2F] bg-[#BA0C2F] text-white'
|
|
592
|
+
: 'border-black bg-white text-black hover:border-[#BA0C2F] dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
|
593
|
+
}`}
|
|
594
|
+
>
|
|
595
|
+
<svg
|
|
596
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
597
|
+
width="16"
|
|
598
|
+
height="16"
|
|
599
|
+
viewBox="0 0 24 24"
|
|
600
|
+
fill="none"
|
|
601
|
+
stroke="currentColor"
|
|
602
|
+
strokeWidth="2"
|
|
603
|
+
strokeLinecap="round"
|
|
604
|
+
strokeLinejoin="round"
|
|
605
|
+
>
|
|
606
|
+
<rect width="14" height="20" x="5" y="2" rx="2" />
|
|
607
|
+
<line x1="12" x2="12.01" y1="18" y2="18" />
|
|
608
|
+
</svg>
|
|
609
|
+
Mobile
|
|
610
|
+
</button>
|
|
611
|
+
</div>
|
|
612
|
+
|
|
613
|
+
{/* Content Area */}
|
|
614
|
+
<div
|
|
615
|
+
className="mx-auto overflow-hidden border-2 border-black bg-white transition-all duration-300 dark:border-gray-700"
|
|
616
|
+
style={{ width: VIEWPORT_WIDTHS[viewport] }}
|
|
617
|
+
>
|
|
618
|
+
{routeStatus === 'checking' ? (
|
|
619
|
+
<div className="flex h-[500px] items-center justify-center bg-gray-50 dark:bg-gray-800">
|
|
620
|
+
<div className="text-center">
|
|
621
|
+
<div className="mx-auto mb-4 h-8 w-8 animate-spin border-4 border-gray-300 border-t-[#BA0C2F]" style={{ borderRadius: '50%' }} />
|
|
622
|
+
<p className="text-sm text-gray-600 dark:text-gray-400">Checking route...</p>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
) : routeStatus === 'not-found' ? (
|
|
626
|
+
<div className="flex h-[500px] items-center justify-center bg-gray-50 dark:bg-gray-800">
|
|
627
|
+
<div className="max-w-sm p-8 text-center">
|
|
628
|
+
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center border-2 border-black bg-gray-100 dark:border-gray-600 dark:bg-gray-700">
|
|
629
|
+
<svg
|
|
630
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
631
|
+
width="32"
|
|
632
|
+
height="32"
|
|
633
|
+
viewBox="0 0 24 24"
|
|
634
|
+
fill="none"
|
|
635
|
+
stroke="currentColor"
|
|
636
|
+
strokeWidth="2"
|
|
637
|
+
strokeLinecap="round"
|
|
638
|
+
strokeLinejoin="round"
|
|
639
|
+
className="text-gray-400"
|
|
640
|
+
>
|
|
641
|
+
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
|
|
642
|
+
<polyline points="14 2 14 8 20 8" />
|
|
643
|
+
<line x1="9" x2="15" y1="15" y2="15" />
|
|
644
|
+
</svg>
|
|
645
|
+
</div>
|
|
646
|
+
<h3 className="mb-2 font-bold text-black dark:text-white">Page Not Found</h3>
|
|
647
|
+
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
|
648
|
+
The route <code className="border border-gray-300 bg-gray-100 px-1 dark:border-gray-600 dark:bg-gray-700">{route}</code> doesn't exist yet.
|
|
649
|
+
</p>
|
|
650
|
+
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
651
|
+
Create the page at <code className="text-[#BA0C2F]">src/app{route}/page.tsx</code> to see the preview.
|
|
652
|
+
</p>
|
|
653
|
+
</div>
|
|
654
|
+
</div>
|
|
655
|
+
) : (
|
|
656
|
+
<iframe
|
|
657
|
+
src={route}
|
|
658
|
+
title="Page Preview"
|
|
659
|
+
className="h-[500px] w-full"
|
|
660
|
+
loading="lazy"
|
|
661
|
+
/>
|
|
662
|
+
)}
|
|
663
|
+
</div>
|
|
664
|
+
|
|
665
|
+
{/* Viewport Info */}
|
|
666
|
+
<p className="mt-4 text-center text-sm text-gray-600 dark:text-gray-400">
|
|
667
|
+
Viewport: {VIEWPORT_WIDTHS[viewport]} • Route: {route}
|
|
668
|
+
{routeStatus === 'not-found' && (
|
|
669
|
+
<span className="ml-2 border border-yellow-400 bg-yellow-50 px-2 py-0.5 text-xs text-yellow-700 dark:border-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400">
|
|
670
|
+
Route does not exist
|
|
671
|
+
</span>
|
|
672
|
+
)}
|
|
673
|
+
</p>
|
|
674
|
+
</div>
|
|
675
|
+
);
|
|
676
|
+
}
|