@codellyson/framely-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/commands/compositions.js +135 -0
- package/commands/preview.js +889 -0
- package/commands/render.js +295 -0
- package/commands/still.js +165 -0
- package/index.js +93 -0
- package/package.json +60 -0
- package/studio/App.css +605 -0
- package/studio/App.jsx +185 -0
- package/studio/CompositionsView.css +399 -0
- package/studio/CompositionsView.jsx +327 -0
- package/studio/PropsEditor.css +195 -0
- package/studio/PropsEditor.tsx +176 -0
- package/studio/RenderDialog.tsx +476 -0
- package/studio/ShareDialog.tsx +200 -0
- package/studio/index.ts +19 -0
- package/studio/player/Player.css +199 -0
- package/studio/player/Player.jsx +355 -0
- package/studio/styles/design-system.css +592 -0
- package/studio/styles/dialogs.css +420 -0
- package/studio/templates/AnimatedGradient.jsx +99 -0
- package/studio/templates/InstagramStory.jsx +172 -0
- package/studio/templates/LowerThird.jsx +139 -0
- package/studio/templates/ProductShowcase.jsx +162 -0
- package/studio/templates/SlideTransition.jsx +211 -0
- package/studio/templates/SocialIntro.jsx +122 -0
- package/studio/templates/SubscribeAnimation.jsx +186 -0
- package/studio/templates/TemplateCard.tsx +58 -0
- package/studio/templates/TemplateFilters.tsx +97 -0
- package/studio/templates/TemplatePreviewDialog.tsx +196 -0
- package/studio/templates/TemplatesMarketplace.css +686 -0
- package/studio/templates/TemplatesMarketplace.tsx +172 -0
- package/studio/templates/TextReveal.jsx +134 -0
- package/studio/templates/UseTemplateDialog.tsx +154 -0
- package/studio/templates/index.ts +45 -0
- package/utils/browser.js +188 -0
- package/utils/codecs.js +200 -0
- package/utils/logger.js +35 -0
- package/utils/props.js +42 -0
- package/utils/render.js +447 -0
- package/utils/validate.js +148 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { TimelineProvider, getCompositionTree } from '@codellyson/framely';
|
|
3
|
+
import { PlayerView } from './player/Player';
|
|
4
|
+
import { RenderDialog } from './RenderDialog';
|
|
5
|
+
import { ExportDialog } from './ShareDialog';
|
|
6
|
+
import { TemplatesMarketplace } from './templates';
|
|
7
|
+
import { PropsEditor } from './PropsEditor';
|
|
8
|
+
import './CompositionsView.css';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CompositionsView - Browse, preview, and export code-defined compositions.
|
|
12
|
+
* Includes a marketplace tab for discovering and using templates.
|
|
13
|
+
*/
|
|
14
|
+
export function CompositionsView({
|
|
15
|
+
compositions,
|
|
16
|
+
selectedId,
|
|
17
|
+
onSelectComposition,
|
|
18
|
+
onUseTemplate,
|
|
19
|
+
}) {
|
|
20
|
+
const [activeTab, setActiveTab] = useState('compositions');
|
|
21
|
+
const [renderDialogOpen, setRenderDialogOpen] = useState(false);
|
|
22
|
+
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
|
23
|
+
const [expandedFolders, setExpandedFolders] = useState(new Set(['Tests', 'Templates']));
|
|
24
|
+
const [customProps, setCustomProps] = useState({});
|
|
25
|
+
|
|
26
|
+
const _compositionTree = getCompositionTree(); // For future use with folder tree
|
|
27
|
+
const selectedComp = compositions[selectedId];
|
|
28
|
+
|
|
29
|
+
// Reset custom props when composition changes
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (selectedComp?.defaultProps) {
|
|
32
|
+
setCustomProps({ ...selectedComp.defaultProps });
|
|
33
|
+
} else {
|
|
34
|
+
setCustomProps({});
|
|
35
|
+
}
|
|
36
|
+
}, [selectedId, selectedComp?.defaultProps]);
|
|
37
|
+
|
|
38
|
+
// Current props = default props merged with custom edits
|
|
39
|
+
const currentProps = { ...(selectedComp?.defaultProps || {}), ...customProps };
|
|
40
|
+
|
|
41
|
+
// Toggle folder expansion
|
|
42
|
+
const toggleFolder = (folderName) => {
|
|
43
|
+
setExpandedFolders((prev) => {
|
|
44
|
+
const next = new Set(prev);
|
|
45
|
+
if (next.has(folderName)) {
|
|
46
|
+
next.delete(folderName);
|
|
47
|
+
} else {
|
|
48
|
+
next.add(folderName);
|
|
49
|
+
}
|
|
50
|
+
return next;
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Group compositions by folder
|
|
55
|
+
const groupedComps = {};
|
|
56
|
+
const rootComps = [];
|
|
57
|
+
|
|
58
|
+
Object.values(compositions).forEach((comp) => {
|
|
59
|
+
const folderPath = comp.folderPath || [];
|
|
60
|
+
if (folderPath.length > 0) {
|
|
61
|
+
const folder = folderPath[0];
|
|
62
|
+
if (!groupedComps[folder]) {
|
|
63
|
+
groupedComps[folder] = [];
|
|
64
|
+
}
|
|
65
|
+
groupedComps[folder].push(comp);
|
|
66
|
+
} else {
|
|
67
|
+
rootComps.push(comp);
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Handle using a template
|
|
72
|
+
const handleUseTemplate = (template, customId, customProps) => {
|
|
73
|
+
onUseTemplate?.(template, customId, customProps);
|
|
74
|
+
setActiveTab('compositions');
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div className="compositions-view">
|
|
79
|
+
{/* Sidebar */}
|
|
80
|
+
<aside className="compositions-sidebar">
|
|
81
|
+
{/* Tab Switcher */}
|
|
82
|
+
<div className="compositions-tabs">
|
|
83
|
+
<button
|
|
84
|
+
className={`compositions-tab ${activeTab === 'compositions' ? 'active' : ''}`}
|
|
85
|
+
onClick={() => setActiveTab('compositions')}
|
|
86
|
+
>
|
|
87
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
88
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
89
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
90
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
91
|
+
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
92
|
+
</svg>
|
|
93
|
+
Compositions
|
|
94
|
+
</button>
|
|
95
|
+
<button
|
|
96
|
+
className={`compositions-tab ${activeTab === 'marketplace' ? 'active' : ''}`}
|
|
97
|
+
onClick={() => setActiveTab('marketplace')}
|
|
98
|
+
>
|
|
99
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
100
|
+
<path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
|
101
|
+
<polyline points="9,22 9,12 15,12 15,22" />
|
|
102
|
+
</svg>
|
|
103
|
+
Marketplace
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{activeTab === 'compositions' && (
|
|
108
|
+
<>
|
|
109
|
+
<div className="compositions-sidebar-header">
|
|
110
|
+
<h3>My Compositions</h3>
|
|
111
|
+
<span className="compositions-count">{Object.keys(compositions).length}</span>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<div className="compositions-list">
|
|
115
|
+
{/* Root-level compositions */}
|
|
116
|
+
{rootComps.map((comp) => (
|
|
117
|
+
<button
|
|
118
|
+
key={comp.id}
|
|
119
|
+
className={`compositions-item ${comp.id === selectedId ? 'active' : ''}`}
|
|
120
|
+
onClick={() => onSelectComposition(comp.id)}
|
|
121
|
+
>
|
|
122
|
+
<span className="compositions-item-icon">🎬</span>
|
|
123
|
+
<div className="compositions-item-info">
|
|
124
|
+
<div className="compositions-item-name">{comp.id}</div>
|
|
125
|
+
<div className="compositions-item-meta">
|
|
126
|
+
{comp.width}×{comp.height} • {comp.fps}fps
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</button>
|
|
130
|
+
))}
|
|
131
|
+
|
|
132
|
+
{/* Folders */}
|
|
133
|
+
{Object.entries(groupedComps).map(([folder, comps]) => (
|
|
134
|
+
<div key={folder} className="compositions-folder">
|
|
135
|
+
<button
|
|
136
|
+
className="compositions-folder-header"
|
|
137
|
+
onClick={() => toggleFolder(folder)}
|
|
138
|
+
>
|
|
139
|
+
<span className="compositions-folder-icon">
|
|
140
|
+
{expandedFolders.has(folder) ? '▼' : '▶'}
|
|
141
|
+
</span>
|
|
142
|
+
<span className="compositions-folder-name">{folder}</span>
|
|
143
|
+
<span className="compositions-folder-count">{comps.length}</span>
|
|
144
|
+
</button>
|
|
145
|
+
{expandedFolders.has(folder) && (
|
|
146
|
+
<div className="compositions-folder-items">
|
|
147
|
+
{comps.map((comp) => (
|
|
148
|
+
<button
|
|
149
|
+
key={comp.id}
|
|
150
|
+
className={`compositions-item ${comp.id === selectedId ? 'active' : ''}`}
|
|
151
|
+
onClick={() => onSelectComposition(comp.id)}
|
|
152
|
+
>
|
|
153
|
+
<span className="compositions-item-icon">🎬</span>
|
|
154
|
+
<div className="compositions-item-info">
|
|
155
|
+
<div className="compositions-item-name">{comp.id}</div>
|
|
156
|
+
<div className="compositions-item-meta">
|
|
157
|
+
{comp.width}×{comp.height} • {comp.fps}fps
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</button>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
</>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{activeTab === 'marketplace' && (
|
|
171
|
+
<div className="compositions-sidebar-marketplace">
|
|
172
|
+
<p>Browse templates in the main panel</p>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</aside>
|
|
176
|
+
|
|
177
|
+
{/* Main - Preview or Marketplace */}
|
|
178
|
+
<main className="compositions-main">
|
|
179
|
+
{activeTab === 'marketplace' ? (
|
|
180
|
+
<TemplatesMarketplace onUseTemplate={handleUseTemplate} />
|
|
181
|
+
) : selectedComp ? (
|
|
182
|
+
selectedComp.component ? (
|
|
183
|
+
<TimelineProvider
|
|
184
|
+
fps={selectedComp.fps}
|
|
185
|
+
width={selectedComp.width}
|
|
186
|
+
height={selectedComp.height}
|
|
187
|
+
durationInFrames={selectedComp.durationInFrames}
|
|
188
|
+
>
|
|
189
|
+
<PlayerView
|
|
190
|
+
component={selectedComp.component}
|
|
191
|
+
compositionWidth={selectedComp.width}
|
|
192
|
+
compositionHeight={selectedComp.height}
|
|
193
|
+
inputProps={currentProps}
|
|
194
|
+
/>
|
|
195
|
+
</TimelineProvider>
|
|
196
|
+
) : (
|
|
197
|
+
<div className="compositions-template-placeholder">
|
|
198
|
+
<div className="template-placeholder-content">
|
|
199
|
+
<div className="template-placeholder-icon">📦</div>
|
|
200
|
+
<h3>{selectedComp._templateName || selectedComp.id}</h3>
|
|
201
|
+
<p>Template from marketplace</p>
|
|
202
|
+
<div className="template-placeholder-info">
|
|
203
|
+
<span>{selectedComp.width}×{selectedComp.height}</span>
|
|
204
|
+
<span>{selectedComp.fps} fps</span>
|
|
205
|
+
<span>{(selectedComp.durationInFrames / selectedComp.fps).toFixed(1)}s</span>
|
|
206
|
+
</div>
|
|
207
|
+
<p className="template-placeholder-note">
|
|
208
|
+
Remote template loading coming soon
|
|
209
|
+
</p>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
) : (
|
|
214
|
+
<div className="compositions-empty">
|
|
215
|
+
<p>Select a composition to preview</p>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
</main>
|
|
219
|
+
|
|
220
|
+
{/* Info Panel */}
|
|
221
|
+
<aside className="compositions-info">
|
|
222
|
+
{selectedComp ? (
|
|
223
|
+
<>
|
|
224
|
+
<div className="compositions-info-header">
|
|
225
|
+
<h2>{selectedComp.id}</h2>
|
|
226
|
+
{selectedComp.folderPath?.length > 0 && (
|
|
227
|
+
<span className="compositions-info-folder">
|
|
228
|
+
{selectedComp.folderPath.join(' / ')}
|
|
229
|
+
</span>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="compositions-info-section">
|
|
234
|
+
<h4>Dimensions</h4>
|
|
235
|
+
<div className="compositions-info-grid">
|
|
236
|
+
<div className="compositions-info-item">
|
|
237
|
+
<span className="label">Width</span>
|
|
238
|
+
<span className="value">{selectedComp.width}px</span>
|
|
239
|
+
</div>
|
|
240
|
+
<div className="compositions-info-item">
|
|
241
|
+
<span className="label">Height</span>
|
|
242
|
+
<span className="value">{selectedComp.height}px</span>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<div className="compositions-info-section">
|
|
248
|
+
<h4>Timing</h4>
|
|
249
|
+
<div className="compositions-info-grid">
|
|
250
|
+
<div className="compositions-info-item">
|
|
251
|
+
<span className="label">FPS</span>
|
|
252
|
+
<span className="value">{selectedComp.fps}</span>
|
|
253
|
+
</div>
|
|
254
|
+
<div className="compositions-info-item">
|
|
255
|
+
<span className="label">Frames</span>
|
|
256
|
+
<span className="value">{selectedComp.durationInFrames}</span>
|
|
257
|
+
</div>
|
|
258
|
+
<div className="compositions-info-item full-width">
|
|
259
|
+
<span className="label">Duration</span>
|
|
260
|
+
<span className="value">
|
|
261
|
+
{(selectedComp.durationInFrames / selectedComp.fps).toFixed(2)}s
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
{/* Props Editor - Customize template properties */}
|
|
268
|
+
{Object.keys(selectedComp.defaultProps || {}).length > 0 && (
|
|
269
|
+
<div className="compositions-info-section">
|
|
270
|
+
<h4>Customize</h4>
|
|
271
|
+
<PropsEditor
|
|
272
|
+
defaultProps={selectedComp.defaultProps}
|
|
273
|
+
onChange={setCustomProps}
|
|
274
|
+
/>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
<div className="compositions-info-actions">
|
|
279
|
+
<button
|
|
280
|
+
className="compositions-action-btn primary"
|
|
281
|
+
onClick={() => setRenderDialogOpen(true)}
|
|
282
|
+
>
|
|
283
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
|
284
|
+
<path d="M8 1v6h6M8 1L2 7l6 6V7" />
|
|
285
|
+
</svg>
|
|
286
|
+
Render Video
|
|
287
|
+
</button>
|
|
288
|
+
<button
|
|
289
|
+
className="compositions-action-btn"
|
|
290
|
+
onClick={() => setExportDialogOpen(true)}
|
|
291
|
+
>
|
|
292
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
293
|
+
<path d="M8 2v8M5 7l3 3 3-3M3 12v2h10v-2" />
|
|
294
|
+
</svg>
|
|
295
|
+
Export
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
</>
|
|
299
|
+
) : (
|
|
300
|
+
<div className="compositions-info-empty">
|
|
301
|
+
<p>No composition selected</p>
|
|
302
|
+
</div>
|
|
303
|
+
)}
|
|
304
|
+
</aside>
|
|
305
|
+
|
|
306
|
+
{/* Dialogs */}
|
|
307
|
+
{selectedComp && (
|
|
308
|
+
<>
|
|
309
|
+
<RenderDialog
|
|
310
|
+
open={renderDialogOpen}
|
|
311
|
+
onClose={() => setRenderDialogOpen(false)}
|
|
312
|
+
composition={selectedComp}
|
|
313
|
+
inputProps={currentProps}
|
|
314
|
+
/>
|
|
315
|
+
<ExportDialog
|
|
316
|
+
open={exportDialogOpen}
|
|
317
|
+
onClose={() => setExportDialogOpen(false)}
|
|
318
|
+
composition={selectedComp}
|
|
319
|
+
inputProps={currentProps}
|
|
320
|
+
/>
|
|
321
|
+
</>
|
|
322
|
+
)}
|
|
323
|
+
</div>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export default CompositionsView;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
.props-editor {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: var(--space-4, 16px);
|
|
5
|
+
font-family: var(--font-sans, 'Inter', system-ui, sans-serif);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.props-editor-empty {
|
|
9
|
+
color: var(--zinc-500, #71717a);
|
|
10
|
+
font-size: var(--text-sm, 13px);
|
|
11
|
+
text-align: center;
|
|
12
|
+
padding: var(--space-5, 20px);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.props-editor-field {
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
gap: var(--space-1-5, 6px);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.props-editor-label {
|
|
22
|
+
font-size: var(--text-xs, 12px);
|
|
23
|
+
font-weight: 500;
|
|
24
|
+
color: var(--zinc-400, #a1a1aa);
|
|
25
|
+
text-transform: uppercase;
|
|
26
|
+
letter-spacing: 0.05em;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Text Input */
|
|
30
|
+
.props-editor-text,
|
|
31
|
+
.props-editor-number {
|
|
32
|
+
width: 100%;
|
|
33
|
+
padding: var(--space-2-5, 10px) var(--space-3, 12px);
|
|
34
|
+
font-size: var(--text-sm, 14px);
|
|
35
|
+
font-family: var(--font-sans, 'Inter', system-ui, sans-serif);
|
|
36
|
+
color: var(--zinc-100, #f4f4f5);
|
|
37
|
+
background: var(--zinc-800, #27272a);
|
|
38
|
+
border: 1px solid var(--zinc-700, #3f3f46);
|
|
39
|
+
border-radius: var(--radius-md, 6px);
|
|
40
|
+
outline: none;
|
|
41
|
+
transition: border-color var(--transition-fast, 0.2s), box-shadow var(--transition-fast, 0.2s);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.props-editor-text:focus,
|
|
45
|
+
.props-editor-number:focus {
|
|
46
|
+
border-color: var(--indigo-500, #6366f1);
|
|
47
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Textarea */
|
|
51
|
+
.props-editor-textarea {
|
|
52
|
+
width: 100%;
|
|
53
|
+
padding: var(--space-2-5, 10px) var(--space-3, 12px);
|
|
54
|
+
font-size: var(--text-sm, 14px);
|
|
55
|
+
font-family: var(--font-sans, 'Inter', system-ui, sans-serif);
|
|
56
|
+
color: var(--zinc-100, #f4f4f5);
|
|
57
|
+
background: var(--zinc-800, #27272a);
|
|
58
|
+
border: 1px solid var(--zinc-700, #3f3f46);
|
|
59
|
+
border-radius: var(--radius-md, 6px);
|
|
60
|
+
outline: none;
|
|
61
|
+
resize: vertical;
|
|
62
|
+
min-height: 60px;
|
|
63
|
+
transition: border-color var(--transition-fast, 0.2s), box-shadow var(--transition-fast, 0.2s);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.props-editor-textarea:focus {
|
|
67
|
+
border-color: var(--indigo-500, #6366f1);
|
|
68
|
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* Color Input */
|
|
72
|
+
.props-editor-color-input {
|
|
73
|
+
display: flex;
|
|
74
|
+
gap: var(--space-2, 8px);
|
|
75
|
+
align-items: center;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.props-editor-color-input input[type="color"] {
|
|
79
|
+
width: 40px;
|
|
80
|
+
height: 40px;
|
|
81
|
+
padding: 0;
|
|
82
|
+
border: none;
|
|
83
|
+
border-radius: var(--radius-md, 6px);
|
|
84
|
+
cursor: pointer;
|
|
85
|
+
background: none;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.props-editor-color-input input[type="color"]::-webkit-color-swatch-wrapper {
|
|
89
|
+
padding: 2px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.props-editor-color-input input[type="color"]::-webkit-color-swatch {
|
|
93
|
+
border: 2px solid var(--zinc-700, #3f3f46);
|
|
94
|
+
border-radius: var(--radius-sm, 4px);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.props-editor-color-input input[type="text"] {
|
|
98
|
+
flex: 1;
|
|
99
|
+
padding: var(--space-2-5, 10px) var(--space-3, 12px);
|
|
100
|
+
font-size: var(--text-sm, 14px);
|
|
101
|
+
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
|
102
|
+
color: var(--zinc-100, #f4f4f5);
|
|
103
|
+
background: var(--zinc-800, #27272a);
|
|
104
|
+
border: 1px solid var(--zinc-700, #3f3f46);
|
|
105
|
+
border-radius: var(--radius-md, 6px);
|
|
106
|
+
outline: none;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.props-editor-color-input input[type="text"]:focus {
|
|
110
|
+
border-color: var(--indigo-500, #6366f1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Color Array */
|
|
114
|
+
.props-editor-color-array {
|
|
115
|
+
display: flex;
|
|
116
|
+
gap: var(--space-2, 8px);
|
|
117
|
+
flex-wrap: wrap;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
.props-editor-color-array input[type="color"] {
|
|
121
|
+
width: 36px;
|
|
122
|
+
height: 36px;
|
|
123
|
+
padding: 0;
|
|
124
|
+
border: none;
|
|
125
|
+
border-radius: var(--radius-md, 6px);
|
|
126
|
+
cursor: pointer;
|
|
127
|
+
background: none;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.props-editor-color-array input[type="color"]::-webkit-color-swatch-wrapper {
|
|
131
|
+
padding: 2px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.props-editor-color-array input[type="color"]::-webkit-color-swatch {
|
|
135
|
+
border: 2px solid var(--zinc-700, #3f3f46);
|
|
136
|
+
border-radius: var(--radius-sm, 4px);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* Toggle Switch */
|
|
140
|
+
.props-editor-toggle {
|
|
141
|
+
position: relative;
|
|
142
|
+
display: inline-block;
|
|
143
|
+
width: 48px;
|
|
144
|
+
height: 26px;
|
|
145
|
+
cursor: pointer;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.props-editor-toggle input {
|
|
149
|
+
opacity: 0;
|
|
150
|
+
width: 0;
|
|
151
|
+
height: 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.props-editor-toggle-slider {
|
|
155
|
+
position: absolute;
|
|
156
|
+
inset: 0;
|
|
157
|
+
background: var(--zinc-800, #27272a);
|
|
158
|
+
border: 1px solid var(--zinc-700, #3f3f46);
|
|
159
|
+
border-radius: 26px;
|
|
160
|
+
transition: var(--transition-fast, 0.2s);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.props-editor-toggle-slider::before {
|
|
164
|
+
content: '';
|
|
165
|
+
position: absolute;
|
|
166
|
+
height: 18px;
|
|
167
|
+
width: 18px;
|
|
168
|
+
left: 3px;
|
|
169
|
+
bottom: 3px;
|
|
170
|
+
background: var(--zinc-500, #71717a);
|
|
171
|
+
border-radius: 50%;
|
|
172
|
+
transition: var(--transition-fast, 0.2s);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.props-editor-toggle input:checked + .props-editor-toggle-slider {
|
|
176
|
+
background: var(--indigo-500, #6366f1);
|
|
177
|
+
border-color: var(--indigo-500, #6366f1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.props-editor-toggle input:checked + .props-editor-toggle-slider::before {
|
|
181
|
+
transform: translateX(22px);
|
|
182
|
+
background: #fff;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/* Number Input Specific */
|
|
186
|
+
.props-editor-number {
|
|
187
|
+
font-family: var(--font-mono, 'JetBrains Mono', monospace);
|
|
188
|
+
-moz-appearance: textfield;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.props-editor-number::-webkit-outer-spin-button,
|
|
192
|
+
.props-editor-number::-webkit-inner-spin-button {
|
|
193
|
+
-webkit-appearance: none;
|
|
194
|
+
margin: 0;
|
|
195
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import './PropsEditor.css';
|
|
3
|
+
|
|
4
|
+
interface PropsEditorProps {
|
|
5
|
+
defaultProps: Record<string, unknown>;
|
|
6
|
+
onChange: (props: Record<string, unknown>) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* PropsEditor - Visual editor for composition props
|
|
11
|
+
* Auto-detects prop types and renders appropriate inputs
|
|
12
|
+
*/
|
|
13
|
+
export function PropsEditor({ defaultProps, onChange }: PropsEditorProps) {
|
|
14
|
+
const [props, setProps] = useState<Record<string, unknown>>({ ...defaultProps });
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
setProps({ ...defaultProps });
|
|
18
|
+
}, [defaultProps]);
|
|
19
|
+
|
|
20
|
+
const handleChange = (key: string, value: unknown) => {
|
|
21
|
+
const newProps = { ...props, [key]: value };
|
|
22
|
+
setProps(newProps);
|
|
23
|
+
onChange(newProps);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const detectType = (key: string, value: unknown): string => {
|
|
27
|
+
// Check by key name patterns
|
|
28
|
+
const keyLower = key.toLowerCase();
|
|
29
|
+
if (keyLower.includes('color') || keyLower.includes('background')) return 'color';
|
|
30
|
+
if (keyLower.includes('show') || keyLower.includes('enable') || keyLower.includes('is')) return 'boolean';
|
|
31
|
+
|
|
32
|
+
// Check by value type
|
|
33
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
34
|
+
if (typeof value === 'number') return 'number';
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
// Check if it's a color hex
|
|
37
|
+
if (/^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value)) return 'color';
|
|
38
|
+
// Check if it's a long text
|
|
39
|
+
if (value.length > 50) return 'textarea';
|
|
40
|
+
return 'text';
|
|
41
|
+
}
|
|
42
|
+
if (Array.isArray(value)) return 'array';
|
|
43
|
+
|
|
44
|
+
return 'text';
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const formatLabel = (key: string): string => {
|
|
48
|
+
return key
|
|
49
|
+
.replace(/([A-Z])/g, ' $1')
|
|
50
|
+
.replace(/^./, (str) => str.toUpperCase())
|
|
51
|
+
.trim();
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderInput = (key: string, value: unknown) => {
|
|
55
|
+
const type = detectType(key, value);
|
|
56
|
+
|
|
57
|
+
switch (type) {
|
|
58
|
+
case 'color':
|
|
59
|
+
return (
|
|
60
|
+
<div className="props-editor-color-input">
|
|
61
|
+
<input
|
|
62
|
+
type="color"
|
|
63
|
+
value={String(value)}
|
|
64
|
+
onChange={(e) => handleChange(key, e.target.value)}
|
|
65
|
+
/>
|
|
66
|
+
<input
|
|
67
|
+
type="text"
|
|
68
|
+
value={String(value)}
|
|
69
|
+
onChange={(e) => handleChange(key, e.target.value)}
|
|
70
|
+
placeholder="#000000"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
case 'boolean':
|
|
76
|
+
return (
|
|
77
|
+
<label className="props-editor-toggle">
|
|
78
|
+
<input
|
|
79
|
+
type="checkbox"
|
|
80
|
+
checked={Boolean(value)}
|
|
81
|
+
onChange={(e) => handleChange(key, e.target.checked)}
|
|
82
|
+
/>
|
|
83
|
+
<span className="props-editor-toggle-slider" />
|
|
84
|
+
</label>
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
case 'number':
|
|
88
|
+
return (
|
|
89
|
+
<input
|
|
90
|
+
type="number"
|
|
91
|
+
value={Number(value)}
|
|
92
|
+
onChange={(e) => handleChange(key, parseFloat(e.target.value) || 0)}
|
|
93
|
+
className="props-editor-number"
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
case 'textarea':
|
|
98
|
+
return (
|
|
99
|
+
<textarea
|
|
100
|
+
value={String(value)}
|
|
101
|
+
onChange={(e) => handleChange(key, e.target.value)}
|
|
102
|
+
className="props-editor-textarea"
|
|
103
|
+
rows={3}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
case 'array':
|
|
108
|
+
// For color arrays, show multiple color pickers
|
|
109
|
+
if (Array.isArray(value) && value.every((v) => typeof v === 'string' && v.startsWith('#'))) {
|
|
110
|
+
return (
|
|
111
|
+
<div className="props-editor-color-array">
|
|
112
|
+
{(value as string[]).map((color, i) => (
|
|
113
|
+
<input
|
|
114
|
+
key={i}
|
|
115
|
+
type="color"
|
|
116
|
+
value={color}
|
|
117
|
+
onChange={(e) => {
|
|
118
|
+
const newArray = [...(value as string[])];
|
|
119
|
+
newArray[i] = e.target.value;
|
|
120
|
+
handleChange(key, newArray);
|
|
121
|
+
}}
|
|
122
|
+
/>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return (
|
|
128
|
+
<input
|
|
129
|
+
type="text"
|
|
130
|
+
value={JSON.stringify(value)}
|
|
131
|
+
onChange={(e) => {
|
|
132
|
+
try {
|
|
133
|
+
handleChange(key, JSON.parse(e.target.value));
|
|
134
|
+
} catch {
|
|
135
|
+
// Invalid JSON, ignore
|
|
136
|
+
}
|
|
137
|
+
}}
|
|
138
|
+
className="props-editor-text"
|
|
139
|
+
/>
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
default:
|
|
143
|
+
return (
|
|
144
|
+
<input
|
|
145
|
+
type="text"
|
|
146
|
+
value={String(value)}
|
|
147
|
+
onChange={(e) => handleChange(key, e.target.value)}
|
|
148
|
+
className="props-editor-text"
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const entries = Object.entries(props);
|
|
155
|
+
|
|
156
|
+
if (entries.length === 0) {
|
|
157
|
+
return (
|
|
158
|
+
<div className="props-editor-empty">
|
|
159
|
+
<p>No customizable properties</p>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="props-editor">
|
|
166
|
+
{entries.map(([key, value]) => (
|
|
167
|
+
<div key={key} className="props-editor-field">
|
|
168
|
+
<label className="props-editor-label">{formatLabel(key)}</label>
|
|
169
|
+
{renderInput(key, value)}
|
|
170
|
+
</div>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export default PropsEditor;
|