@hyperframes/studio 0.7.5 → 0.7.7

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 (77) hide show
  1. package/dist/assets/{index-BwFzbjZQ.js → index-B2YXvFxf.js} +1 -1
  2. package/dist/assets/index-BSkUuN8g.css +1 -0
  3. package/dist/assets/index-BeRh2hMe.js +375 -0
  4. package/dist/assets/{index-C5NAfiPa.js → index-BoASKOeE.js} +1 -1
  5. package/dist/chunk-KZXYQYIU.js +876 -0
  6. package/dist/chunk-KZXYQYIU.js.map +1 -0
  7. package/dist/domEditingLayers-SSXQZHHQ.js +41 -0
  8. package/dist/domEditingLayers-SSXQZHHQ.js.map +1 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +3326 -2939
  12. package/dist/index.js.map +1 -1
  13. package/package.json +5 -5
  14. package/src/captions/hooks/useCaptionSync.ts +6 -1
  15. package/src/components/StudioPreviewArea.tsx +10 -3
  16. package/src/components/StudioRightPanel.tsx +4 -0
  17. package/src/components/editor/AnimationCard.tsx +77 -250
  18. package/src/components/editor/AnimationCardParts.tsx +220 -0
  19. package/src/components/editor/BlockParamsPanel.tsx +4 -8
  20. package/src/components/editor/DomEditOverlay.tsx +132 -48
  21. package/src/components/editor/EaseCurveSection.tsx +177 -101
  22. package/src/components/editor/GsapAnimationSection.tsx +4 -0
  23. package/src/components/editor/KeyframeEaseList.tsx +127 -0
  24. package/src/components/editor/OffCanvasIndicators.tsx +111 -0
  25. package/src/components/editor/PropertyPanel.tsx +63 -56
  26. package/src/components/editor/gsapAnimationCallbacks.ts +3 -0
  27. package/src/components/editor/gsapAnimationConstants.ts +11 -32
  28. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  29. package/src/components/editor/manualOffsetDrag.ts +8 -0
  30. package/src/components/editor/marqueeCommit.ts +168 -0
  31. package/src/components/editor/propertyPanelHelpers.ts +7 -0
  32. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  33. package/src/components/editor/snapTargetCollection.ts +0 -5
  34. package/src/components/editor/useMotionPathData.ts +2 -1
  35. package/src/components/panels/SlideshowPanel.tsx +0 -1
  36. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  37. package/src/components/sidebar/AssetsTab.tsx +542 -254
  38. package/src/components/sidebar/assetHelpers.ts +40 -0
  39. package/src/contexts/DomEditContext.tsx +12 -0
  40. package/src/hooks/gsapDragCommit.ts +101 -0
  41. package/src/hooks/gsapDragPositionCommit.ts +1 -2
  42. package/src/hooks/gsapRuntimeBridge.ts +28 -2
  43. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  44. package/src/hooks/gsapRuntimeKeyframes.ts +13 -0
  45. package/src/hooks/gsapRuntimeReaders.ts +1 -6
  46. package/src/hooks/useDomEditCommits.ts +0 -4
  47. package/src/hooks/useDomEditPreviewSync.ts +5 -0
  48. package/src/hooks/useDomEditSession.ts +43 -0
  49. package/src/hooks/useDomEditTextCommits.ts +3 -12
  50. package/src/hooks/useDomEditWiring.ts +3 -0
  51. package/src/hooks/useDomSelection.ts +46 -0
  52. package/src/hooks/useGestureCommit.ts +26 -7
  53. package/src/hooks/useGestureRecording.ts +2 -16
  54. package/src/hooks/useGsapAnimationOps.ts +2 -2
  55. package/src/hooks/useGsapAwareEditing.ts +0 -4
  56. package/src/hooks/useGsapScriptCommits.ts +0 -12
  57. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  58. package/src/hooks/useGsapTweenCache.ts +151 -42
  59. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  60. package/src/hooks/useRazorSplit.ts +0 -3
  61. package/src/player/components/Player.tsx +0 -5
  62. package/src/player/components/timelineIcons.tsx +2 -1
  63. package/src/player/hooks/useTimelinePlayer.ts +4 -14
  64. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -9
  65. package/src/player/lib/timelineIframeHelpers.ts +2 -6
  66. package/src/telemetry/client.ts +2 -0
  67. package/src/utils/gestureSmoother.test.ts +48 -0
  68. package/src/utils/gestureSmoother.ts +46 -0
  69. package/src/utils/marqueeGeometry.test.ts +123 -0
  70. package/src/utils/marqueeGeometry.ts +172 -0
  71. package/src/utils/optimisticUpdate.ts +1 -2
  72. package/src/utils/sourcePatcher.ts +0 -10
  73. package/src/utils/velocityEaseFitter.test.ts +58 -0
  74. package/src/utils/velocityEaseFitter.ts +121 -0
  75. package/dist/assets/index-D_JGXmfx.js +0 -374
  76. package/dist/assets/index-DzWIinxk.css +0 -1
  77. package/src/utils/editDebugLog.ts +0 -16
