@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,476 @@
1
+ /**
2
+ * RenderDialog Component
3
+ *
4
+ * Modal dialog for configuring and starting video renders.
5
+ * Supports codec selection, quality settings, and progress tracking.
6
+ */
7
+
8
+ import { useState, useCallback, useEffect, useRef, type MouseEvent, type ChangeEvent, type ReactElement } from 'react';
9
+ import './styles/dialogs.css';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types & Interfaces
13
+ // ---------------------------------------------------------------------------
14
+
15
+ interface Codec {
16
+ id: string;
17
+ name: string;
18
+ ext: string;
19
+ description: string;
20
+ }
21
+
22
+ interface QualityPreset {
23
+ id: string;
24
+ name: string;
25
+ crf: number;
26
+ description: string;
27
+ }
28
+
29
+ export interface Composition {
30
+ id: string;
31
+ width: number;
32
+ height: number;
33
+ fps: number;
34
+ durationInFrames: number;
35
+ _templateId?: string;
36
+ _isTemplate?: boolean;
37
+ }
38
+
39
+ interface RenderProgressEvent {
40
+ type: 'progress';
41
+ percent: number;
42
+ frame: number;
43
+ total: number;
44
+ }
45
+
46
+ interface RenderStatusEvent {
47
+ type: 'status';
48
+ message: string;
49
+ }
50
+
51
+ interface RenderCompleteEvent {
52
+ type: 'complete';
53
+ durationMs?: number;
54
+ downloadUrl?: string;
55
+ }
56
+
57
+ interface RenderErrorEvent {
58
+ type: 'error';
59
+ message: string;
60
+ }
61
+
62
+ type RenderStreamEvent =
63
+ | RenderProgressEvent
64
+ | RenderStatusEvent
65
+ | RenderCompleteEvent
66
+ | RenderErrorEvent;
67
+
68
+ interface RenderResult {
69
+ durationMs?: number;
70
+ downloadUrl?: string;
71
+ }
72
+
73
+ export interface RenderDialogProps {
74
+ open: boolean;
75
+ onClose: () => void;
76
+ composition: Composition | null;
77
+ inputProps?: Record<string, unknown>;
78
+ onRender?: (event: RenderCompleteEvent) => void;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Constants
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const CODECS: Codec[] = [
86
+ { id: 'h264', name: 'H.264 (MP4)', ext: 'mp4', description: 'Most compatible, good quality' },
87
+ { id: 'h265', name: 'H.265 (HEVC)', ext: 'mp4', description: 'Better compression, less compatible' },
88
+ { id: 'vp9', name: 'VP9 (WebM)', ext: 'webm', description: 'Good for web' },
89
+ { id: 'prores', name: 'ProRes (MOV)', ext: 'mov', description: 'Professional editing' },
90
+ { id: 'gif', name: 'GIF', ext: 'gif', description: 'Animated image, limited colors' },
91
+ ];
92
+
93
+ const QUALITY_PRESETS: QualityPreset[] = [
94
+ { id: 'low', name: 'Low', crf: 28, description: 'Smaller file, lower quality' },
95
+ { id: 'medium', name: 'Medium', crf: 23, description: 'Balanced' },
96
+ { id: 'high', name: 'High', crf: 18, description: 'Recommended' },
97
+ { id: 'lossless', name: 'Lossless', crf: 0, description: 'Best quality, large file' },
98
+ ];
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Component
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export function RenderDialog({
105
+ open,
106
+ onClose,
107
+ composition,
108
+ inputProps = {},
109
+ onRender,
110
+ }: RenderDialogProps): ReactElement | null {
111
+ const [codec, setCodec] = useState<string>('h264');
112
+ const [quality, setQuality] = useState<string>('high');
113
+ const [customCrf, _setCustomCrf] = useState<number>(18);
114
+ const [scale, setScale] = useState<number>(1);
115
+ const [startFrame, setStartFrame] = useState<number>(0);
116
+ const [endFrame, setEndFrame] = useState<number>(composition?.durationInFrames ? composition.durationInFrames - 1 : 299);
117
+ const [muted, setMuted] = useState<boolean>(false);
118
+ const [parallel, setParallel] = useState<boolean>(false);
119
+ const [_concurrency, _setConcurrency] = useState<number>(4);
120
+
121
+ const [rendering, setRendering] = useState<boolean>(false);
122
+ const [progress, setProgress] = useState<number>(0);
123
+ const [statusMessage, setStatusMessage] = useState<string>('');
124
+ const [error, setError] = useState<string | null>(null);
125
+ const [result, setResult] = useState<RenderResult | null>(null);
126
+
127
+ useEffect(() => {
128
+ if (composition?.durationInFrames) {
129
+ setEndFrame(composition.durationInFrames - 1);
130
+ }
131
+ }, [composition?.durationInFrames]);
132
+
133
+ const getCrf = (): number => {
134
+ if (quality === 'custom') return customCrf;
135
+ const preset = QUALITY_PRESETS.find((p) => p.id === quality);
136
+ return preset ? preset.crf : 18;
137
+ };
138
+
139
+ const outputWidth: number = Math.round((composition?.width || 1920) * scale);
140
+ const outputHeight: number = Math.round((composition?.height || 1080) * scale);
141
+ const frameCount: number = endFrame - startFrame + 1;
142
+ const duration: number = frameCount / (composition?.fps || 30);
143
+
144
+ const handleRender = useCallback(async (): Promise<void> => {
145
+ if (!composition) return;
146
+
147
+ setRendering(true);
148
+ setProgress(0);
149
+ setStatusMessage('Starting render...');
150
+ setError(null);
151
+ setResult(null);
152
+
153
+ try {
154
+ const response: Response = await fetch('/api/render', {
155
+ method: 'POST',
156
+ headers: { 'Content-Type': 'application/json' },
157
+ body: JSON.stringify({
158
+ compositionId: composition.id,
159
+ templateId: composition._templateId,
160
+ isTemplate: composition._isTemplate,
161
+ width: composition.width,
162
+ height: composition.height,
163
+ fps: composition.fps,
164
+ durationInFrames: composition.durationInFrames,
165
+ startFrame,
166
+ endFrame,
167
+ codec,
168
+ crf: getCrf(),
169
+ scale,
170
+ inputProps,
171
+ muted,
172
+ }),
173
+ });
174
+
175
+ if (!response.ok) {
176
+ throw new Error('Render request failed: ' + response.statusText);
177
+ }
178
+
179
+ const reader: ReadableStreamDefaultReader<Uint8Array> = response.body!.getReader();
180
+ const decoder = new TextDecoder();
181
+ let buffer = '';
182
+
183
+ while (true) {
184
+ const { done, value } = await reader.read();
185
+ if (done) break;
186
+
187
+ buffer += decoder.decode(value, { stream: true });
188
+ const lines: string[] = buffer.split('\n');
189
+ buffer = lines.pop()!;
190
+
191
+ for (const line of lines) {
192
+ if (!line.trim()) continue;
193
+ try {
194
+ const event: RenderStreamEvent = JSON.parse(line);
195
+ if (event.type === 'progress') {
196
+ setProgress(event.percent);
197
+ setStatusMessage(`Frame ${event.frame}/${event.total}`);
198
+ } else if (event.type === 'status') {
199
+ setStatusMessage(event.message);
200
+ } else if (event.type === 'complete') {
201
+ setProgress(100);
202
+ setResult(event);
203
+ if (onRender) onRender(event);
204
+ } else if (event.type === 'error') {
205
+ throw new Error(event.message);
206
+ }
207
+ } catch (parseErr) {
208
+ if (parseErr instanceof Error && parseErr.message !== 'Invalid JSON') throw parseErr;
209
+ }
210
+ }
211
+ }
212
+ } catch (err: unknown) {
213
+ setError(err instanceof Error ? err.message : String(err));
214
+ } finally {
215
+ setRendering(false);
216
+ setStatusMessage('');
217
+ }
218
+ }, [
219
+ composition, codec, quality, customCrf, scale,
220
+ startFrame, endFrame, muted, inputProps, onRender,
221
+ ]);
222
+
223
+ const dialogRef = useRef<HTMLDivElement>(null);
224
+
225
+ useEffect(() => {
226
+ if (!open) return;
227
+ const handleKeyDown = (e: globalThis.KeyboardEvent): void => {
228
+ if (e.key === 'Escape' && !rendering) onClose?.();
229
+ };
230
+ document.addEventListener('keydown', handleKeyDown);
231
+ return () => document.removeEventListener('keydown', handleKeyDown);
232
+ }, [open, rendering, onClose]);
233
+
234
+ useEffect(() => {
235
+ if (!open || !dialogRef.current) return;
236
+ const dialog: HTMLDivElement = dialogRef.current;
237
+ const focusable: NodeListOf<HTMLElement> = dialog.querySelectorAll(
238
+ 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
239
+ );
240
+ if (focusable.length > 0) focusable[0].focus();
241
+
242
+ const handleTab = (e: globalThis.KeyboardEvent): void => {
243
+ if (e.key !== 'Tab' || focusable.length === 0) return;
244
+ const first: HTMLElement = focusable[0];
245
+ const last: HTMLElement = focusable[focusable.length - 1];
246
+ if (e.shiftKey && document.activeElement === first) {
247
+ e.preventDefault();
248
+ last.focus();
249
+ } else if (!e.shiftKey && document.activeElement === last) {
250
+ e.preventDefault();
251
+ first.focus();
252
+ }
253
+ };
254
+ document.addEventListener('keydown', handleTab);
255
+ return () => document.removeEventListener('keydown', handleTab);
256
+ }, [open]);
257
+
258
+ if (!open) return null;
259
+
260
+ const selectedCodec: Codec | undefined = CODECS.find((c) => c.id === codec);
261
+
262
+ return (
263
+ <div
264
+ className="dialog-overlay"
265
+ role="dialog"
266
+ aria-modal="true"
267
+ aria-labelledby="render-dialog-title"
268
+ onClick={(e: MouseEvent<HTMLDivElement>) => {
269
+ if (e.target === e.currentTarget && !rendering) onClose?.();
270
+ }}
271
+ >
272
+ <div ref={dialogRef} className="dialog-panel">
273
+ {/* Header */}
274
+ <div className="dialog-header">
275
+ <h2 id="render-dialog-title" className="dialog-title">
276
+ Render Video
277
+ </h2>
278
+ <button
279
+ className="dialog-close-btn"
280
+ onClick={onClose}
281
+ disabled={rendering}
282
+ aria-label="Close render dialog"
283
+ >
284
+ ×
285
+ </button>
286
+ </div>
287
+
288
+ {/* Content */}
289
+ <div className="dialog-body">
290
+ {/* Composition info */}
291
+ <div className="dialog-info-card">
292
+ <div className="dialog-info-card-title">
293
+ {composition?.id || 'Unknown'}
294
+ </div>
295
+ <div className="dialog-info-card-subtitle">
296
+ {composition?.width}x{composition?.height} @ {composition?.fps}fps · {composition?.durationInFrames} frames
297
+ </div>
298
+ </div>
299
+
300
+ {/* Codec selection */}
301
+ <div className="dialog-section">
302
+ <label className="dialog-label">Format</label>
303
+ <div className="dialog-option-grid dialog-option-grid--3col">
304
+ {CODECS.map((c: Codec) => (
305
+ <button
306
+ key={c.id}
307
+ className={`dialog-option-btn${codec === c.id ? ' active' : ''}`}
308
+ onClick={() => setCodec(c.id)}
309
+ disabled={rendering}
310
+ >
311
+ {c.name}
312
+ </button>
313
+ ))}
314
+ </div>
315
+ <div className="dialog-option-hint">
316
+ {selectedCodec?.description}
317
+ </div>
318
+ </div>
319
+
320
+ {/* Quality selection (not for GIF) */}
321
+ {codec !== 'gif' && (
322
+ <div className="dialog-section">
323
+ <label className="dialog-label">Quality</label>
324
+ <div className="dialog-option-grid dialog-option-grid--4col">
325
+ {QUALITY_PRESETS.map((p: QualityPreset) => (
326
+ <button
327
+ key={p.id}
328
+ className={`dialog-option-btn${quality === p.id ? ' active' : ''}`}
329
+ onClick={() => setQuality(p.id)}
330
+ disabled={rendering}
331
+ >
332
+ {p.name}
333
+ </button>
334
+ ))}
335
+ </div>
336
+ </div>
337
+ )}
338
+
339
+ {/* Scale */}
340
+ <div className="dialog-section">
341
+ <label className="dialog-label">
342
+ Scale ({outputWidth}x{outputHeight})
343
+ </label>
344
+ <input
345
+ className="dialog-range"
346
+ type="range"
347
+ min="0.25"
348
+ max="2"
349
+ step="0.25"
350
+ value={scale}
351
+ onChange={(e: ChangeEvent<HTMLInputElement>) => setScale(parseFloat(e.target.value))}
352
+ disabled={rendering}
353
+ />
354
+ <div className="dialog-range-labels">
355
+ <span>25%</span>
356
+ <span>{scale * 100}%</span>
357
+ <span>200%</span>
358
+ </div>
359
+ </div>
360
+
361
+ {/* Frame range */}
362
+ <div className="dialog-section">
363
+ <label className="dialog-label">
364
+ Frame Range ({frameCount} frames, {duration.toFixed(2)}s)
365
+ </label>
366
+ <div className="dialog-frame-range">
367
+ <input
368
+ className="dialog-input"
369
+ type="number"
370
+ value={startFrame}
371
+ onChange={(e: ChangeEvent<HTMLInputElement>) => setStartFrame(Math.max(0, parseInt(e.target.value) || 0))}
372
+ disabled={rendering}
373
+ />
374
+ <span className="dialog-frame-range-sep">to</span>
375
+ <input
376
+ className="dialog-input"
377
+ type="number"
378
+ value={endFrame}
379
+ onChange={(e: ChangeEvent<HTMLInputElement>) => setEndFrame(Math.min(composition?.durationInFrames ? composition.durationInFrames - 1 : 299, parseInt(e.target.value) || 0))}
380
+ disabled={rendering}
381
+ />
382
+ </div>
383
+ </div>
384
+
385
+ {/* Options */}
386
+ <div className="dialog-section">
387
+ <div className="dialog-checkbox-group">
388
+ <label className="dialog-checkbox-label">
389
+ <input
390
+ type="checkbox"
391
+ checked={muted}
392
+ onChange={(e: ChangeEvent<HTMLInputElement>) => setMuted(e.target.checked)}
393
+ disabled={rendering || codec === 'gif'}
394
+ />
395
+ Mute audio
396
+ </label>
397
+ <label className="dialog-checkbox-label">
398
+ <input
399
+ type="checkbox"
400
+ checked={parallel}
401
+ onChange={(e: ChangeEvent<HTMLInputElement>) => setParallel(e.target.checked)}
402
+ disabled={rendering}
403
+ />
404
+ Parallel rendering
405
+ </label>
406
+ </div>
407
+ </div>
408
+
409
+ {/* Error display */}
410
+ {error && (
411
+ <div className="dialog-alert dialog-alert--error">
412
+ {error}
413
+ </div>
414
+ )}
415
+
416
+ {/* Result display */}
417
+ {result && (
418
+ <div className="dialog-alert dialog-alert--success">
419
+ <div className="dialog-alert-title">
420
+ Render complete!
421
+ </div>
422
+ <div className="dialog-alert-subtitle">
423
+ {result.durationMs && `Completed in ${(result.durationMs / 1000).toFixed(1)}s`}
424
+ </div>
425
+ {result.downloadUrl && (
426
+ <a
427
+ className="dialog-alert-link"
428
+ href={result.downloadUrl}
429
+ target="_blank"
430
+ rel="noopener noreferrer"
431
+ >
432
+ Download
433
+ </a>
434
+ )}
435
+ </div>
436
+ )}
437
+
438
+ {/* Progress bar */}
439
+ {rendering && (
440
+ <div className="dialog-progress">
441
+ <div className="dialog-progress-track">
442
+ <div
443
+ className="dialog-progress-fill"
444
+ style={{ width: `${progress}%` }}
445
+ />
446
+ </div>
447
+ <div className="dialog-progress-text">
448
+ {statusMessage || `Rendering... ${progress}%`}
449
+ </div>
450
+ </div>
451
+ )}
452
+ </div>
453
+
454
+ {/* Footer */}
455
+ <div className="dialog-footer">
456
+ <button
457
+ className="dialog-btn dialog-btn--secondary"
458
+ onClick={onClose}
459
+ disabled={rendering}
460
+ >
461
+ Cancel
462
+ </button>
463
+ <button
464
+ className="dialog-btn dialog-btn--primary"
465
+ onClick={handleRender}
466
+ disabled={rendering}
467
+ >
468
+ {rendering ? 'Rendering...' : 'Start Render'}
469
+ </button>
470
+ </div>
471
+ </div>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ export default RenderDialog;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * ExportDialog Component
3
+ *
4
+ * Modal dialog for exporting compositions via CLI command
5
+ * or config file download.
6
+ */
7
+
8
+ import { useState, useCallback, useEffect, useRef, type MouseEvent, type ReactElement } from 'react';
9
+ import './styles/dialogs.css';
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Types & Interfaces
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface Composition {
16
+ id: string;
17
+ width: number;
18
+ height: number;
19
+ fps: number;
20
+ durationInFrames: number;
21
+ }
22
+
23
+ type CopyLabel = 'cli' | 'config' | null;
24
+
25
+ export interface ExportDialogProps {
26
+ open: boolean;
27
+ onClose: () => void;
28
+ composition: Composition;
29
+ inputProps?: Record<string, unknown>;
30
+ }
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Component
34
+ // ---------------------------------------------------------------------------
35
+
36
+ export function ExportDialog({
37
+ open,
38
+ onClose,
39
+ composition,
40
+ inputProps = {},
41
+ }: ExportDialogProps): ReactElement | null {
42
+ const [copied, setCopied] = useState<CopyLabel>(null);
43
+
44
+ const copyToClipboard = useCallback((text: string, label: CopyLabel): void => {
45
+ navigator.clipboard.writeText(text).then(() => {
46
+ setCopied(label);
47
+ setTimeout(() => setCopied(null), 2000);
48
+ });
49
+ }, []);
50
+
51
+ const dialogRef = useRef<HTMLDivElement>(null);
52
+
53
+ useEffect(() => {
54
+ if (!open) return;
55
+ const handleKeyDown = (e: globalThis.KeyboardEvent): void => {
56
+ if (e.key === 'Escape') onClose?.();
57
+ };
58
+ document.addEventListener('keydown', handleKeyDown);
59
+ return () => document.removeEventListener('keydown', handleKeyDown);
60
+ }, [open, onClose]);
61
+
62
+ useEffect(() => {
63
+ if (!open || !dialogRef.current) return;
64
+ const dialog: HTMLDivElement = dialogRef.current;
65
+ const focusable: NodeListOf<HTMLElement> = dialog.querySelectorAll(
66
+ 'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
67
+ );
68
+ if (focusable.length > 0) focusable[0].focus();
69
+
70
+ const handleTab = (e: globalThis.KeyboardEvent): void => {
71
+ if (e.key !== 'Tab' || focusable.length === 0) return;
72
+ const first: HTMLElement = focusable[0];
73
+ const last: HTMLElement = focusable[focusable.length - 1];
74
+ if (e.shiftKey && document.activeElement === first) {
75
+ e.preventDefault();
76
+ last.focus();
77
+ } else if (!e.shiftKey && document.activeElement === last) {
78
+ e.preventDefault();
79
+ first.focus();
80
+ }
81
+ };
82
+ document.addEventListener('keydown', handleTab);
83
+ return () => document.removeEventListener('keydown', handleTab);
84
+ }, [open]);
85
+
86
+ if (!open) return null;
87
+
88
+ const propsJson: string = Object.keys(inputProps).length > 0
89
+ ? ` --props '${JSON.stringify(inputProps)}'`
90
+ : '';
91
+ const cliCommand: string = `framely render ${composition.id} out.mp4 --codec h264 --crf 18${propsJson}`;
92
+
93
+ const configJson: string = JSON.stringify({
94
+ compositionId: composition.id,
95
+ width: composition.width,
96
+ height: composition.height,
97
+ fps: composition.fps,
98
+ durationInFrames: composition.durationInFrames,
99
+ inputProps,
100
+ }, null, 2);
101
+
102
+ const handleDownloadConfig = (): void => {
103
+ const blob = new Blob([configJson], { type: 'application/json' });
104
+ const url: string = URL.createObjectURL(blob);
105
+ const a: HTMLAnchorElement = document.createElement('a');
106
+ a.href = url;
107
+ a.download = `${composition.id}.framely.json`;
108
+ a.click();
109
+ URL.revokeObjectURL(url);
110
+ };
111
+
112
+ return (
113
+ <div
114
+ className="dialog-overlay"
115
+ role="dialog"
116
+ aria-modal="true"
117
+ aria-labelledby="export-dialog-title"
118
+ onClick={(e: MouseEvent<HTMLDivElement>) => {
119
+ if (e.target === e.currentTarget) onClose();
120
+ }}
121
+ >
122
+ <div ref={dialogRef} className="dialog-panel">
123
+ {/* Header */}
124
+ <div className="dialog-header">
125
+ <h2 id="export-dialog-title" className="dialog-title">
126
+ Export
127
+ </h2>
128
+ <button
129
+ className="dialog-close-btn"
130
+ onClick={onClose}
131
+ aria-label="Close export dialog"
132
+ >
133
+ ×
134
+ </button>
135
+ </div>
136
+
137
+ {/* Content */}
138
+ <div className="dialog-body">
139
+ {/* CLI Command */}
140
+ <div className="dialog-section">
141
+ <label className="dialog-label">Render Command</label>
142
+ <textarea
143
+ className="dialog-code"
144
+ readOnly
145
+ value={cliCommand}
146
+ rows={2}
147
+ onClick={(e: MouseEvent<HTMLTextAreaElement>) => (e.target as HTMLTextAreaElement).select()}
148
+ />
149
+ <div className="dialog-btn-row">
150
+ <button
151
+ className={`dialog-btn ${copied === 'cli' ? 'dialog-btn--success' : 'dialog-btn--subtle'}`}
152
+ onClick={() => copyToClipboard(cliCommand, 'cli')}
153
+ >
154
+ {copied === 'cli' ? 'Copied!' : 'Copy command'}
155
+ </button>
156
+ </div>
157
+ </div>
158
+
159
+ {/* Config File */}
160
+ <div className="dialog-section">
161
+ <label className="dialog-label">Config File</label>
162
+ <textarea
163
+ className="dialog-code"
164
+ readOnly
165
+ value={configJson}
166
+ rows={6}
167
+ onClick={(e: MouseEvent<HTMLTextAreaElement>) => (e.target as HTMLTextAreaElement).select()}
168
+ />
169
+ <div className="dialog-btn-row">
170
+ <button
171
+ className={`dialog-btn ${copied === 'config' ? 'dialog-btn--success' : 'dialog-btn--subtle'}`}
172
+ onClick={() => copyToClipboard(configJson, 'config')}
173
+ >
174
+ {copied === 'config' ? 'Copied!' : 'Copy config'}
175
+ </button>
176
+ <button
177
+ className="dialog-btn dialog-btn--subtle"
178
+ onClick={handleDownloadConfig}
179
+ >
180
+ Download .framely.json
181
+ </button>
182
+ </div>
183
+ </div>
184
+ </div>
185
+
186
+ {/* Footer */}
187
+ <div className="dialog-footer">
188
+ <button
189
+ className="dialog-btn dialog-btn--secondary"
190
+ onClick={onClose}
191
+ >
192
+ Close
193
+ </button>
194
+ </div>
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ export default ExportDialog;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Framely Studio
3
+ *
4
+ * Studio UI components for the Framely preview and render dialogs.
5
+ */
6
+
7
+ export { CompositionsView } from './CompositionsView';
8
+
9
+ export { RenderDialog } from './RenderDialog';
10
+ export type {
11
+ Composition as RenderComposition,
12
+ RenderDialogProps,
13
+ } from './RenderDialog';
14
+
15
+ export { ExportDialog } from './ShareDialog';
16
+ export type {
17
+ Composition as ExportComposition,
18
+ ExportDialogProps,
19
+ } from './ShareDialog';