@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,186 @@
|
|
|
1
|
+
import { AbsoluteFill, useCurrentFrame, useVideoConfig, interpolate, spring } from '@codellyson/framely';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* YouTube Subscribe Animation Template
|
|
5
|
+
*/
|
|
6
|
+
export function SubscribeAnimation({
|
|
7
|
+
channelName = 'Your Channel',
|
|
8
|
+
buttonColor = '#FF0000',
|
|
9
|
+
showBell = true,
|
|
10
|
+
}) {
|
|
11
|
+
const frame = useCurrentFrame();
|
|
12
|
+
const { fps } = useVideoConfig();
|
|
13
|
+
|
|
14
|
+
// Button entrance
|
|
15
|
+
const buttonSpring = spring({ frame, fps, config: { damping: 12, stiffness: 150 } });
|
|
16
|
+
const buttonScale = interpolate(buttonSpring, [0, 1], [0, 1]);
|
|
17
|
+
const buttonY = interpolate(buttonSpring, [0, 1], [50, 0]);
|
|
18
|
+
|
|
19
|
+
// Subscribe text morph
|
|
20
|
+
const textProgress = interpolate(frame, [40, 50], [0, 1], { extrapolateRight: 'clamp' });
|
|
21
|
+
const showSubscribed = textProgress > 0.5;
|
|
22
|
+
|
|
23
|
+
// Bell animation
|
|
24
|
+
const bellDelay = 60;
|
|
25
|
+
const bellSpring = spring({ frame: frame - bellDelay, fps, config: { damping: 8, stiffness: 200 } });
|
|
26
|
+
const bellScale = interpolate(bellSpring, [0, 1], [0, 1]);
|
|
27
|
+
const bellRotate = interpolate(
|
|
28
|
+
frame - bellDelay,
|
|
29
|
+
[0, 10, 20, 30, 40],
|
|
30
|
+
[0, -20, 20, -10, 0],
|
|
31
|
+
{ extrapolateRight: 'clamp' }
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// Cursor animation
|
|
35
|
+
const cursorX = interpolate(frame, [0, 30, 35], [400, 0, 0], { extrapolateRight: 'clamp' });
|
|
36
|
+
const cursorY = interpolate(frame, [0, 30, 35], [200, 0, 5], { extrapolateRight: 'clamp' });
|
|
37
|
+
const cursorOpacity = interpolate(frame, [0, 10, 100, 120], [0, 1, 1, 0], { extrapolateRight: 'clamp' });
|
|
38
|
+
const cursorClick = frame >= 35 && frame <= 45;
|
|
39
|
+
|
|
40
|
+
// Channel name
|
|
41
|
+
const nameOpacity = interpolate(frame, [70, 90], [0, 1], { extrapolateRight: 'clamp' });
|
|
42
|
+
const nameY = interpolate(frame, [70, 90], [20, 0], { extrapolateRight: 'clamp' });
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<AbsoluteFill
|
|
46
|
+
style={{
|
|
47
|
+
background: 'linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%)',
|
|
48
|
+
display: 'flex',
|
|
49
|
+
flexDirection: 'column',
|
|
50
|
+
alignItems: 'center',
|
|
51
|
+
justifyContent: 'center',
|
|
52
|
+
gap: 30,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{/* Subscribe Button */}
|
|
56
|
+
<div
|
|
57
|
+
style={{
|
|
58
|
+
display: 'flex',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
gap: 20,
|
|
61
|
+
transform: `scale(${buttonScale}) translateY(${buttonY}px)`,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<button
|
|
65
|
+
style={{
|
|
66
|
+
padding: '16px 32px',
|
|
67
|
+
fontSize: 24,
|
|
68
|
+
fontWeight: 600,
|
|
69
|
+
color: '#fff',
|
|
70
|
+
background: showSubscribed ? '#666' : buttonColor,
|
|
71
|
+
border: 'none',
|
|
72
|
+
borderRadius: 4,
|
|
73
|
+
cursor: 'pointer',
|
|
74
|
+
transition: 'background 0.2s',
|
|
75
|
+
minWidth: 200,
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{showSubscribed ? 'SUBSCRIBED' : 'SUBSCRIBE'}
|
|
79
|
+
</button>
|
|
80
|
+
|
|
81
|
+
{/* Bell */}
|
|
82
|
+
{showBell && (
|
|
83
|
+
<div
|
|
84
|
+
style={{
|
|
85
|
+
width: 50,
|
|
86
|
+
height: 50,
|
|
87
|
+
background: 'rgba(255, 255, 255, 0.1)',
|
|
88
|
+
borderRadius: '50%',
|
|
89
|
+
display: 'flex',
|
|
90
|
+
alignItems: 'center',
|
|
91
|
+
justifyContent: 'center',
|
|
92
|
+
transform: `scale(${bellScale}) rotate(${bellRotate}deg)`,
|
|
93
|
+
}}
|
|
94
|
+
>
|
|
95
|
+
<svg width="28" height="28" viewBox="0 0 24 24" fill="#fff">
|
|
96
|
+
<path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z" />
|
|
97
|
+
</svg>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Channel Name */}
|
|
103
|
+
<div
|
|
104
|
+
style={{
|
|
105
|
+
opacity: nameOpacity,
|
|
106
|
+
transform: `translateY(${nameY}px)`,
|
|
107
|
+
textAlign: 'center',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
<p
|
|
111
|
+
style={{
|
|
112
|
+
fontSize: 18,
|
|
113
|
+
color: 'rgba(255, 255, 255, 0.6)',
|
|
114
|
+
margin: 0,
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
Thanks for subscribing to
|
|
118
|
+
</p>
|
|
119
|
+
<p
|
|
120
|
+
style={{
|
|
121
|
+
fontSize: 28,
|
|
122
|
+
fontWeight: 600,
|
|
123
|
+
color: '#fff',
|
|
124
|
+
margin: '8px 0 0',
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
{channelName}
|
|
128
|
+
</p>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{/* Cursor */}
|
|
132
|
+
<div
|
|
133
|
+
style={{
|
|
134
|
+
position: 'absolute',
|
|
135
|
+
left: '50%',
|
|
136
|
+
top: '50%',
|
|
137
|
+
transform: `translate(${cursorX}px, ${cursorY}px)`,
|
|
138
|
+
opacity: cursorOpacity,
|
|
139
|
+
pointerEvents: 'none',
|
|
140
|
+
}}
|
|
141
|
+
>
|
|
142
|
+
<svg
|
|
143
|
+
width="32"
|
|
144
|
+
height="32"
|
|
145
|
+
viewBox="0 0 24 24"
|
|
146
|
+
style={{
|
|
147
|
+
transform: cursorClick ? 'scale(0.9)' : 'scale(1)',
|
|
148
|
+
transition: 'transform 0.1s',
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
<path
|
|
152
|
+
d="M4 4l16 12-6.5 1.5L12 24 4 4z"
|
|
153
|
+
fill="#fff"
|
|
154
|
+
stroke="#000"
|
|
155
|
+
strokeWidth="1"
|
|
156
|
+
/>
|
|
157
|
+
</svg>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Particles on subscribe */}
|
|
161
|
+
{showSubscribed && [...Array(12)].map((_, i) => {
|
|
162
|
+
const angle = (i / 12) * Math.PI * 2;
|
|
163
|
+
const distance = interpolate(frame - 45, [0, 30], [0, 150], { extrapolateRight: 'clamp' });
|
|
164
|
+
const particleOpacity = interpolate(frame - 45, [0, 20, 30], [0, 1, 0], { extrapolateRight: 'clamp' });
|
|
165
|
+
return (
|
|
166
|
+
<div
|
|
167
|
+
key={i}
|
|
168
|
+
style={{
|
|
169
|
+
position: 'absolute',
|
|
170
|
+
left: '50%',
|
|
171
|
+
top: '45%',
|
|
172
|
+
width: 8,
|
|
173
|
+
height: 8,
|
|
174
|
+
background: buttonColor,
|
|
175
|
+
borderRadius: '50%',
|
|
176
|
+
transform: `translate(${Math.cos(angle) * distance}px, ${Math.sin(angle) * distance}px)`,
|
|
177
|
+
opacity: particleOpacity,
|
|
178
|
+
}}
|
|
179
|
+
/>
|
|
180
|
+
);
|
|
181
|
+
})}
|
|
182
|
+
</AbsoluteFill>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export default SubscribeAnimation;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Template } from '@codellyson/framely';
|
|
2
|
+
|
|
3
|
+
export interface TemplateCardProps {
|
|
4
|
+
template: Template;
|
|
5
|
+
onClick: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Template card component for the marketplace grid
|
|
10
|
+
*/
|
|
11
|
+
export function TemplateCard({ template, onClick }: TemplateCardProps) {
|
|
12
|
+
return (
|
|
13
|
+
<button className="template-card" onClick={onClick} type="button">
|
|
14
|
+
<div className="template-card-preview">
|
|
15
|
+
<img
|
|
16
|
+
src={template.preview.thumbnail}
|
|
17
|
+
alt={template.name}
|
|
18
|
+
loading="lazy"
|
|
19
|
+
/>
|
|
20
|
+
{template.featured && (
|
|
21
|
+
<span className="template-card-badge">Featured</span>
|
|
22
|
+
)}
|
|
23
|
+
</div>
|
|
24
|
+
<div className="template-card-info">
|
|
25
|
+
<h3 className="template-card-name">{template.name}</h3>
|
|
26
|
+
<p className="template-card-meta">
|
|
27
|
+
{template.width}x{template.height} • {template.fps}fps
|
|
28
|
+
</p>
|
|
29
|
+
<div className="template-card-footer">
|
|
30
|
+
<span className="template-card-author">
|
|
31
|
+
{template.author.verified && (
|
|
32
|
+
<svg
|
|
33
|
+
className="template-verified-icon"
|
|
34
|
+
width="12"
|
|
35
|
+
height="12"
|
|
36
|
+
viewBox="0 0 24 24"
|
|
37
|
+
fill="currentColor"
|
|
38
|
+
>
|
|
39
|
+
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
40
|
+
</svg>
|
|
41
|
+
)}
|
|
42
|
+
{template.author.name}
|
|
43
|
+
</span>
|
|
44
|
+
{template.rating && (
|
|
45
|
+
<span className="template-card-rating">
|
|
46
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
|
|
47
|
+
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
|
48
|
+
</svg>
|
|
49
|
+
{template.rating.toFixed(1)}
|
|
50
|
+
</span>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default TemplateCard;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { templatesApi, CATEGORY_LABELS } from '@codellyson/framely';
|
|
3
|
+
import type { TemplateCategory, TemplatesFilterParams, CategoryCount } from '@codellyson/framely';
|
|
4
|
+
|
|
5
|
+
export interface TemplateFiltersProps {
|
|
6
|
+
filters: TemplatesFilterParams;
|
|
7
|
+
onChange: (filters: Partial<TemplatesFilterParams>) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Filter controls for the templates marketplace
|
|
12
|
+
*/
|
|
13
|
+
export function TemplateFilters({ filters, onChange }: TemplateFiltersProps) {
|
|
14
|
+
const [categories, setCategories] = useState<CategoryCount[]>([]);
|
|
15
|
+
const [searchValue, setSearchValue] = useState(filters.search || '');
|
|
16
|
+
|
|
17
|
+
// Fetch categories on mount
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
templatesApi.getCategories().then(setCategories).catch(console.error);
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
// Debounced search
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const timeout = setTimeout(() => {
|
|
25
|
+
if (searchValue !== filters.search) {
|
|
26
|
+
onChange({ search: searchValue || undefined });
|
|
27
|
+
}
|
|
28
|
+
}, 300);
|
|
29
|
+
return () => clearTimeout(timeout);
|
|
30
|
+
}, [searchValue, filters.search, onChange]);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="templates-filters">
|
|
34
|
+
{/* Search */}
|
|
35
|
+
<div className="filter-search">
|
|
36
|
+
<svg
|
|
37
|
+
className="filter-search-icon"
|
|
38
|
+
width="16"
|
|
39
|
+
height="16"
|
|
40
|
+
viewBox="0 0 24 24"
|
|
41
|
+
fill="none"
|
|
42
|
+
stroke="currentColor"
|
|
43
|
+
strokeWidth="2"
|
|
44
|
+
>
|
|
45
|
+
<circle cx="11" cy="11" r="8" />
|
|
46
|
+
<path d="M21 21l-4.35-4.35" />
|
|
47
|
+
</svg>
|
|
48
|
+
<input
|
|
49
|
+
type="text"
|
|
50
|
+
placeholder="Search templates..."
|
|
51
|
+
value={searchValue}
|
|
52
|
+
onChange={(e) => setSearchValue(e.target.value)}
|
|
53
|
+
/>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{/* Categories */}
|
|
57
|
+
<div className="filter-categories">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className={`filter-category ${!filters.category ? 'active' : ''}`}
|
|
61
|
+
onClick={() => onChange({ category: undefined })}
|
|
62
|
+
>
|
|
63
|
+
All
|
|
64
|
+
</button>
|
|
65
|
+
{categories.map(({ category, count }) => (
|
|
66
|
+
<button
|
|
67
|
+
key={category}
|
|
68
|
+
type="button"
|
|
69
|
+
className={`filter-category ${filters.category === category ? 'active' : ''}`}
|
|
70
|
+
onClick={() =>
|
|
71
|
+
onChange({ category: filters.category === category ? undefined : category })
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
{CATEGORY_LABELS[category as TemplateCategory] || category}
|
|
75
|
+
<span className="filter-category-count">{count}</span>
|
|
76
|
+
</button>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Sort */}
|
|
81
|
+
<div className="filter-sort">
|
|
82
|
+
<select
|
|
83
|
+
value={filters.sortBy || 'newest'}
|
|
84
|
+
onChange={(e) =>
|
|
85
|
+
onChange({ sortBy: e.target.value as TemplatesFilterParams['sortBy'] })
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
<option value="newest">Newest</option>
|
|
89
|
+
<option value="popular">Most Popular</option>
|
|
90
|
+
<option value="rating">Highest Rated</option>
|
|
91
|
+
</select>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export default TemplateFilters;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import type { Template } from '@codellyson/framely';
|
|
3
|
+
import { CATEGORY_LABELS } from '@codellyson/framely';
|
|
4
|
+
|
|
5
|
+
export interface TemplatePreviewDialogProps {
|
|
6
|
+
open: boolean;
|
|
7
|
+
template: Template | null;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onUseTemplate: () => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dialog for previewing a template before using it
|
|
14
|
+
*/
|
|
15
|
+
export function TemplatePreviewDialog({
|
|
16
|
+
open,
|
|
17
|
+
template,
|
|
18
|
+
onClose,
|
|
19
|
+
onUseTemplate,
|
|
20
|
+
}: TemplatePreviewDialogProps) {
|
|
21
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
22
|
+
|
|
23
|
+
// Escape key handler
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!open) return;
|
|
26
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
27
|
+
if (e.key === 'Escape') onClose();
|
|
28
|
+
};
|
|
29
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
30
|
+
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
31
|
+
}, [open, onClose]);
|
|
32
|
+
|
|
33
|
+
// Focus trap
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!open || !dialogRef.current) return;
|
|
36
|
+
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
|
|
37
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
38
|
+
);
|
|
39
|
+
if (focusable.length > 0) {
|
|
40
|
+
focusable[0].focus();
|
|
41
|
+
}
|
|
42
|
+
}, [open]);
|
|
43
|
+
|
|
44
|
+
if (!open || !template) return null;
|
|
45
|
+
|
|
46
|
+
const duration = template.durationInFrames / template.fps;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
className="template-preview-overlay"
|
|
51
|
+
role="dialog"
|
|
52
|
+
aria-modal="true"
|
|
53
|
+
aria-labelledby="preview-dialog-title"
|
|
54
|
+
onClick={(e) => {
|
|
55
|
+
if (e.target === e.currentTarget) onClose();
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<div ref={dialogRef} className="template-preview-dialog">
|
|
59
|
+
{/* Header */}
|
|
60
|
+
<div className="template-preview-header">
|
|
61
|
+
<h2 id="preview-dialog-title">{template.name}</h2>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={onClose}
|
|
65
|
+
aria-label="Close"
|
|
66
|
+
className="template-dialog-close"
|
|
67
|
+
>
|
|
68
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
69
|
+
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
|
|
70
|
+
</svg>
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
{/* Content */}
|
|
75
|
+
<div className="template-preview-content">
|
|
76
|
+
{/* Preview Area */}
|
|
77
|
+
<div className="template-preview-media">
|
|
78
|
+
{template.preview.video ? (
|
|
79
|
+
<video
|
|
80
|
+
src={template.preview.video}
|
|
81
|
+
autoPlay
|
|
82
|
+
loop
|
|
83
|
+
muted
|
|
84
|
+
playsInline
|
|
85
|
+
/>
|
|
86
|
+
) : template.preview.preview ? (
|
|
87
|
+
<img src={template.preview.preview} alt={template.name} />
|
|
88
|
+
) : (
|
|
89
|
+
<img src={template.preview.thumbnail} alt={template.name} />
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* Details Panel */}
|
|
94
|
+
<div className="template-preview-details">
|
|
95
|
+
<p className="template-preview-description">{template.description}</p>
|
|
96
|
+
|
|
97
|
+
<div className="template-preview-info-grid">
|
|
98
|
+
<div className="template-preview-info-item">
|
|
99
|
+
<span className="label">Resolution</span>
|
|
100
|
+
<span className="value">
|
|
101
|
+
{template.width} x {template.height}
|
|
102
|
+
</span>
|
|
103
|
+
</div>
|
|
104
|
+
<div className="template-preview-info-item">
|
|
105
|
+
<span className="label">FPS</span>
|
|
106
|
+
<span className="value">{template.fps}</span>
|
|
107
|
+
</div>
|
|
108
|
+
<div className="template-preview-info-item">
|
|
109
|
+
<span className="label">Duration</span>
|
|
110
|
+
<span className="value">{duration.toFixed(1)}s</span>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="template-preview-info-item">
|
|
113
|
+
<span className="label">Category</span>
|
|
114
|
+
<span className="value">
|
|
115
|
+
{CATEGORY_LABELS[template.category] || template.category}
|
|
116
|
+
</span>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div className="template-preview-author">
|
|
121
|
+
<span className="label">Created by</span>
|
|
122
|
+
<div className="template-preview-author-info">
|
|
123
|
+
{template.author.avatar && (
|
|
124
|
+
<img
|
|
125
|
+
src={template.author.avatar}
|
|
126
|
+
alt=""
|
|
127
|
+
className="template-preview-author-avatar"
|
|
128
|
+
/>
|
|
129
|
+
)}
|
|
130
|
+
<span className="template-preview-author-name">
|
|
131
|
+
{template.author.verified && (
|
|
132
|
+
<svg
|
|
133
|
+
className="template-verified-icon"
|
|
134
|
+
width="14"
|
|
135
|
+
height="14"
|
|
136
|
+
viewBox="0 0 24 24"
|
|
137
|
+
fill="currentColor"
|
|
138
|
+
>
|
|
139
|
+
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
140
|
+
</svg>
|
|
141
|
+
)}
|
|
142
|
+
{template.author.name}
|
|
143
|
+
</span>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{template.tags.length > 0 && (
|
|
148
|
+
<div className="template-preview-tags">
|
|
149
|
+
{template.tags.map((tag) => (
|
|
150
|
+
<span key={tag} className="template-preview-tag">
|
|
151
|
+
{tag}
|
|
152
|
+
</span>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{template.downloads !== undefined && (
|
|
158
|
+
<div className="template-preview-stats">
|
|
159
|
+
<span className="template-preview-stat">
|
|
160
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
161
|
+
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z" />
|
|
162
|
+
</svg>
|
|
163
|
+
{template.downloads.toLocaleString()} downloads
|
|
164
|
+
</span>
|
|
165
|
+
{template.rating && (
|
|
166
|
+
<span className="template-preview-stat">
|
|
167
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
|
168
|
+
<path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
|
169
|
+
</svg>
|
|
170
|
+
{template.rating.toFixed(1)}
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* Footer */}
|
|
179
|
+
<div className="template-preview-footer">
|
|
180
|
+
<button type="button" onClick={onClose} className="template-btn-secondary">
|
|
181
|
+
Cancel
|
|
182
|
+
</button>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={onUseTemplate}
|
|
186
|
+
className="template-btn-primary"
|
|
187
|
+
>
|
|
188
|
+
Use Template
|
|
189
|
+
</button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export default TemplatePreviewDialog;
|