@@ -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, AUDIO_EXT } from "../../utils/mediaTypes";
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, DeleteConfirm } from "./AssetContextMenu";
7
+ import { usePlayerStore } from "../../player/store/playerStore";
8
+ import {
9
+ type MediaCategory,
10
+ getCategory,
11
+ getAudioSubtype,
12
+ basename,
13
+ ext,
14
+ CATEGORY_LABELS,
15
+ FILTER_ORDER,
16
+ } from "./assetHelpers";
6
17
 
7
18
  interface AssetsTabProps {
8
19
  projectId: string;
@@ -12,98 +23,244 @@ interface AssetsTabProps {
12
23
  onRename?: (oldPath: string, newPath: string) => void;
13
24
  }
14
25
 
15
- /** Inline thumbnail content — rendered inside the container div in AssetCard. */
16
- function AssetThumbnail({
17
- serveUrl,
18
- name,
19
- isImage,
20
- isVideo,
21
- isAudio,
26
+ function AudioRow({
27
+ projectId,
28
+ asset,
29
+ used,
30
+ meta,
31
+ onCopy,
32
+ isCopied,
33
+ onDelete,
34
+ onRename,
22
35
  }: {
23
- serveUrl: string;
24
- name: string;
25
- isImage: boolean;
26
- isVideo: boolean;
27
- isAudio: boolean;
36
+ projectId: string;
37
+ asset: string;
38
+ used: boolean;
39
+ meta?: { description?: string; duration?: number };
40
+ onCopy: (path: string) => void;
41
+ isCopied: boolean;
42
+ onDelete?: (path: string) => void;
43
+ onRename?: (oldPath: string, newPath: string) => void;
28
44
  }) {
45
+ const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
46
+ const [confirmDelete, setConfirmDelete] = useState(false);
47
+ const [playing, setPlaying] = useState(false);
48
+ const [bars, setBars] = useState<number[]>([]);
49
+ const audioRef = useRef<HTMLAudioElement | null>(null);
50
+ const actxRef = useRef<AudioContext | null>(null);
51
+ const analyserRef = useRef<AnalyserNode | null>(null);
52
+ const sourceRef = useRef<MediaElementAudioSourceNode | null>(null);
53
+ const animRef = useRef<number>(0);
54
+ const name = basename(asset);
55
+ const subtype = getAudioSubtype(asset);
56
+ const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
57
+
58
+ useEffect(() => {
59
+ return () => {
60
+ cancelAnimationFrame(animRef.current);
61
+ audioRef.current?.pause();
62
+ actxRef.current?.close();
63
+ };
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ if (playing) {
68
+ const barCount = 24;
69
+ const loop = () => {
70
+ const analyser = analyserRef.current;
71
+ if (!analyser) {
72
+ animRef.current = requestAnimationFrame(loop);
73
+ return;
74
+ }
75
+ const data = new Uint8Array(analyser.frequencyBinCount);
76
+ analyser.getByteFrequencyData(data);
77
+ const step = Math.floor(data.length / barCount);
78
+ const next: number[] = [];
79
+ for (let i = 0; i < barCount; i++) {
80
+ let sum = 0;
81
+ for (let j = 0; j < step; j++) sum += data[i * step + j];
82
+ next.push(sum / step / 255);
83
+ }
84
+ setBars(next);
85
+ if (audioRef.current && !audioRef.current.paused)
86
+ animRef.current = requestAnimationFrame(loop);
87
+ };
88
+ animRef.current = requestAnimationFrame(loop);
89
+ } else {
90
+ setBars([]);
91
+ }
92
+ return () => cancelAnimationFrame(animRef.current);
93
+ }, [playing]);
94
+
95
+ const togglePlay = useCallback(async () => {
96
+ if (playing) {
97
+ audioRef.current?.pause();
98
+ setPlaying(false);
99
+ cancelAnimationFrame(animRef.current);
100
+ return;
101
+ }
102
+
103
+ if (!actxRef.current) {
104
+ actxRef.current = new AudioContext();
105
+ analyserRef.current = actxRef.current.createAnalyser();
106
+ analyserRef.current.fftSize = 256;
107
+ analyserRef.current.smoothingTimeConstant = 0.7;
108
+ }
109
+
110
+ if (!audioRef.current) {
111
+ const el = new Audio();
112
+ el.onended = () => {
113
+ setPlaying(false);
114
+ cancelAnimationFrame(animRef.current);
115
+ };
116
+ audioRef.current = el;
117
+ sourceRef.current = actxRef.current.createMediaElementSource(el);
118
+ sourceRef.current.connect(analyserRef.current!);
119
+ analyserRef.current!.connect(actxRef.current.destination);
120
+ el.src = serveUrl;
121
+ }
122
+
123
+ if (actxRef.current.state === "suspended") await actxRef.current.resume();
124
+ audioRef.current.currentTime = 0;
125
+ await audioRef.current.play();
126
+ setPlaying(true);
127
+ }, [serveUrl, playing]);
128
+
29
129
  return (
30
130
  <>
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";
131
+ <div
132
+ draggable
133
+ onClick={() => onCopy(asset)}
134
+ onDragStart={(e) => {
135
+ e.dataTransfer.effectAllowed = "copy";
136
+ e.dataTransfer.setData(TIMELINE_ASSET_MIME, JSON.stringify({ path: asset }));
137
+ e.dataTransfer.setData("text/plain", asset);
138
+ }}
139
+ onContextMenu={(e) => {
140
+ e.preventDefault();
141
+ setContextMenu({ x: e.clientX, y: e.clientY });
142
+ }}
143
+ className={`group w-full text-left px-4 py-1.5 flex items-center gap-2.5 transition-all cursor-pointer ${
144
+ playing
145
+ ? "bg-panel-accent/[0.06]"
146
+ : isCopied
147
+ ? "bg-panel-accent/10"
148
+ : "hover:bg-panel-surface-hover"
149
+ }`}
150
+ >
151
+ <button
152
+ className={`w-7 h-7 rounded-md flex-shrink-0 flex items-center justify-center transition-all ${
153
+ playing
154
+ ? "bg-panel-accent/15 text-panel-accent"
155
+ : "text-panel-text-5 group-hover:text-panel-text-3"
156
+ }`}
157
+ onClick={(e) => {
158
+ e.stopPropagation();
159
+ togglePlay();
39
160
  }}
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>
161
+ >
162
+ {playing ? (
163
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
164
+ <rect x="6" y="4" width="4" height="16" rx="1" />
165
+ <rect x="14" y="4" width="4" height="16" rx="1" />
166
+ </svg>
167
+ ) : (
168
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
169
+ <polygon points="6,4 20,12 6,20" />
170
+ </svg>
171
+ )}
172
+ </button>
173
+ <div className="min-w-0 flex-1">
174
+ <div className="flex items-center gap-1.5">
175
+ <span
176
+ className={`text-[12px] font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
177
+ >
178
+ {name}
179
+ </span>
180
+ {!playing && (
181
+ <span className="text-[11px] text-panel-text-5 flex-shrink-0">
182
+ {meta?.duration ? `${meta.duration}s · ` : ""}
183
+ {subtype}
184
+ </span>
185
+ )}
186
+ {used && (
187
+ <span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded flex-shrink-0">
188
+ in use
189
+ </span>
190
+ )}
191
+ </div>
192
+ {bars.length > 0 && (
193
+ <div className="flex items-end gap-[2px] h-[14px] mt-0.5">
194
+ {bars.map((v, i) => (
195
+ <div
196
+ key={i}
197
+ className="flex-1 rounded-[1px]"
198
+ style={{
199
+ height: `${Math.max(10, v * 100)}%`,
200
+ background: `linear-gradient(to top, rgba(60, 230, 172, ${0.3 + v * 0.5}), rgba(60, 230, 172, ${0.5 + v * 0.5}))`,
201
+ transition: "height 80ms ease-out",
202
+ }}
203
+ />
204
+ ))}
205
+ </div>
206
+ )}
58
207
  </div>
208
+ </div>
209
+
210
+ {contextMenu && (
211
+ <ContextMenu
212
+ x={contextMenu.x}
213
+ y={contextMenu.y}
214
+ asset={asset}
215
+ onClose={() => setContextMenu(null)}
216
+ onCopy={onCopy}
217
+ onDelete={onDelete}
218
+ onRename={onRename}
219
+ />
59
220
  )}
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>
221
+ {confirmDelete && (
222
+ <DeleteConfirm
223
+ name={name}
224
+ onConfirm={() => {
225
+ onDelete?.(asset);
226
+ setConfirmDelete(false);
227
+ }}
228
+ onCancel={() => setConfirmDelete(false)}
229
+ />
79
230
  )}
80
231
  </>
81
232
  );
82
233
  }
83
234
 
84
- function AssetCard({
235
+ function ImageCard({
85
236
  projectId,
86
237
  asset,
238
+ used,
87
239
  onCopy,
88
240
  isCopied,
89
241
  onDelete,
90
242
  onRename,
243
+ size,
91
244
  }: {
92
245
  projectId: string;
93
246
  asset: string;
247
+ used: boolean;
94
248
  onCopy: (path: string) => void;
95
249
  isCopied: boolean;
96
250
  onDelete?: (path: string) => void;
97
251
  onRename?: (oldPath: string, newPath: string) => void;
252
+ size: "large" | "small";
98
253
  }) {
99
- const [hovered, setHovered] = useState(false);
100
254
  const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
101
- const [renaming, setRenaming] = useState(false);
102
- const [renameName, setRenameName] = useState("");
103
- const [confirmDelete, setConfirmDelete] = useState(false);
104
- const name = asset.split("/").pop() ?? asset;
255
+ const [hovered, setHovered] = useState(false);
256
+ const name = basename(asset);
257
+ const extension = ext(asset);
105
258
  const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
106
259
  const isVideo = VIDEO_EXT.test(asset);
260
+ const isImage = IMAGE_EXT.test(asset);
261
+
262
+ const thumbW = size === "large" ? "w-full" : "w-[50px]";
263
+ const thumbH = size === "large" ? "h-[100px]" : "h-[32px]";
107
264
 
108
265
  return (
109
266
  <>
@@ -121,158 +278,103 @@ function AssetCard({
121
278
  }}
122
279
  onPointerEnter={() => setHovered(true)}
123
280
  onPointerLeave={() => setHovered(false)}
124
- className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
125
- isCopied
126
- ? "bg-studio-accent/10 border-l-2 border-studio-accent"
127
- : "border-l-2 border-transparent hover:bg-neutral-800/50"
281
+ className={`transition-colors cursor-pointer ${
282
+ size === "large"
283
+ ? `px-2.5 py-1 ${isCopied ? "bg-studio-accent/10" : "hover:bg-neutral-800/30"}`
284
+ : `px-2.5 py-1.5 flex items-center gap-2.5 ${
285
+ isCopied
286
+ ? "bg-studio-accent/10 border-l-2 border-studio-accent"
287
+ : "border-l-2 border-transparent hover:bg-neutral-800/50"
288
+ }`
128
289
  }`}
129
290
  >
130
- <div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
131
- <AssetThumbnail
132
- serveUrl={serveUrl}
133
- name={name}
134
- isImage={IMAGE_EXT.test(asset)}
135
- isVideo={isVideo}
136
- isAudio={AUDIO_EXT.test(asset)}
137
- />
138
- {isVideo && hovered && (
139
- <video
140
- src={serveUrl}
141
- autoPlay
142
- muted
143
- loop
144
- playsInline
145
- className="absolute inset-0 w-full h-full object-contain"
146
- />
147
- )}
148
- </div>
149
- <div className="min-w-0 flex-1">
150
- {renaming ? (
151
- <input
152
- autoFocus
153
- value={renameName}
154
- onChange={(e) => setRenameName(e.target.value)}
155
- onKeyDown={(e) => {
156
- if (e.key === "Enter") {
157
- e.preventDefault();
158
- const trimmed = renameName.trim();
159
- if (trimmed && trimmed !== name) {
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">
291
+ {size === "large" ? (
292
+ <div className="flex flex-col gap-1">
293
+ <div className={`${thumbW} ${thumbH} rounded overflow-hidden bg-neutral-900 relative`}>
294
+ {isImage && (
295
+ <img
296
+ src={serveUrl}
297
+ alt={name}
298
+ loading="lazy"
299
+ className="w-full h-full object-cover"
300
+ onError={(e) => {
301
+ (e.target as HTMLImageElement).style.display = "none";
302
+ }}
303
+ />
304
+ )}
305
+ {isVideo && <VideoFrameThumbnail src={serveUrl} />}
306
+ {isVideo && hovered && (
307
+ <video
308
+ src={serveUrl}
309
+ autoPlay
310
+ muted
311
+ loop
312
+ playsInline
313
+ className="absolute inset-0 w-full h-full object-cover"
314
+ />
315
+ )}
316
+ </div>
317
+ <div className="flex items-center gap-1.5">
318
+ <span
319
+ className={`text-xs font-medium truncate ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
320
+ >
185
321
  {name}
186
322
  </span>
187
- {isCopied ? (
188
- <span className="text-[9px] text-studio-accent">Copied!</span>
189
- ) : (
190
- <span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
323
+ <span className="text-[10px] text-neutral-600">{extension}</span>
324
+ {used && (
325
+ <span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
326
+ in use
327
+ </span>
191
328
  )}
192
- </>
193
- )}
194
- </div>
329
+ </div>
330
+ </div>
331
+ ) : (
332
+ <>
333
+ <div className="w-[50px] h-[32px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 flex items-center justify-center">
334
+ {isImage && (
335
+ <img
336
+ src={serveUrl}
337
+ alt={name}
338
+ loading="lazy"
339
+ className="w-full h-full object-cover"
340
+ onError={(e) => {
341
+ (e.target as HTMLImageElement).style.display = "none";
342
+ }}
343
+ />
344
+ )}
345
+ {!isImage && (
346
+ <span className="text-[9px] font-medium text-neutral-700">{extension}</span>
347
+ )}
348
+ </div>
349
+ <div className="min-w-0 flex-1">
350
+ <span
351
+ className={`text-xs font-medium truncate block ${used ? "text-panel-text-1" : "text-panel-text-3"}`}
352
+ >
353
+ {name}
354
+ </span>
355
+ <div className="flex items-center gap-1.5">
356
+ <span className="text-[10px] text-neutral-600 truncate">{extension}</span>
357
+ {used && (
358
+ <span className="text-[9px] font-medium text-panel-accent bg-panel-accent/10 px-1.5 py-px rounded">
359
+ in use
360
+ </span>
361
+ )}
362
+ </div>
363
+ </div>
364
+ </>
365
+ )}
195
366
  </div>
196
367
 
197
- {/* Context menu */}
198
368
  {contextMenu && (
199
- <div
200
- className="fixed inset-0 z-[200]"
201
- onClick={() => setContextMenu(null)}
202
- onContextMenu={(e) => {
203
- e.preventDefault();
204
- setContextMenu(null);
205
- }}
206
- >
207
- <div
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>
369
+ <ContextMenu
370
+ x={contextMenu.x}
371
+ y={contextMenu.y}
372
+ asset={asset}
373
+ onClose={() => setContextMenu(null)}
374
+ onCopy={onCopy}
375
+ onDelete={onDelete}
376
+ onRename={onRename}
377
+ />
276
378
  )}
277
379
  </>
278
380
  );
@@ -288,6 +390,33 @@ export const AssetsTab = memo(function AssetsTab({
288
390
  const fileInputRef = useRef<HTMLInputElement>(null);
289
391
  const [dragOver, setDragOver] = useState(false);
290
392
  const [copiedPath, setCopiedPath] = useState<string | null>(null);
393
+ const [activeFilter, setActiveFilter] = useState<MediaCategory | "all">("all");
394
+ const [searchQuery, setSearchQuery] = useState("");
395
+ const [manifest, setManifest] = useState<
396
+ Map<string, { description?: string; duration?: number; width?: number; height?: number }>
397
+ >(new Map());
398
+
399
+ useEffect(() => {
400
+ fetch(`/api/projects/${projectId}/preview/.media/manifest.jsonl`)
401
+ .then((r) => (r.ok ? r.text() : ""))
402
+ .then((text) => {
403
+ const m = new Map<
404
+ string,
405
+ { description?: string; duration?: number; width?: number; height?: number }
406
+ >();
407
+ for (const line of text.split("\n")) {
408
+ if (!line.trim()) continue;
409
+ try {
410
+ const rec = JSON.parse(line);
411
+ if (rec.path) m.set(rec.path, rec);
412
+ } catch {
413
+ /* skip */
414
+ }
415
+ }
416
+ setManifest(m);
417
+ })
418
+ .catch(() => {});
419
+ }, [projectId, assets]);
291
420
 
292
421
  const handleDrop = useCallback(
293
422
  (e: React.DragEvent) => {
@@ -306,7 +435,56 @@ export const AssetsTab = memo(function AssetsTab({
306
435
  }
307
436
  }, []);
308
437
 
309
- const mediaAssets = assets.filter((a) => MEDIA_EXT.test(a));
438
+ const elements = usePlayerStore((s) => s.elements);
439
+ const usedPaths = useMemo(() => {
440
+ const paths = new Set<string>();
441
+ for (const el of elements) {
442
+ if (el.src) {
443
+ const src = el.src.replace(/^\/api\/projects\/[^/]+\/preview\//, "");
444
+ paths.add(src);
445
+ }
446
+ }
447
+ return paths;
448
+ }, [elements]);
449
+
450
+ const mediaAssets = useMemo(() => {
451
+ const all = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a));
452
+ if (!searchQuery) return all;
453
+ const q = searchQuery.toLowerCase();
454
+ return all.filter((a) => {
455
+ if (basename(a).toLowerCase().includes(q)) return true;
456
+ const rec = manifest.get(a);
457
+ return rec?.description?.toLowerCase().includes(q);
458
+ });
459
+ }, [assets, searchQuery, manifest]);
460
+
461
+ const categorized = useMemo(() => {
462
+ const groups: Record<MediaCategory, string[]> = { audio: [], images: [], video: [], fonts: [] };
463
+ for (const a of mediaAssets) {
464
+ const cat = getCategory(a);
465
+ if (cat) groups[cat].push(a);
466
+ }
467
+ // Sort: used assets first within each category
468
+ for (const cat of FILTER_ORDER) {
469
+ groups[cat].sort((a, b) => {
470
+ const aUsed = usedPaths.has(a) ? 0 : 1;
471
+ const bUsed = usedPaths.has(b) ? 0 : 1;
472
+ return aUsed - bUsed;
473
+ });
474
+ }
475
+ return groups;
476
+ }, [mediaAssets, usedPaths]);
477
+
478
+ const counts = useMemo(() => {
479
+ const c: Record<string, number> = { all: mediaAssets.length };
480
+ for (const cat of FILTER_ORDER) c[cat] = categorized[cat].length;
481
+ return c;
482
+ }, [mediaAssets, categorized]);
483
+
484
+ const visibleCategories =
485
+ activeFilter === "all"
486
+ ? FILTER_ORDER.filter((c) => categorized[c].length > 0)
487
+ : [activeFilter as MediaCategory].filter((c) => categorized[c].length > 0);
310
488
 
311
489
  return (
312
490
  <div
@@ -318,44 +496,111 @@ export const AssetsTab = memo(function AssetsTab({
318
496
  onDragLeave={() => setDragOver(false)}
319
497
  onDrop={handleDrop}
320
498
  >
321
- {/* Import button */}
322
- {onImport && (
323
- <div className="px-3 py-2 border-b border-neutral-800/40 flex-shrink-0">
324
- <button
325
- onClick={() => fileInputRef.current?.click()}
326
- className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 text-[11px] rounded-lg border border-dashed border-neutral-700/50 text-neutral-500 hover:text-neutral-300 hover:border-neutral-600 transition-colors"
327
- >
328
- <svg
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"
499
+ {/* Header matches design panel Section pattern */}
500
+ <div className="px-4 pt-2.5 pb-1.5 flex-shrink-0">
501
+ {/* Import */}
502
+ {onImport && (
503
+ <>
504
+ <button
505
+ onClick={() => fileInputRef.current?.click()}
506
+ 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
507
  >
337
- <path d="M12 5v14M5 12h14" />
508
+ <svg
509
+ width="11"
510
+ height="11"
511
+ viewBox="0 0 24 24"
512
+ fill="none"
513
+ stroke="currentColor"
514
+ strokeWidth="2.5"
515
+ strokeLinecap="round"
516
+ >
517
+ <path d="M12 5v14M5 12h14" />
518
+ </svg>
519
+ Import media
520
+ </button>
521
+ <input
522
+ ref={fileInputRef}
523
+ type="file"
524
+ accept="video/*,image/*,audio/*,font/*"
525
+ multiple
526
+ className="hidden"
527
+ onChange={(e) => {
528
+ if (e.target.files?.length) {
529
+ onImport(e.target.files);
530
+ e.target.value = "";
531
+ }
532
+ }}
533
+ />
534
+ </>
535
+ )}
536
+
537
+ {/* Search */}
538
+ {mediaAssets.length > 0 && (
539
+ <div className="flex items-center gap-1.5 rounded-md bg-panel-input px-2.5 py-[5px] mb-2">
540
+ <svg width="12" height="12" viewBox="0 0 256 256" fill="none" className="flex-shrink-0">
541
+ <circle
542
+ cx="116"
543
+ cy="116"
544
+ r="76"
545
+ stroke="currentColor"
546
+ strokeWidth="22"
547
+ className="text-panel-text-5"
548
+ />
549
+ <line
550
+ x1="170"
551
+ y1="170"
552
+ x2="232"
553
+ y2="232"
554
+ stroke="currentColor"
555
+ strokeWidth="22"
556
+ strokeLinecap="round"
557
+ className="text-panel-text-5"
558
+ />
338
559
  </svg>
339
- Import media
340
- </button>
341
- <input
342
- ref={fileInputRef}
343
- type="file"
344
- accept="video/*,image/*,audio/*"
345
- multiple
346
- className="hidden"
347
- onChange={(e) => {
348
- if (e.target.files?.length) {
349
- onImport(e.target.files);
350
- e.target.value = "";
351
- }
352
- }}
353
- />
354
- </div>
355
- )}
560
+ <input
561
+ type="text"
562
+ value={searchQuery}
563
+ onChange={(e) => setSearchQuery(e.target.value)}
564
+ placeholder="Search assets..."
565
+ className="min-w-0 w-full bg-transparent text-[11px] text-panel-text-1 outline-none placeholder:text-panel-text-5"
566
+ />
567
+ </div>
568
+ )}
569
+
570
+ {/* Filter chips — panel-input style */}
571
+ {mediaAssets.length > 0 && (
572
+ <div className="flex gap-1.5 flex-wrap">
573
+ <button
574
+ onClick={() => setActiveFilter("all")}
575
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
576
+ activeFilter === "all"
577
+ ? "bg-panel-accent/15 text-panel-accent"
578
+ : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
579
+ }`}
580
+ >
581
+ All {counts.all}
582
+ </button>
583
+ {FILTER_ORDER.map((cat) =>
584
+ counts[cat] > 0 ? (
585
+ <button
586
+ key={cat}
587
+ onClick={() => setActiveFilter(activeFilter === cat ? "all" : cat)}
588
+ className={`px-2.5 py-1 text-[11px] font-medium rounded-md transition-colors ${
589
+ activeFilter === cat
590
+ ? "bg-panel-accent/15 text-panel-accent"
591
+ : "bg-panel-input text-panel-text-3 hover:text-panel-text-1"
592
+ }`}
593
+ >
594
+ {CATEGORY_LABELS[cat]} {counts[cat]}
595
+ </button>
596
+ ) : null,
597
+ )}
598
+ </div>
599
+ )}
600
+ </div>
356
601
 
357
602
  {/* Asset list */}
358
- <div className="flex-1 overflow-y-auto">
603
+ <div className="flex-1 overflow-y-auto mt-1">
359
604
  {mediaAssets.length === 0 ? (
360
605
  <div className="flex flex-col items-center justify-center h-full px-4 gap-2">
361
606
  <svg
@@ -378,16 +623,59 @@ export const AssetsTab = memo(function AssetsTab({
378
623
  <p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
379
624
  </div>
380
625
  ) : (
381
- mediaAssets.map((asset) => (
382
- <AssetCard
383
- key={asset}
384
- projectId={projectId}
385
- asset={asset}
386
- onCopy={handleCopyPath}
387
- isCopied={copiedPath === asset}
388
- onDelete={onDelete}
389
- onRename={onRename}
390
- />
626
+ visibleCategories.map((cat) => (
627
+ <div key={cat} className="mb-1">
628
+ {activeFilter === "all" && (
629
+ <div className="flex items-center gap-2 px-4 py-2 border-t border-panel-border">
630
+ <h3 className="text-[12px] font-semibold text-panel-text-1">
631
+ {CATEGORY_LABELS[cat]}
632
+ </h3>
633
+ <span className="text-[11px] text-panel-text-5">{categorized[cat].length}</span>
634
+ </div>
635
+ )}
636
+ {cat === "audio" &&
637
+ categorized[cat].map((a) => (
638
+ <AudioRow
639
+ key={a}
640
+ projectId={projectId}
641
+ asset={a}
642
+ used={usedPaths.has(a)}
643
+ meta={manifest.get(a)}
644
+ onCopy={handleCopyPath}
645
+ isCopied={copiedPath === a}
646
+ onDelete={onDelete}
647
+ onRename={onRename}
648
+ />
649
+ ))}
650
+ {(cat === "images" || cat === "video") &&
651
+ categorized[cat].map((a) => (
652
+ <ImageCard
653
+ key={a}
654
+ projectId={projectId}
655
+ asset={a}
656
+ used={usedPaths.has(a)}
657
+ onCopy={handleCopyPath}
658
+ isCopied={copiedPath === a}
659
+ onDelete={onDelete}
660
+ onRename={onRename}
661
+ size={categorized[cat].length <= 4 ? "large" : "small"}
662
+ />
663
+ ))}
664
+ {cat === "fonts" &&
665
+ categorized[cat].map((a) => (
666
+ <ImageCard
667
+ key={a}
668
+ projectId={projectId}
669
+ asset={a}
670
+ used={usedPaths.has(a)}
671
+ onCopy={handleCopyPath}
672
+ isCopied={copiedPath === a}
673
+ onDelete={onDelete}
674
+ onRename={onRename}
675
+ size="small"
676
+ />
677
+ ))}
678
+ </div>
391
679
  ))
392
680
  )}
393
681
  </div>