@hyperframes/studio 0.1.10 → 0.1.11

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 (38) hide show
  1. package/dist/assets/index-Bj0pPj_X.js +92 -0
  2. package/dist/assets/index-BnvciBdD.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +10 -4
  5. package/src/App.tsx +744 -271
  6. package/src/components/editor/FileTree.tsx +186 -32
  7. package/src/components/editor/SourceEditor.tsx +3 -1
  8. package/src/components/nle/NLELayout.tsx +125 -23
  9. package/src/components/renders/RenderQueue.tsx +123 -0
  10. package/src/components/renders/RenderQueueItem.tsx +133 -0
  11. package/src/components/renders/useRenderQueue.ts +161 -0
  12. package/src/components/sidebar/AssetsTab.tsx +360 -0
  13. package/src/components/sidebar/CompositionsTab.tsx +227 -0
  14. package/src/components/sidebar/LeftSidebar.tsx +102 -0
  15. package/src/components/ui/ExpandOnHover.tsx +194 -0
  16. package/src/hooks/useCodeEditor.ts +1 -1
  17. package/src/hooks/useElementPicker.ts +5 -1
  18. package/src/index.ts +10 -2
  19. package/src/player/components/AudioWaveform.tsx +168 -0
  20. package/src/player/components/CompositionThumbnail.tsx +140 -0
  21. package/src/player/components/EditModal.tsx +165 -0
  22. package/src/player/components/Player.tsx +6 -5
  23. package/src/player/components/PlayerControls.tsx +78 -39
  24. package/src/player/components/Timeline.test.ts +110 -0
  25. package/src/player/components/Timeline.tsx +537 -260
  26. package/src/player/components/TimelineClip.tsx +80 -0
  27. package/src/player/components/VideoThumbnail.tsx +196 -0
  28. package/src/player/hooks/useTimelinePlayer.ts +404 -112
  29. package/src/player/index.ts +3 -3
  30. package/src/player/lib/time.test.ts +57 -0
  31. package/src/player/lib/time.ts +1 -0
  32. package/src/player/store/playerStore.test.ts +265 -0
  33. package/src/player/store/playerStore.ts +44 -16
  34. package/src/utils/htmlEditor.ts +164 -0
  35. package/dist/assets/index-Df6fO-S6.js +0 -78
  36. package/dist/assets/index-KoBceNoU.css +0 -1
  37. package/src/player/components/AgentActivityTrack.tsx +0 -93
  38. package/src/player/lib/useMountEffect.ts +0 -10
