@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.
Files changed (58) hide show
  1. package/dist/assets/{index-B_NnmU6q.js → index--lOAjFJl.js} +1 -1
  2. package/dist/assets/{index-ysftPins.js → index-BSyZKYmH.js} +204 -203
  3. package/dist/assets/{index-DsBhGFPe.js → index-Dgeszckd.js} +1 -1
  4. package/dist/assets/index-svYFaNuq.css +1 -0
  5. package/dist/index.d.ts +5 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +3650 -2431
  8. package/dist/index.js.map +1 -1
  9. package/package.json +5 -5
  10. package/src/components/StudioRightPanel.tsx +7 -1
  11. package/src/components/editor/AnimationCard.tsx +6 -0
  12. package/src/components/editor/DomEditOverlay.tsx +4 -12
  13. package/src/components/editor/EaseCurveSection.tsx +175 -99
  14. package/src/components/editor/GsapAnimationSection.tsx +2 -0
  15. package/src/components/editor/KeyframeEaseList.tsx +67 -3
  16. package/src/components/editor/MarqueeOverlay.tsx +40 -0
  17. package/src/components/editor/OffCanvasIndicators.tsx +2 -7
  18. package/src/components/editor/PropertyPanel.tsx +23 -2
  19. package/src/components/editor/Transform3DCube.tsx +313 -0
  20. package/src/components/editor/gsapAnimationCallbacks.ts +2 -0
  21. package/src/components/editor/gsapAnimationConstants.ts +11 -35
  22. package/src/components/editor/gsapAnimationHelpers.test.ts +1 -1
  23. package/src/components/editor/marqueeCommit.ts +63 -20
  24. package/src/components/editor/propertyPanel3dTransform.tsx +311 -92
  25. package/src/components/editor/propertyPanelHelpers.ts +46 -18
  26. package/src/components/editor/propertyPanelTimingSection.tsx +35 -2
  27. package/src/components/editor/transform3dProjection.test.ts +99 -0
  28. package/src/components/editor/transform3dProjection.ts +172 -0
  29. package/src/components/editor/useMotionPathData.ts +2 -1
  30. package/src/components/sidebar/AssetContextMenu.tsx +97 -0
  31. package/src/components/sidebar/AssetsTab.tsx +364 -266
  32. package/src/components/sidebar/AudioRow.tsx +202 -0
  33. package/src/components/sidebar/assetHelpers.ts +40 -0
  34. package/src/contexts/DomEditContext.tsx +8 -0
  35. package/src/hooks/gsapDragCommit.test.ts +9 -5
  36. package/src/hooks/gsapDragCommit.ts +129 -9
  37. package/src/hooks/gsapRuntimeBridge.ts +22 -0
  38. package/src/hooks/gsapRuntimeKeyframes.test.ts +47 -0
  39. package/src/hooks/gsapRuntimeKeyframes.ts +63 -5
  40. package/src/hooks/gsapRuntimePatch.ts +162 -35
  41. package/src/hooks/useAnimatedPropertyCommit.ts +256 -78
  42. package/src/hooks/useDomEditCommits.ts +0 -2
  43. package/src/hooks/useDomEditSession.ts +24 -2
  44. package/src/hooks/useDomEditWiring.ts +3 -0
  45. package/src/hooks/useDomGeometryCommits.ts +3 -31
  46. package/src/hooks/useGestureCommit.ts +0 -4
  47. package/src/hooks/useGsapAwareEditing.ts +31 -5
  48. package/src/hooks/useGsapKeyframeOps.ts +4 -1
  49. package/src/hooks/useGsapScriptCommits.ts +0 -12
  50. package/src/hooks/useGsapSelectionHandlers.ts +12 -9
  51. package/src/hooks/useGsapTweenCache.test.ts +45 -1
  52. package/src/hooks/useGsapTweenCache.ts +125 -42
  53. package/src/hooks/useMusicBeatAnalysis.ts +36 -21
  54. package/src/hooks/useRazorSplit.ts +0 -3
  55. package/src/utils/marqueeGeometry.test.ts +15 -98
  56. package/src/utils/marqueeGeometry.ts +1 -158
  57. package/dist/assets/index-wJZf6FK3.css +0 -1
  58. 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, 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 } 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
- /** Inline thumbnail content — rendered inside the container div in AssetCard. */
16
- function AssetThumbnail({
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 [renaming, setRenaming] = useState(false);
102
- const [renameName, setRenameName] = useState("");
103
- const [confirmDelete, setConfirmDelete] = useState(false);
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={`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"
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
- <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">
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
- {isCopied ? (
188
- <span className="text-[9px] text-studio-accent">Copied!</span>
189
- ) : (
190
- <span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
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
- </div>
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
- <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>
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 mediaAssets = assets.filter((a) => MEDIA_EXT.test(a));
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
- {/* 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"
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
- <path d="M12 5v14M5 12h14" />
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
- 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
- )}
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
- 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
- />
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>