@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.
Files changed (40) hide show
  1. package/commands/compositions.js +135 -0
  2. package/commands/preview.js +889 -0
  3. package/commands/render.js +295 -0
  4. package/commands/still.js +165 -0
  5. package/index.js +93 -0
  6. package/package.json +60 -0
  7. package/studio/App.css +605 -0
  8. package/studio/App.jsx +185 -0
  9. package/studio/CompositionsView.css +399 -0
  10. package/studio/CompositionsView.jsx +327 -0
  11. package/studio/PropsEditor.css +195 -0
  12. package/studio/PropsEditor.tsx +176 -0
  13. package/studio/RenderDialog.tsx +476 -0
  14. package/studio/ShareDialog.tsx +200 -0
  15. package/studio/index.ts +19 -0
  16. package/studio/player/Player.css +199 -0
  17. package/studio/player/Player.jsx +355 -0
  18. package/studio/styles/design-system.css +592 -0
  19. package/studio/styles/dialogs.css +420 -0
  20. package/studio/templates/AnimatedGradient.jsx +99 -0
  21. package/studio/templates/InstagramStory.jsx +172 -0
  22. package/studio/templates/LowerThird.jsx +139 -0
  23. package/studio/templates/ProductShowcase.jsx +162 -0
  24. package/studio/templates/SlideTransition.jsx +211 -0
  25. package/studio/templates/SocialIntro.jsx +122 -0
  26. package/studio/templates/SubscribeAnimation.jsx +186 -0
  27. package/studio/templates/TemplateCard.tsx +58 -0
  28. package/studio/templates/TemplateFilters.tsx +97 -0
  29. package/studio/templates/TemplatePreviewDialog.tsx +196 -0
  30. package/studio/templates/TemplatesMarketplace.css +686 -0
  31. package/studio/templates/TemplatesMarketplace.tsx +172 -0
  32. package/studio/templates/TextReveal.jsx +134 -0
  33. package/studio/templates/UseTemplateDialog.tsx +154 -0
  34. package/studio/templates/index.ts +45 -0
  35. package/utils/browser.js +188 -0
  36. package/utils/codecs.js +200 -0
  37. package/utils/logger.js +35 -0
  38. package/utils/props.js +42 -0
  39. package/utils/render.js +447 -0
  40. 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;