@@ -0,0 +1,133 @@
1
+ import { memo, useCallback, useState } from "react";
2
+ import type { RenderJob } from "./useRenderQueue";
3
+
4
+ interface RenderQueueItemProps {
5
+ job: RenderJob;
6
+ onDelete: () => void;
7
+ }
8
+
9
+ function formatDuration(ms: number): string {
10
+ if (ms < 1000) return `${ms}ms`;
11
+ const s = Math.round(ms / 1000);
12
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
13
+ }
14
+
15
+ function formatTimeAgo(timestamp: number): string {
16
+ const diff = Date.now() - timestamp;
17
+ if (diff < 60000) return "just now";
18
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
19
+ return `${Math.floor(diff / 3600000)}h ago`;
20
+ }
21
+
22
+ export const RenderQueueItem = memo(function RenderQueueItem({
23
+ job,
24
+ onDelete,
25
+ }: RenderQueueItemProps) {
26
+ const [hovered, setHovered] = useState(false);
27
+
28
+ const handleDownload = useCallback(() => {
29
+ const a = document.createElement("a");
30
+ a.href = `/api/render/${job.id}/download`;
31
+ a.download = job.filename;
32
+ a.click();
33
+ }, [job.id, job.filename]);
34
+
35
+ return (
36
+ <div
37
+ onPointerEnter={() => setHovered(true)}
38
+ onPointerLeave={() => setHovered(false)}
39
+ className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0"
40
+ >
41
+ <div className="flex items-center gap-2">
42
+ {/* Status indicator */}
43
+ <div className="flex-shrink-0">
44
+ {job.status === "rendering" && (
45
+ <div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
46
+ )}
47
+ {job.status === "complete" && <div className="w-2 h-2 rounded-full bg-green-400" />}
48
+ {job.status === "failed" && <div className="w-2 h-2 rounded-full bg-red-400" />}
49
+ {job.status === "cancelled" && <div className="w-2 h-2 rounded-full bg-neutral-600" />}
50
+ </div>
51
+
52
+ {/* Info */}
53
+ <div className="flex-1 min-w-0">
54
+ <div className="flex items-center gap-1.5">
55
+ <span className="text-[11px] font-medium text-neutral-300 truncate">
56
+ {job.filename}
57
+ </span>
58
+ {job.durationMs && (
59
+ <span className="text-[9px] text-neutral-600 flex-shrink-0">
60
+ {formatDuration(job.durationMs)}
61
+ </span>
62
+ )}
63
+ </div>
64
+
65
+ {/* Progress bar + percentage */}
66
+ {job.status === "rendering" && (
67
+ <div className="mt-1">
68
+ <div className="flex items-center justify-between mb-0.5">
69
+ <span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
70
+ <span className="text-[9px] font-mono text-[#3CE6AC]">{job.progress}%</span>
71
+ </div>
72
+ <div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
73
+ <div
74
+ className="h-full bg-[#3CE6AC] rounded-full transition-all duration-300"
75
+ style={{ width: `${job.progress}%` }}
76
+ />
77
+ </div>
78
+ </div>
79
+ )}
80
+
81
+ {job.status !== "rendering" && (
82
+ <span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
83
+ )}
84
+ </div>
85
+
86
+ {/* Actions */}
87
+ {hovered && (
88
+ <div className="flex items-center gap-1 flex-shrink-0">
89
+ {job.status === "complete" && (
90
+ <button
91
+ onClick={handleDownload}
92
+ className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
93
+ title="Download"
94
+ >
95
+ <svg
96
+ width="12"
97
+ height="12"
98
+ viewBox="0 0 24 24"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ strokeWidth="2"
102
+ strokeLinecap="round"
103
+ strokeLinejoin="round"
104
+ >
105
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
106
+ <polyline points="7 10 12 15 17 10" />
107
+ <line x1="12" y1="15" x2="12" y2="3" />
108
+ </svg>
109
+ </button>
110
+ )}
111
+ <button
112
+ onClick={onDelete}
113
+ className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
114
+ title="Remove"
115
+ >
116
+ <svg
117
+ width="12"
118
+ height="12"
119
+ viewBox="0 0 24 24"
120
+ fill="none"
121
+ stroke="currentColor"
122
+ strokeWidth="2"
123
+ strokeLinecap="round"
124
+ >
125
+ <path d="M18 6L6 18M6 6l12 12" />
126
+ </svg>
127
+ </button>
128
+ </div>
129
+ )}
130
+ </div>
131
+ </div>
132
+ );
133
+ });
@@ -0,0 +1,161 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+
3
+ export interface RenderJob {
4
+ id: string;
5
+ status: "rendering" | "complete" | "failed" | "cancelled";
6
+ progress: number;
7
+ stage?: string;
8
+ filename: string;
9
+ createdAt: number;
10
+ durationMs?: number;
11
+ }
12
+
13
+ export function useRenderQueue(projectId: string | null) {
14
+ const [jobs, setJobs] = useState<RenderJob[]>([]);
15
+ const eventSourceRef = useRef<EventSource | null>(null);
16
+ const activeJobRef = useRef<string | null>(null);
17
+
18
+ // Load completed renders from the server
19
+ const loadRenders = useCallback(async () => {
20
+ if (!projectId) return;
21
+ try {
22
+ const res = await fetch(`/api/projects/${projectId}/renders`);
23
+ if (!res.ok) return;
24
+ const data = await res.json();
25
+ if (Array.isArray(data.renders)) {
26
+ setJobs((prev) => {
27
+ const existing = new Set(prev.map((j) => j.id));
28
+ const fromServer: RenderJob[] = data.renders
29
+ .filter((r: { id: string }) => !existing.has(r.id))
30
+ .map(
31
+ (r: {
32
+ id: string;
33
+ filename: string;
34
+ createdAt: number;
35
+ size: number;
36
+ status?: string;
37
+ durationMs?: number;
38
+ }) => ({
39
+ id: r.id,
40
+ status: (r.status === "failed" ? "failed" : "complete") as "complete" | "failed",
41
+ progress: 100,
42
+ filename: r.filename,
43
+ createdAt: r.createdAt,
44
+ durationMs: r.durationMs,
45
+ }),
46
+ );
47
+ return [...prev, ...fromServer];
48
+ });
49
+ }
50
+ } catch {
51
+ // ignore
52
+ }
53
+ }, [projectId]);
54
+
55
+ useEffect(() => {
56
+ loadRenders();
57
+ }, [loadRenders]);
58
+
59
+ // Start a render and track progress via SSE
60
+ const startRender = useCallback(
61
+ async (fps = 30, quality = "standard", format: "mp4" | "webm" = "mp4") => {
62
+ if (!projectId) return;
63
+
64
+ const startTime = Date.now();
65
+ const res = await fetch(`/api/projects/${projectId}/render`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ fps, quality, format }),
69
+ });
70
+ if (!res.ok) return;
71
+ const { jobId } = await res.json();
72
+
73
+ const ext = format === "webm" ? ".webm" : ".mp4";
74
+ const job: RenderJob = {
75
+ id: jobId,
76
+ status: "rendering",
77
+ progress: 0,
78
+ filename: `${jobId}${ext}`,
79
+ createdAt: startTime,
80
+ };
81
+ setJobs((prev) => [...prev, job]);
82
+ activeJobRef.current = jobId;
83
+
84
+ // Track progress via SSE
85
+ const es = new EventSource(`/api/render/${jobId}/progress`);
86
+ eventSourceRef.current = es;
87
+
88
+ es.addEventListener("progress", (event) => {
89
+ try {
90
+ const data = JSON.parse(event.data);
91
+ setJobs((prev) =>
92
+ prev.map((j) =>
93
+ j.id === jobId
94
+ ? {
95
+ ...j,
96
+ progress: data.progress ?? j.progress,
97
+ stage: data.stage ?? data.message ?? j.stage,
98
+ status:
99
+ data.status === "complete"
100
+ ? "complete"
101
+ : data.status === "failed"
102
+ ? "failed"
103
+ : j.status,
104
+ durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
105
+ }
106
+ : j,
107
+ ),
108
+ );
109
+ if (data.status === "complete" || data.status === "failed") {
110
+ es.close();
111
+ activeJobRef.current = null;
112
+ }
113
+ } catch {
114
+ // ignore parse errors
115
+ }
116
+ });
117
+
118
+ es.onerror = () => {
119
+ es.close();
120
+ setJobs((prev) =>
121
+ prev.map((j) =>
122
+ j.id === jobId && j.status === "rendering" ? { ...j, status: "failed" } : j,
123
+ ),
124
+ );
125
+ activeJobRef.current = null;
126
+ };
127
+
128
+ return jobId;
129
+ },
130
+ [projectId],
131
+ );
132
+
133
+ const deleteRender = useCallback(async (jobId: string) => {
134
+ try {
135
+ await fetch(`/api/render/${jobId}`, { method: "DELETE" });
136
+ } catch {
137
+ // ignore
138
+ }
139
+ setJobs((prev) => prev.filter((j) => j.id !== jobId));
140
+ }, []);
141
+
142
+ const clearCompleted = useCallback(() => {
143
+ setJobs((prev) => prev.filter((j) => j.status === "rendering"));
144
+ }, []);
145
+
146
+ // Clean up EventSource on unmount or projectId change
147
+ useEffect(() => {
148
+ return () => {
149
+ eventSourceRef.current?.close();
150
+ eventSourceRef.current = null;
151
+ };
152
+ }, [projectId]);
153
+
154
+ return {
155
+ jobs,
156
+ startRender,
157
+ deleteRender,
158
+ clearCompleted,
159
+ isRendering: jobs.some((j) => j.status === "rendering"),
160
+ };
161
+ }
@@ -0,0 +1,360 @@
1
+ import { memo, useState, useCallback, useRef } from "react";
2
+ import { ExpandOnHover } from "../ui/ExpandOnHover";
3
+
4
+ interface AssetsTabProps {
5
+ projectId: string;
6
+ assets: string[];
7
+ onImport?: (files: FileList) => void;
8
+ }
9
+
10
+ const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i;
11
+ const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
12
+ const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
13
+ const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i;
14
+
15
+ function AssetThumbnail({
16
+ serveUrl,
17
+ name,
18
+ isImage,
19
+ isVideo,
20
+ isAudio,
21
+ }: {
22
+ serveUrl: string;
23
+ name: string;
24
+ isImage: boolean;
25
+ isVideo: boolean;
26
+ isAudio: boolean;
27
+ }) {
28
+ return (
29
+ <div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
30
+ {isImage && (
31
+ <img
32
+ src={serveUrl}
33
+ alt={name}
34
+ loading="lazy"
35
+ className="w-full h-full object-cover"
36
+ onError={(e) => {
37
+ (e.target as HTMLImageElement).style.display = "none";
38
+ }}
39
+ />
40
+ )}
41
+ {isVideo && (
42
+ <>
43
+ <video
44
+ src={serveUrl}
45
+ muted
46
+ playsInline
47
+ preload="metadata"
48
+ className="w-full h-full object-cover"
49
+ />
50
+ <div className="absolute inset-0 flex items-center justify-center bg-black/30">
51
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="white" className="opacity-80">
52
+ <polygon points="6,3 20,12 6,21" />
53
+ </svg>
54
+ </div>
55
+ </>
56
+ )}
57
+ {isAudio && (
58
+ <div className="w-full h-full flex items-center justify-center bg-neutral-900">
59
+ <svg
60
+ width="16"
61
+ height="16"
62
+ viewBox="0 0 24 24"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ strokeWidth="1.5"
66
+ className="text-purple-400"
67
+ >
68
+ <path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
69
+ <circle cx="6" cy="18" r="3" />
70
+ <circle cx="18" cy="16" r="3" />
71
+ </svg>
72
+ </div>
73
+ )}
74
+ {!isImage && !isVideo && !isAudio && (
75
+ <div className="w-full h-full flex items-center justify-center bg-neutral-900">
76
+ <svg
77
+ width="14"
78
+ height="14"
79
+ viewBox="0 0 24 24"
80
+ fill="none"
81
+ stroke="currentColor"
82
+ strokeWidth="1.5"
83
+ className="text-neutral-600"
84
+ >
85
+ <path
86
+ d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
87
+ strokeLinecap="round"
88
+ strokeLinejoin="round"
89
+ />
90
+ <polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
91
+ </svg>
92
+ </div>
93
+ )}
94
+ </div>
95
+ );
96
+ }
97
+
98
+ function ExpandedAssetPreview({
99
+ serveUrl,
100
+ name,
101
+ asset,
102
+ isImage,
103
+ isVideo,
104
+ isAudio,
105
+ onCopy,
106
+ }: {
107
+ serveUrl: string;
108
+ name: string;
109
+ asset: string;
110
+ isImage: boolean;
111
+ isVideo: boolean;
112
+ isAudio: boolean;
113
+ onCopy: () => void;
114
+ }) {
115
+ return (
116
+ <div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
117
+ <div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
118
+ {isImage && (
119
+ <img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
120
+ )}
121
+ {isVideo && (
122
+ <video
123
+ src={serveUrl}
124
+ autoPlay
125
+ muted
126
+ loop
127
+ playsInline
128
+ className="max-w-full max-h-full object-contain rounded"
129
+ />
130
+ )}
131
+ {isAudio && (
132
+ <div className="flex flex-col items-center gap-4">
133
+ <svg
134
+ width="48"
135
+ height="48"
136
+ viewBox="0 0 24 24"
137
+ fill="none"
138
+ stroke="currentColor"
139
+ strokeWidth="1.5"
140
+ className="text-purple-400"
141
+ >
142
+ <path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
143
+ <circle cx="6" cy="18" r="3" />
144
+ <circle cx="18" cy="16" r="3" />
145
+ </svg>
146
+ <audio src={serveUrl} controls autoPlay className="w-64" />
147
+ </div>
148
+ )}
149
+ </div>
150
+ <div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
151
+ <div>
152
+ <div className="text-sm font-medium text-neutral-200">{name}</div>
153
+ <div className="text-[10px] text-neutral-600 font-mono mt-0.5">{asset}</div>
154
+ </div>
155
+ <button
156
+ onClick={(e) => {
157
+ e.stopPropagation();
158
+ onCopy();
159
+ }}
160
+ className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
161
+ >
162
+ Copy Path
163
+ </button>
164
+ </div>
165
+ </div>
166
+ );
167
+ }
168
+
169
+ function AssetCard({
170
+ projectId,
171
+ asset,
172
+ onCopy,
173
+ isCopied,
174
+ }: {
175
+ projectId: string;
176
+ asset: string;
177
+ onCopy: (path: string) => void;
178
+ isCopied: boolean;
179
+ }) {
180
+ const name = asset.split("/").pop() ?? asset;
181
+ const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
182
+ const isImage = IMAGE_EXT.test(asset);
183
+ const isVideo = VIDEO_EXT.test(asset);
184
+ const isAudio = AUDIO_EXT.test(asset);
185
+ const hasExpandablePreview = isImage || isVideo || isAudio;
186
+
187
+ const card = (
188
+ <div
189
+ className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
190
+ isCopied
191
+ ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
192
+ : "border-l-2 border-transparent hover:bg-neutral-800/50"
193
+ }`}
194
+ >
195
+ <AssetThumbnail
196
+ serveUrl={serveUrl}
197
+ name={name}
198
+ isImage={isImage}
199
+ isVideo={isVideo}
200
+ isAudio={isAudio}
201
+ />
202
+ <div className="min-w-0 flex-1">
203
+ <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
204
+ {isCopied ? (
205
+ <span className="text-[9px] text-[#3CE6AC]">Copied!</span>
206
+ ) : (
207
+ <span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
208
+ )}
209
+ </div>
210
+ </div>
211
+ );
212
+
213
+ if (!hasExpandablePreview) {
214
+ return (
215
+ <button
216
+ type="button"
217
+ onClick={() => onCopy(asset)}
218
+ title="Click to copy path"
219
+ className="w-full"
220
+ >
221
+ {card}
222
+ </button>
223
+ );
224
+ }
225
+
226
+ return (
227
+ <ExpandOnHover
228
+ expandedContent={(closeExpand) => (
229
+ <ExpandedAssetPreview
230
+ serveUrl={serveUrl}
231
+ name={name}
232
+ asset={asset}
233
+ isImage={isImage}
234
+ isVideo={isVideo}
235
+ isAudio={isAudio}
236
+ onCopy={() => {
237
+ closeExpand();
238
+ onCopy(asset);
239
+ }}
240
+ />
241
+ )}
242
+ onClick={() => onCopy(asset)}
243
+ expandScale={0.45}
244
+ delay={500}
245
+ >
246
+ {card}
247
+ </ExpandOnHover>
248
+ );
249
+ }
250
+
251
+ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) {
252
+ const fileInputRef = useRef<HTMLInputElement>(null);
253
+ const [dragOver, setDragOver] = useState(false);
254
+ const [copiedPath, setCopiedPath] = useState<string | null>(null);
255
+
256
+ const handleDrop = useCallback(
257
+ (e: React.DragEvent) => {
258
+ e.preventDefault();
259
+ setDragOver(false);
260
+ if (e.dataTransfer.files.length) onImport?.(e.dataTransfer.files);
261
+ },
262
+ [onImport],
263
+ );
264
+
265
+ const handleCopyPath = useCallback(async (path: string) => {
266
+ try {
267
+ await navigator.clipboard.writeText(path);
268
+ setCopiedPath(path);
269
+ setTimeout(() => setCopiedPath(null), 1500);
270
+ } catch {
271
+ // ignore
272
+ }
273
+ }, []);
274
+
275
+ const mediaAssets = assets.filter((a) => MEDIA_EXT.test(a));
276
+
277
+ return (
278
+ <div
279
+ className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-blue-950/20" : ""}`}
280
+ onDragOver={(e) => {
281
+ e.preventDefault();
282
+ setDragOver(true);
283
+ }}
284
+ onDragLeave={() => setDragOver(false)}
285
+ onDrop={handleDrop}
286
+ >
287
+ {/* Import button */}
288
+ {onImport && (
289
+ <div className="px-3 py-2 border-b border-neutral-800/40 flex-shrink-0">
290
+ <button
291
+ onClick={() => fileInputRef.current?.click()}
292
+ 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"
293
+ >
294
+ <svg
295
+ width="12"
296
+ height="12"
297
+ viewBox="0 0 24 24"
298
+ fill="none"
299
+ stroke="currentColor"
300
+ strokeWidth="2.5"
301
+ strokeLinecap="round"
302
+ >
303
+ <path d="M12 5v14M5 12h14" />
304
+ </svg>
305
+ Import media
306
+ </button>
307
+ <input
308
+ ref={fileInputRef}
309
+ type="file"
310
+ accept="video/*,image/*,audio/*"
311
+ multiple
312
+ className="hidden"
313
+ onChange={(e) => {
314
+ if (e.target.files?.length) {
315
+ onImport(e.target.files);
316
+ e.target.value = "";
317
+ }
318
+ }}
319
+ />
320
+ </div>
321
+ )}
322
+
323
+ {/* Asset list */}
324
+ <div className="flex-1 overflow-y-auto">
325
+ {mediaAssets.length === 0 ? (
326
+ <div className="flex flex-col items-center justify-center h-full px-4 gap-2">
327
+ <svg
328
+ width="24"
329
+ height="24"
330
+ viewBox="0 0 24 24"
331
+ fill="none"
332
+ stroke="currentColor"
333
+ strokeWidth="1.5"
334
+ className="text-neutral-700"
335
+ >
336
+ <path
337
+ d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"
338
+ strokeLinecap="round"
339
+ strokeLinejoin="round"
340
+ />
341
+ <polyline points="17 8 12 3 7 8" strokeLinecap="round" strokeLinejoin="round" />
342
+ <line x1="12" y1="3" x2="12" y2="15" strokeLinecap="round" />
343
+ </svg>
344
+ <p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
345
+ </div>
346
+ ) : (
347
+ mediaAssets.map((asset) => (
348
+ <AssetCard
349
+ key={asset}
350
+ projectId={projectId}
351
+ asset={asset}
352
+ onCopy={handleCopyPath}
353
+ isCopied={copiedPath === asset}
354
+ />
355
+ ))
356
+ )}
357
+ </div>
358
+ </div>
359
+ );
360
+ });