@hyperframes/studio 0.7.6 → 0.7.8
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/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
- package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
- package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
- package/dist/assets/index-svYFaNuq.css +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3650 -2431
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/components/StudioRightPanel.tsx +7 -1
- package/src/components/editor/AnimationCard.tsx +6 -0
- package/src/components/editor/DomEditOverlay.tsx +4 -12
- package/src/components/editor/EaseCurveSection.tsx +175 -99
- package/src/components/editor/GsapAnimationSection.tsx +2 -0
- package/src/components/editor/KeyframeEaseList.tsx +67 -3
- package/src/components/editor/MarqueeOverlay.tsx +40 -0
- package/src/components/editor/OffCanvasIndicators.tsx +2 -7
- package/src/components/editor/PropertyPanel.tsx +23 -2
- package/src/components/editor/Transform3DCube.tsx +313 -0
- package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
- package/src/components/editor/gsapAnimationConstants.ts +11 -35
- package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
- package/src/components/editor/marqueeCommit.ts +63 -20
- package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
- package/src/components/editor/propertyPanelHelpers.ts +46 -18
- package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
- package/src/components/editor/transform3dProjection.test.ts +99 -0
- package/src/components/editor/transform3dProjection.ts +172 -0
- package/src/components/editor/useMotionPathData.ts +2 -1
- package/src/components/sidebar/AssetContextMenu.tsx +97 -0
- package/src/components/sidebar/AssetsTab.tsx +364 -266
- package/src/components/sidebar/AudioRow.tsx +202 -0
- package/src/components/sidebar/assetHelpers.ts +40 -0
- package/src/contexts/DomEditContext.tsx +8 -0
- package/src/hooks/gsapDragCommit.test.ts +9 -5
- package/src/hooks/gsapDragCommit.ts +129 -9
- package/src/hooks/gsapRuntimeBridge.ts +22 -0
- package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
- package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
- package/src/hooks/gsapRuntimePatch.ts +162 -35
- package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
- package/src/hooks/useDomEditCommits.ts +0 -2
- package/src/hooks/useDomEditSession.ts +24 -2
- package/src/hooks/useDomEditWiring.ts +3 -0
- package/src/hooks/useDomGeometryCommits.ts +3 -31
- package/src/hooks/useGestureCommit.ts +0 -4
- package/src/hooks/useGsapAwareEditing.ts +31 -5
- package/src/hooks/useGsapKeyframeOps.ts +4 -1
- package/src/hooks/useGsapScriptCommits.ts +0 -12
- package/src/hooks/useGsapSelectionHandlers.ts +12 -9
- package/src/hooks/useGsapTweenCache.test.ts +45 -1
- package/src/hooks/useGsapTweenCache.ts +125 -42
- package/src/hooks/useMusicBeatAnalysis.ts +36 -21
- package/src/hooks/useRazorSplit.ts +0 -3
- package/src/utils/marqueeGeometry.test.ts +15 -98
- package/src/utils/marqueeGeometry.ts +1 -158
- package/dist/assets/index-wJZf6FK3.css +0 -1
- package/src/utils/editDebugLog.ts +0 -9
|
@@ -1,8 +1,19 @@
|
|
|
1
|
-
import { memo, useState, useCallback, useRef } from "react";
|
|
1
|
+
import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react";
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
|
-
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT,
|
|
3
|
+
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
5
|
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
|
+
import { ContextMenu } from "./AssetContextMenu";
|
|
7
|
+
import { usePlayerStore } from "../../player/store/playerStore";
|
|
8
|
+
import {
|
|
9
|
+
type MediaCategory,
|
|
10
|
+
getCategory,
|
|
11
|
+
basename,
|
|
12
|
+
ext,
|
|
13
|
+
CATEGORY_LABELS,
|
|
14
|
+
FILTER_ORDER,
|
|
15
|
+
} from "./assetHelpers";
|
|
16
|
+
import { AudioRow } from "./AudioRow";
|
|
6
17
|
|
|
7
18
|
interface AssetsTabProps {
|
|
8
19
|
projectId: string;
|
|
@@ -12,98 +23,36 @@ interface AssetsTabProps {
|
|
|
12
23
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
13
24
|
}
|
|
14
25
|
|
|
15
|
-
|
|
16
|
-
function
|
|
17
|
-
serveUrl,
|
|
18
|
-
name,
|
|
19
|
-
isImage,
|
|
20
|
-
isVideo,
|
|
21
|
-
isAudio,
|
|
22
|
-
}: {
|
|
23
|
-
serveUrl: string;
|
|
24
|
-
name: string;
|
|
25
|
-
isImage: boolean;
|
|
26
|
-
isVideo: boolean;
|
|
27
|
-
isAudio: boolean;
|
|
28
|
-
}) {
|
|
29
|
-
return (
|
|
30
|
-
<>
|
|
31
|
-
{isImage && (
|
|
32
|
-
<img
|
|
33
|
-
src={serveUrl}
|
|
34
|
-
alt={name}
|
|
35
|
-
loading="lazy"
|
|
36
|
-
className="w-full h-full object-contain"
|
|
37
|
-
onError={(e) => {
|
|
38
|
-
(e.target as HTMLImageElement).style.display = "none";
|
|
39
|
-
}}
|
|
40
|
-
/>
|
|
41
|
-
)}
|
|
42
|
-
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
|
43
|
-
{isAudio && (
|
|
44
|
-
<div className="w-full h-full flex items-center justify-center">
|
|
45
|
-
<svg
|
|
46
|
-
width="16"
|
|
47
|
-
height="16"
|
|
48
|
-
viewBox="0 0 24 24"
|
|
49
|
-
fill="none"
|
|
50
|
-
stroke="currentColor"
|
|
51
|
-
strokeWidth="1.5"
|
|
52
|
-
className="text-purple-400"
|
|
53
|
-
>
|
|
54
|
-
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
|
|
55
|
-
<circle cx="6" cy="18" r="3" />
|
|
56
|
-
<circle cx="18" cy="16" r="3" />
|
|
57
|
-
</svg>
|
|
58
|
-
</div>
|
|
59
|
-
)}
|
|
60
|
-
{!isImage && !isVideo && !isAudio && (
|
|
61
|
-
<div className="w-full h-full flex items-center justify-center">
|
|
62
|
-
<svg
|
|
63
|
-
width="14"
|
|
64
|
-
height="14"
|
|
65
|
-
viewBox="0 0 24 24"
|
|
66
|
-
fill="none"
|
|
67
|
-
stroke="currentColor"
|
|
68
|
-
strokeWidth="1.5"
|
|
69
|
-
className="text-neutral-600"
|
|
70
|
-
>
|
|
71
|
-
<path
|
|
72
|
-
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
|
73
|
-
strokeLinecap="round"
|
|
74
|
-
strokeLinejoin="round"
|
|
75
|
-
/>
|
|
76
|
-
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
|
|
77
|
-
</svg>
|
|
78
|
-
</div>
|
|
79
|
-
)}
|
|
80
|
-
</>
|
|
81
|
-
);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function AssetCard({
|
|
26
|
+
// fallow-ignore-next-line complexity
|
|
27
|
+
function ImageCard({
|
|
85
28
|
projectId,
|
|
86
29
|
asset,
|
|
30
|
+
used,
|
|
87
31
|
onCopy,
|
|
88
32
|
isCopied,
|
|
89
33
|
onDelete,
|
|
90
34
|
onRename,
|
|
35
|
+
size,
|
|
91
36
|
}: {
|
|
92
37
|
projectId: string;
|
|
93
38
|
asset: string;
|
|
39
|
+
used: boolean;
|
|
94
40
|
onCopy: (path: string) => void;
|
|
95
41
|
isCopied: boolean;
|
|
96
42
|
onDelete?: (path: string) => void;
|
|
97
43
|
onRename?: (oldPath: string, newPath: string) => void;
|
|
44
|
+
size: "large" | "small";
|
|
98
45
|
}) {
|
|
99
|
-
const [hovered, setHovered] = useState(false);
|
|
100
46
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
101
|
-
const [
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
const name = asset.split("/").pop() ?? asset;
|
|
47
|
+
const [hovered, setHovered] = useState(false);
|
|
48
|
+
const name = basename(asset);
|
|
49
|
+
const extension = ext(asset);
|
|
105
50
|
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
|
106
51
|
const isVideo = VIDEO_EXT.test(asset);
|
|
52
|
+
const isImage = IMAGE_EXT.test(asset);
|
|
53
|
+
|
|
54
|
+
const thumbW = size === "large" ? "w-full" : "w-[50px]";
|
|
55
|
+
const thumbH = size === "large" ? "h-[100px]" : "h-[32px]";
|
|
107
56
|
|
|
108
57
|
return (
|
|
109
58
|
<>
|
|
@@ -121,158 +70,103 @@ function AssetCard({
|
|
|
121
70
|
}}
|
|
122
71
|
onPointerEnter={() => setHovered(true)}
|
|
123
72
|
onPointerLeave={() => setHovered(false)}
|
|
124
|
-
className={`
|
|
125
|
-
|
|
126
|
-
? "bg-studio-accent/10
|
|
127
|
-
:
|
|
73
|
+
className={`transition-colors cursor-pointer ${
|
|
74
|
+
size === "large"
|
|
75
|
+
? `px-2.5 py-1 ${isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/30"}`
|
|
76
|
+
: `px-2.5 py-1.5 flex items-center gap-2.5 ${
|
|
77
|
+
isCopied
|
|
78
|
+
? "bg-studio-accent/10 border-l-2 border-studio-accent"
|
|
79
|
+
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
|
80
|
+
}`
|
|
128
81
|
}`}
|
|
129
82
|
>
|
|
130
|
-
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const dir = asset.includes("/")
|
|
161
|
-
? asset.slice(0, asset.lastIndexOf("/") + 1)
|
|
162
|
-
: "";
|
|
163
|
-
onRename?.(asset, dir + trimmed);
|
|
164
|
-
}
|
|
165
|
-
setRenaming(false);
|
|
166
|
-
} else if (e.key === "Escape") {
|
|
167
|
-
setRenaming(false);
|
|
168
|
-
}
|
|
169
|
-
}}
|
|
170
|
-
onBlur={() => {
|
|
171
|
-
const trimmed = renameName.trim();
|
|
172
|
-
if (trimmed && trimmed !== name) {
|
|
173
|
-
const dir = asset.includes("/") ? asset.slice(0, asset.lastIndexOf("/") + 1) : "";
|
|
174
|
-
onRename?.(asset, dir + trimmed);
|
|
175
|
-
}
|
|
176
|
-
setRenaming(false);
|
|
177
|
-
}}
|
|
178
|
-
onClick={(e) => e.stopPropagation()}
|
|
179
|
-
className="w-full bg-neutral-800 text-neutral-200 text-[11px] px-1.5 py-0.5 rounded border border-neutral-600 outline-none focus:border-studio-accent"
|
|
180
|
-
spellCheck={false}
|
|
181
|
-
/>
|
|
182
|
-
) : (
|
|
183
|
-
<>
|
|
184
|
-
<span className="text-[11px] font-medium text-neutral-300 truncate block">
|
|
83
|
+
{size === "large" ? (
|
|
84
|
+
<div className="flex flex-col gap-1">
|
|
85
|
+
<div className={`${thumbW} ${thumbH} rounded overflow-hidden bg-neutral-900 relative`}>
|
|
86
|
+
{isImage && (
|
|
87
|
+
<img
|
|
88
|
+
src={serveUrl}
|
|
89
|
+
alt={name}
|
|
90
|
+
loading="lazy"
|
|
91
|
+
className="w-full h-full object-cover"
|
|
92
|
+
onError={(e) => {
|
|
93
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
{isVideo && <VideoFrameThumbnail src={serveUrl} />}
|
|
98
|
+
{isVideo && hovered && (
|
|
99
|
+
<video
|
|
100
|
+
src={serveUrl}
|
|
101
|
+
autoPlay
|
|
102
|
+
muted
|
|
103
|
+
loop
|
|
104
|
+
playsInline
|
|
105
|
+
className="absolute inset-0 w-full h-full object-cover"
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
<div className="flex items-center gap-1.5">
|
|
110
|
+
<span
|
|
111
|
+
className={`text-xs font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
112
|
+
>
|
|
185
113
|
{name}
|
|
186
114
|
</span>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
115
|
+
<span className="text-[10px] text-neutral-600">{extension}</span>
|
|
116
|
+
{used && (
|
|
117
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
|
118
|
+
in use
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
) : (
|
|
124
|
+
<>
|
|
125
|
+
<div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
|
|
126
|
+
{isImage && (
|
|
127
|
+
<img
|
|
128
|
+
src={serveUrl}
|
|
129
|
+
alt={name}
|
|
130
|
+
loading="lazy"
|
|
131
|
+
className="w-full h-full object-cover"
|
|
132
|
+
onError={(e) => {
|
|
133
|
+
(e.target as HTMLImageElement).style.display = "none";
|
|
134
|
+
}}
|
|
135
|
+
/>
|
|
136
|
+
)}
|
|
137
|
+
{!isImage && (
|
|
138
|
+
<span className="text-[9px] font-medium text-neutral-700">{extension}</span>
|
|
191
139
|
)}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
140
|
+
</div>
|
|
141
|
+
<div className="min-w-0 flex-1">
|
|
142
|
+
<span
|
|
143
|
+
className={`text-xs font-medium truncate block ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
|
|
144
|
+
>
|
|
145
|
+
{name}
|
|
146
|
+
</span>
|
|
147
|
+
<div className="flex items-center gap-1.5">
|
|
148
|
+
<span className="text-[10px] text-neutral-600 truncate">{extension}</span>
|
|
149
|
+
{used && (
|
|
150
|
+
<span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
|
|
151
|
+
in use
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</>
|
|
157
|
+
)}
|
|
195
158
|
</div>
|
|
196
159
|
|
|
197
|
-
{/* Context menu */}
|
|
198
160
|
{contextMenu && (
|
|
199
|
-
<
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
className="absolute bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl py-1 min-w-[140px] text-xs"
|
|
209
|
-
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
210
|
-
>
|
|
211
|
-
<button
|
|
212
|
-
onClick={(e) => {
|
|
213
|
-
e.stopPropagation();
|
|
214
|
-
onCopy(asset);
|
|
215
|
-
setContextMenu(null);
|
|
216
|
-
}}
|
|
217
|
-
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
218
|
-
>
|
|
219
|
-
Copy path
|
|
220
|
-
</button>
|
|
221
|
-
{onRename && (
|
|
222
|
-
<button
|
|
223
|
-
onClick={(e) => {
|
|
224
|
-
e.stopPropagation();
|
|
225
|
-
setRenameName(name);
|
|
226
|
-
setRenaming(true);
|
|
227
|
-
setContextMenu(null);
|
|
228
|
-
}}
|
|
229
|
-
className="w-full text-left px-3 py-1.5 text-neutral-300 hover:bg-neutral-800 transition-colors"
|
|
230
|
-
>
|
|
231
|
-
Rename
|
|
232
|
-
</button>
|
|
233
|
-
)}
|
|
234
|
-
{onDelete && (
|
|
235
|
-
<button
|
|
236
|
-
onClick={(e) => {
|
|
237
|
-
e.stopPropagation();
|
|
238
|
-
setConfirmDelete(true);
|
|
239
|
-
setContextMenu(null);
|
|
240
|
-
}}
|
|
241
|
-
className="w-full text-left px-3 py-1.5 text-red-400 hover:bg-neutral-800 transition-colors"
|
|
242
|
-
>
|
|
243
|
-
Delete
|
|
244
|
-
</button>
|
|
245
|
-
)}
|
|
246
|
-
</div>
|
|
247
|
-
</div>
|
|
248
|
-
)}
|
|
249
|
-
|
|
250
|
-
{/* Delete confirmation */}
|
|
251
|
-
{confirmDelete && (
|
|
252
|
-
<div className="px-2 py-1.5 bg-red-950/30 border-l-2 border-red-500 flex items-center justify-between gap-2">
|
|
253
|
-
<span className="text-[10px] text-red-400 truncate">Delete {name}?</span>
|
|
254
|
-
<div className="flex items-center gap-1 flex-shrink-0">
|
|
255
|
-
<button
|
|
256
|
-
onClick={(e) => {
|
|
257
|
-
e.stopPropagation();
|
|
258
|
-
onDelete?.(asset);
|
|
259
|
-
setConfirmDelete(false);
|
|
260
|
-
}}
|
|
261
|
-
className="px-2 py-0.5 text-[10px] rounded bg-red-600 text-white hover:bg-red-500 transition-colors"
|
|
262
|
-
>
|
|
263
|
-
Delete
|
|
264
|
-
</button>
|
|
265
|
-
<button
|
|
266
|
-
onClick={(e) => {
|
|
267
|
-
e.stopPropagation();
|
|
268
|
-
setConfirmDelete(false);
|
|
269
|
-
}}
|
|
270
|
-
className="px-2 py-0.5 text-[10px] rounded text-neutral-400 hover:text-neutral-200 transition-colors"
|
|
271
|
-
>
|
|
272
|
-
Cancel
|
|
273
|
-
</button>
|
|
274
|
-
</div>
|
|
275
|
-
</div>
|
|
161
|
+
<ContextMenu
|
|
162
|
+
x={contextMenu.x}
|
|
163
|
+
y={contextMenu.y}
|
|
164
|
+
asset={asset}
|
|
165
|
+
onClose={() => setContextMenu(null)}
|
|
166
|
+
onCopy={onCopy}
|
|
167
|
+
onDelete={onDelete}
|
|
168
|
+
onRename={onRename}
|
|
169
|
+
/>
|
|
276
170
|
)}
|
|
277
171
|
</>
|
|
278
172
|
);
|
|
@@ -288,6 +182,51 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
288
182
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
289
183
|
const [dragOver, setDragOver] = useState(false);
|
|
290
184
|
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
|
185
|
+
const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
|
|
186
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
187
|
+
const [manifest, setManifest] = useState<
|
|
188
|
+
Map<string, { description?: string; duration?: number; width?: number; height?: number }>
|
|
189
|
+
>(new Map());
|
|
190
|
+
|
|
191
|
+
// Projects whose media manifest 404'd — most don't have one. Cache the miss so
|
|
192
|
+
// we don't re-fetch (and spam the console) on every re-render; the effect was
|
|
193
|
+
// also keyed on the `assets` array reference, which changes each render, so it
|
|
194
|
+
// re-fired constantly. Key on a stable join + skip known-missing manifests.
|
|
195
|
+
const manifest404Ref = useRef<Set<string>>(new Set());
|
|
196
|
+
const assetsKey = assets.join("|");
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
if (manifest404Ref.current.has(projectId)) return;
|
|
199
|
+
let cancelled = false;
|
|
200
|
+
fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`)
|
|
201
|
+
.then((r) => {
|
|
202
|
+
if (!r.ok) {
|
|
203
|
+
manifest404Ref.current.add(projectId);
|
|
204
|
+
return "";
|
|
205
|
+
}
|
|
206
|
+
return r.text();
|
|
207
|
+
})
|
|
208
|
+
.then((text) => {
|
|
209
|
+
if (cancelled || !text) return;
|
|
210
|
+
const m = new Map<
|
|
211
|
+
string,
|
|
212
|
+
{ description?: string; duration?: number; width?: number; height?: number }
|
|
213
|
+
>();
|
|
214
|
+
for (const line of text.split("\n")) {
|
|
215
|
+
if (!line.trim()) continue;
|
|
216
|
+
try {
|
|
217
|
+
const rec = JSON.parse(line);
|
|
218
|
+
if (rec.path) m.set(rec.path, rec);
|
|
219
|
+
} catch {
|
|
220
|
+
/* skip */
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
setManifest(m);
|
|
224
|
+
})
|
|
225
|
+
.catch(() => {});
|
|
226
|
+
return () => {
|
|
227
|
+
cancelled = true;
|
|
228
|
+
};
|
|
229
|
+
}, [projectId, assetsKey]);
|
|
291
230
|
|
|
292
231
|
const handleDrop = useCallback(
|
|
293
232
|
(e: React.DragEvent) => {
|
|
@@ -306,7 +245,56 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
306
245
|
}
|
|
307
246
|
}, []);
|
|
308
247
|
|
|
309
|
-
const
|
|
248
|
+
const elements = usePlayerStore((s) => s.elements);
|
|
249
|
+
const usedPaths = useMemo(() => {
|
|
250
|
+
const paths = new Set<string>();
|
|
251
|
+
for (const el of elements) {
|
|
252
|
+
if (el.src) {
|
|
253
|
+
const src = el.src.replace(/^\/api\/projects\/[^/]+\/preview\//, "");
|
|
254
|
+
paths.add(src);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return paths;
|
|
258
|
+
}, [elements]);
|
|
259
|
+
|
|
260
|
+
const mediaAssets = useMemo(() => {
|
|
261
|
+
const all = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
|
|
262
|
+
if (!searchQuery) return all;
|
|
263
|
+
const q = searchQuery.toLowerCase();
|
|
264
|
+
return all.filter((a) => {
|
|
265
|
+
if (basename(a).toLowerCase().includes(q)) return true;
|
|
266
|
+
const rec = manifest.get(a);
|
|
267
|
+
return rec?.description?.toLowerCase().includes(q);
|
|
268
|
+
});
|
|
269
|
+
}, [assets, searchQuery, manifest]);
|
|
270
|
+
|
|
271
|
+
const categorized = useMemo(() => {
|
|
272
|
+
const groups: Record<MediaCategory, string[]> = { audio: [], images: [], video: [], fonts: [] };
|
|
273
|
+
for (const a of mediaAssets) {
|
|
274
|
+
const cat = getCategory(a);
|
|
275
|
+
if (cat) groups[cat].push(a);
|
|
276
|
+
}
|
|
277
|
+
// Sort: used assets first within each category
|
|
278
|
+
for (const cat of FILTER_ORDER) {
|
|
279
|
+
groups[cat].sort((a, b) => {
|
|
280
|
+
const aUsed = usedPaths.has(a) ? 0 : 1;
|
|
281
|
+
const bUsed = usedPaths.has(b) ? 0 : 1;
|
|
282
|
+
return aUsed - bUsed;
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
return groups;
|
|
286
|
+
}, [mediaAssets, usedPaths]);
|
|
287
|
+
|
|
288
|
+
const counts = useMemo(() => {
|
|
289
|
+
const c: Record<string, number> = { all: mediaAssets.length };
|
|
290
|
+
for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length;
|
|
291
|
+
return c;
|
|
292
|
+
}, [mediaAssets, categorized]);
|
|
293
|
+
|
|
294
|
+
const visibleCategories =
|
|
295
|
+
activeFilter === "all"
|
|
296
|
+
? FILTER_ORDER.filter((c) => categorized[c].length > 0)
|
|
297
|
+
: [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0);
|
|
310
298
|
|
|
311
299
|
return (
|
|
312
300
|
<div
|
|
@@ -318,44 +306,111 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
318
306
|
onDragLeave={() => setDragOver(false)}
|
|
319
307
|
onDrop={handleDrop}
|
|
320
308
|
>
|
|
321
|
-
{/*
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
width="12"
|
|
330
|
-
height="12"
|
|
331
|
-
viewBox="0 0 24 24"
|
|
332
|
-
fill="none"
|
|
333
|
-
stroke="currentColor"
|
|
334
|
-
strokeWidth="2.5"
|
|
335
|
-
strokeLinecap="round"
|
|
309
|
+
{/* Header — matches design panel Section pattern */}
|
|
310
|
+
<div className="px-4 pt-2.5 pb-1.5 flex-shrink-0">
|
|
311
|
+
{/* Import */}
|
|
312
|
+
{onImport && (
|
|
313
|
+
<>
|
|
314
|
+
<button
|
|
315
|
+
onClick={() => fileInputRef.current?.click()}
|
|
316
|
+
className="w-full flex items-center justify-center gap-1.5 rounded-md bg-panel-input px-3 py-[7px] text-[11px] font-medium text-panel-text-3 hover:text-panel-text-1 transition-colors mb-2.5"
|
|
336
317
|
>
|
|
337
|
-
<
|
|
318
|
+
<svg
|
|
319
|
+
width="11"
|
|
320
|
+
height="11"
|
|
321
|
+
viewBox="0 0 24 24"
|
|
322
|
+
fill="none"
|
|
323
|
+
stroke="currentColor"
|
|
324
|
+
strokeWidth="2.5"
|
|
325
|
+
strokeLinecap="round"
|
|
326
|
+
>
|
|
327
|
+
<path d="M12 5v14M5 12h14" />
|
|
328
|
+
</svg>
|
|
329
|
+
Import media
|
|
330
|
+
</button>
|
|
331
|
+
<input
|
|
332
|
+
ref={fileInputRef}
|
|
333
|
+
type="file"
|
|
334
|
+
accept="video/*,image/*,audio/*,font/*"
|
|
335
|
+
multiple
|
|
336
|
+
className="hidden"
|
|
337
|
+
onChange={(e) => {
|
|
338
|
+
if (e.target.files?.length) {
|
|
339
|
+
onImport(e.target.files);
|
|
340
|
+
e.target.value = "";
|
|
341
|
+
}
|
|
342
|
+
}}
|
|
343
|
+
/>
|
|
344
|
+
</>
|
|
345
|
+
)}
|
|
346
|
+
|
|
347
|
+
{/* Search */}
|
|
348
|
+
{mediaAssets.length > 0 && (
|
|
349
|
+
<div className="flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] mb-2">
|
|
350
|
+
<svg width="12" height="12" viewBox="0 0 256 256" fill="none" className="flex-shrink-0">
|
|
351
|
+
<circle
|
|
352
|
+
cx="116"
|
|
353
|
+
cy="116"
|
|
354
|
+
r="76"
|
|
355
|
+
stroke="currentColor"
|
|
356
|
+
strokeWidth="22"
|
|
357
|
+
className="text-panel-text-5"
|
|
358
|
+
/>
|
|
359
|
+
<line
|
|
360
|
+
x1="170"
|
|
361
|
+
y1="170"
|
|
362
|
+
x2="232"
|
|
363
|
+
y2="232"
|
|
364
|
+
stroke="currentColor"
|
|
365
|
+
strokeWidth="22"
|
|
366
|
+
strokeLinecap="round"
|
|
367
|
+
className="text-panel-text-5"
|
|
368
|
+
/>
|
|
338
369
|
</svg>
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
370
|
+
<input
|
|
371
|
+
type="text"
|
|
372
|
+
value={searchQuery}
|
|
373
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
374
|
+
placeholder="Search assets..."
|
|
375
|
+
className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
|
|
376
|
+
/>
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
{/* Filter chips — panel-input style */}
|
|
381
|
+
{mediaAssets.length > 0 && (
|
|
382
|
+
<div className="flex gap-1.5 flex-wrap">
|
|
383
|
+
<button
|
|
384
|
+
onClick={() => setActiveFilter("all")}
|
|
385
|
+
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
|
386
|
+
activeFilter === "all"
|
|
387
|
+
? "bg-panel-accent/15 text-panel-accent"
|
|
388
|
+
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
|
389
|
+
}`}
|
|
390
|
+
>
|
|
391
|
+
All {counts.all}
|
|
392
|
+
</button>
|
|
393
|
+
{FILTER_ORDER.map((cat) =>
|
|
394
|
+
counts[cat] > 0 ? (
|
|
395
|
+
<button
|
|
396
|
+
key={cat}
|
|
397
|
+
onClick={() => setActiveFilter(activeFilter === cat ? "all" : cat)}
|
|
398
|
+
className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
|
|
399
|
+
activeFilter === cat
|
|
400
|
+
? "bg-panel-accent/15 text-panel-accent"
|
|
401
|
+
: "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
|
|
402
|
+
}`}
|
|
403
|
+
>
|
|
404
|
+
{CATEGORY_LABELS[cat]} {counts[cat]}
|
|
405
|
+
</button>
|
|
406
|
+
) : null,
|
|
407
|
+
)}
|
|
408
|
+
</div>
|
|
409
|
+
)}
|
|
410
|
+
</div>
|
|
356
411
|
|
|
357
412
|
{/* Asset list */}
|
|
358
|
-
<div className="flex-1 overflow-y-auto">
|
|
413
|
+
<div className="flex-1 overflow-y-auto mt-1">
|
|
359
414
|
{mediaAssets.length === 0 ? (
|
|
360
415
|
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
|
|
361
416
|
<svg
|
|
@@ -378,16 +433,59 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
378
433
|
<p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
|
|
379
434
|
</div>
|
|
380
435
|
) : (
|
|
381
|
-
|
|
382
|
-
<
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
436
|
+
visibleCategories.map((cat) => (
|
|
437
|
+
<div key={cat} className="mb-1">
|
|
438
|
+
{activeFilter === "all" && (
|
|
439
|
+
<div className="flex items-center gap-2 px-4 py-2 border-t border-panel-border">
|
|
440
|
+
<h3 className="text-[12px] font-semibold text-panel-text-1">
|
|
441
|
+
{CATEGORY_LABELS[cat]}
|
|
442
|
+
</h3>
|
|
443
|
+
<span className="text-[11px] text-panel-text-5">{categorized[cat].length}</span>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
{cat === "audio" &&
|
|
447
|
+
categorized[cat].map((a) => (
|
|
448
|
+
<AudioRow
|
|
449
|
+
key={a}
|
|
450
|
+
projectId={projectId}
|
|
451
|
+
asset={a}
|
|
452
|
+
used={usedPaths.has(a)}
|
|
453
|
+
meta={manifest.get(a)}
|
|
454
|
+
onCopy={handleCopyPath}
|
|
455
|
+
isCopied={copiedPath === a}
|
|
456
|
+
onDelete={onDelete}
|
|
457
|
+
onRename={onRename}
|
|
458
|
+
/>
|
|
459
|
+
))}
|
|
460
|
+
{(cat === "images" || cat === "video") &&
|
|
461
|
+
categorized[cat].map((a) => (
|
|
462
|
+
<ImageCard
|
|
463
|
+
key={a}
|
|
464
|
+
projectId={projectId}
|
|
465
|
+
asset={a}
|
|
466
|
+
used={usedPaths.has(a)}
|
|
467
|
+
onCopy={handleCopyPath}
|
|
468
|
+
isCopied={copiedPath === a}
|
|
469
|
+
onDelete={onDelete}
|
|
470
|
+
onRename={onRename}
|
|
471
|
+
size={categorized[cat].length <= 4 ? "large" : "small"}
|
|
472
|
+
/>
|
|
473
|
+
))}
|
|
474
|
+
{cat === "fonts" &&
|
|
475
|
+
categorized[cat].map((a) => (
|
|
476
|
+
<ImageCard
|
|
477
|
+
key={a}
|
|
478
|
+
projectId={projectId}
|
|
479
|
+
asset={a}
|
|
480
|
+
used={usedPaths.has(a)}
|
|
481
|
+
onCopy={handleCopyPath}
|
|
482
|
+
isCopied={copiedPath === a}
|
|
483
|
+
onDelete={onDelete}
|
|
484
|
+
onRename={onRename}
|
|
485
|
+
size="small"
|
|
486
|
+
/>
|
|
487
|
+
))}
|
|
488
|
+
</div>
|
|
391
489
|
))
|
|
392
490
|
)}
|
|
393
491
|
</div>
|