@hyperframes/studio 0.1.13 → 0.1.14

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 (31) hide show
  1. package/dist/assets/index-CLmYRLY-.css +1 -0
  2. package/dist/assets/index-CRvFpc0E.js +84 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +2 -2
  5. package/src/App.tsx +139 -657
  6. package/src/components/LintModal.tsx +149 -0
  7. package/src/components/MediaPreview.tsx +79 -0
  8. package/src/components/editor/FileTree.tsx +50 -40
  9. package/src/components/editor/PropertyPanel.tsx +3 -3
  10. package/src/components/nle/NLELayout.tsx +59 -43
  11. package/src/components/renders/RenderQueue.tsx +19 -16
  12. package/src/components/renders/RenderQueueItem.tsx +13 -8
  13. package/src/components/sidebar/AssetsTab.tsx +34 -144
  14. package/src/components/sidebar/CompositionsTab.tsx +47 -161
  15. package/src/components/sidebar/LeftSidebar.tsx +79 -8
  16. package/src/components/ui/VideoFrameThumbnail.tsx +1 -5
  17. package/src/index.ts +0 -3
  18. package/src/player/components/CompositionThumbnail.tsx +20 -94
  19. package/src/player/components/EditModal.tsx +5 -5
  20. package/src/player/components/PlayerControls.tsx +56 -3
  21. package/src/player/components/Timeline.tsx +13 -17
  22. package/src/player/components/TimelineClip.tsx +0 -1
  23. package/src/player/index.ts +0 -1
  24. package/src/player/store/playerStore.ts +3 -28
  25. package/src/utils/mediaTypes.ts +9 -0
  26. package/dist/assets/index-2uBPlHR_.css +0 -1
  27. package/dist/assets/index-uQ8cgxb3.js +0 -92
  28. package/src/components/ui/ExpandOnHover.tsx +0 -194
  29. package/src/components/ui/ExpandedVideoPreview.tsx +0 -37
  30. package/src/hooks/useCodeEditor.ts +0 -88
  31. package/src/player/components/PreviewPanel.tsx +0 -181
@@ -1,7 +1,6 @@
1
1
  import { memo, useState, useCallback, useRef } from "react";
2
- import { ExpandOnHover } from "../ui/ExpandOnHover";
3
- import { ExpandedVideoPreview } from "../ui/ExpandedVideoPreview";
4
2
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
+ import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
5
4
 
6
5
  interface AssetsTabProps {
7
6
  projectId: string;
@@ -9,11 +8,7 @@ interface AssetsTabProps {
9
8
  onImport?: (files: FileList) => void;
10
9
  }
11
10
 
12
- const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i;
13
- const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
14
- const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
15
- const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i;
16
-
11
+ /** Inline thumbnail content — rendered inside the container div in AssetCard. */
17
12
  function AssetThumbnail({
18
13
  serveUrl,
19
14
  name,
@@ -28,7 +23,7 @@ function AssetThumbnail({
28
23
  isAudio: boolean;
29
24
  }) {
30
25
  return (
31
- <div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
26
+ <>
32
27
  {isImage && (
33
28
  <img
34
29
  src={serveUrl}
@@ -42,7 +37,7 @@ function AssetThumbnail({
42
37
  )}
43
38
  {isVideo && <VideoFrameThumbnail src={serveUrl} />}
44
39
  {isAudio && (
45
- <div className="w-full h-full flex items-center justify-center bg-neutral-900">
40
+ <div className="w-full h-full flex items-center justify-center">
46
41
  <svg
47
42
  width="16"
48
43
  height="16"
@@ -59,7 +54,7 @@ function AssetThumbnail({
59
54
  </div>
60
55
  )}
61
56
  {!isImage && !isVideo && !isAudio && (
62
- <div className="w-full h-full flex items-center justify-center bg-neutral-900">
57
+ <div className="w-full h-full flex items-center justify-center">
63
58
  <svg
64
59
  width="14"
65
60
  height="14"
@@ -78,89 +73,7 @@ function AssetThumbnail({
78
73
  </svg>
79
74
  </div>
80
75
  )}
81
- </div>
82
- );
83
- }
84
-
85
- function ExpandedAssetPreview({
86
- serveUrl,
87
- name,
88
- asset,
89
- isImage,
90
- isVideo,
91
- isAudio,
92
- onCopy,
93
- }: {
94
- serveUrl: string;
95
- name: string;
96
- asset: string;
97
- isImage: boolean;
98
- isVideo: boolean;
99
- isAudio: boolean;
100
- onCopy: () => void;
101
- }) {
102
- if (isVideo) {
103
- return (
104
- <ExpandedVideoPreview
105
- src={serveUrl}
106
- name={name}
107
- subtitle={asset}
108
- action={
109
- <button
110
- onClick={(e) => {
111
- e.stopPropagation();
112
- onCopy();
113
- }}
114
- className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors flex-shrink-0"
115
- >
116
- Copy Path
117
- </button>
118
- }
119
- />
120
- );
121
- }
122
-
123
- return (
124
- <div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
125
- <div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
126
- {isImage && (
127
- <img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
128
- )}
129
- {isAudio && (
130
- <div className="flex flex-col items-center gap-4">
131
- <svg
132
- width="48"
133
- height="48"
134
- viewBox="0 0 24 24"
135
- fill="none"
136
- stroke="currentColor"
137
- strokeWidth="1.5"
138
- className="text-purple-400"
139
- >
140
- <path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
141
- <circle cx="6" cy="18" r="3" />
142
- <circle cx="18" cy="16" r="3" />
143
- </svg>
144
- <audio src={serveUrl} controls autoPlay className="w-64" />
145
- </div>
146
- )}
147
- </div>
148
- <div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
149
- <div>
150
- <div className="text-sm font-medium text-neutral-200">{name}</div>
151
- <div className="text-[10px] text-neutral-600 font-mono mt-0.5">{asset}</div>
152
- </div>
153
- <button
154
- onClick={(e) => {
155
- e.stopPropagation();
156
- onCopy();
157
- }}
158
- className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
159
- >
160
- Copy Path
161
- </button>
162
- </div>
163
- </div>
76
+ </>
164
77
  );
165
78
  }
166
79
 
@@ -175,75 +88,52 @@ function AssetCard({
175
88
  onCopy: (path: string) => void;
176
89
  isCopied: boolean;
177
90
  }) {
91
+ const [hovered, setHovered] = useState(false);
178
92
  const name = asset.split("/").pop() ?? asset;
179
93
  const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
180
- const isImage = IMAGE_EXT.test(asset);
181
94
  const isVideo = VIDEO_EXT.test(asset);
182
- const isAudio = AUDIO_EXT.test(asset);
183
- const hasExpandablePreview = isImage || isVideo || isAudio;
184
95
 
185
- const card = (
96
+ return (
186
97
  <div
98
+ onClick={() => onCopy(asset)}
99
+ onPointerEnter={() => setHovered(true)}
100
+ onPointerLeave={() => setHovered(false)}
187
101
  className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
188
102
  isCopied
189
- ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
103
+ ? "bg-studio-accent/10 border-l-2 border-studio-accent"
190
104
  : "border-l-2 border-transparent hover:bg-neutral-800/50"
191
105
  }`}
192
106
  >
193
- <AssetThumbnail
194
- serveUrl={serveUrl}
195
- name={name}
196
- isImage={isImage}
197
- isVideo={isVideo}
198
- isAudio={isAudio}
199
- />
107
+ <div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
108
+ <AssetThumbnail
109
+ serveUrl={serveUrl}
110
+ name={name}
111
+ isImage={IMAGE_EXT.test(asset)}
112
+ isVideo={isVideo}
113
+ isAudio={AUDIO_EXT.test(asset)}
114
+ />
115
+ {/* Inline video autoplay on hover — same pattern as renders */}
116
+ {isVideo && hovered && (
117
+ <video
118
+ src={serveUrl}
119
+ autoPlay
120
+ muted
121
+ loop
122
+ playsInline
123
+ className="absolute inset-0 w-full h-full object-contain"
124
+ />
125
+ )}
126
+ </div>
200
127
  <div className="min-w-0 flex-1">
201
128
  <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
202
129
  {isCopied ? (
203
- <span className="text-[9px] text-[#3CE6AC]">Copied!</span>
130
+ <span className="text-[9px] text-studio-accent">Copied!</span>
204
131
  ) : (
205
132
  <span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
206
133
  )}
207
134
  </div>
208
135
  </div>
209
136
  );
210
-
211
- if (!hasExpandablePreview) {
212
- return (
213
- <button
214
- type="button"
215
- onClick={() => onCopy(asset)}
216
- title="Click to copy path"
217
- className="w-full"
218
- >
219
- {card}
220
- </button>
221
- );
222
- }
223
-
224
- return (
225
- <ExpandOnHover
226
- expandedContent={(closeExpand) => (
227
- <ExpandedAssetPreview
228
- serveUrl={serveUrl}
229
- name={name}
230
- asset={asset}
231
- isImage={isImage}
232
- isVideo={isVideo}
233
- isAudio={isAudio}
234
- onCopy={() => {
235
- closeExpand();
236
- onCopy(asset);
237
- }}
238
- />
239
- )}
240
- onClick={() => onCopy(asset)}
241
- expandScale={0.45}
242
- delay={500}
243
- >
244
- {card}
245
- </ExpandOnHover>
246
- );
247
137
  }
248
138
 
249
139
  export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) {
@@ -274,7 +164,7 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
274
164
 
275
165
  return (
276
166
  <div
277
- className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-blue-950/20" : ""}`}
167
+ className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-studio-accent/[0.05]" : ""}`}
278
168
  onDragOver={(e) => {
279
169
  e.preventDefault();
280
170
  setDragOver(true);
@@ -1,5 +1,4 @@
1
- import { memo, useRef, useState, useCallback, useEffect } from "react";
2
- import { ExpandOnHover } from "../ui/ExpandOnHover";
1
+ import { memo, useRef, useState } from "react";
3
2
 
4
3
  interface CompositionsTabProps {
5
4
  projectId: string;
@@ -8,132 +7,6 @@ interface CompositionsTabProps {
8
7
  onSelect: (comp: string) => void;
9
8
  }
10
9
 
11
- function ExpandedCompPreview({
12
- previewUrl,
13
- name,
14
- comp,
15
- onSelect,
16
- }: {
17
- previewUrl: string;
18
- name: string;
19
- comp: string;
20
- onSelect: () => void;
21
- }) {
22
- const containerRef = useRef<HTMLDivElement>(null);
23
- const iframeRef = useRef<HTMLIFrameElement>(null);
24
- const [dims, setDims] = useState({ w: 1920, h: 1080 });
25
- const [scale, setScale] = useState(1);
26
-
27
- const updateScale = useCallback(() => {
28
- const el = containerRef.current;
29
- if (!el) return;
30
- const s = Math.min(el.clientWidth / dims.w, el.clientHeight / dims.h);
31
- setScale(s);
32
- }, [dims]);
33
-
34
- // eslint-disable-next-line no-restricted-syntax
35
- useEffect(() => {
36
- updateScale();
37
- const el = containerRef.current;
38
- if (!el) return;
39
- const ro = new ResizeObserver(updateScale);
40
- ro.observe(el);
41
- return () => ro.disconnect();
42
- }, [updateScale]);
43
-
44
- const handleLoad = useCallback(() => {
45
- const iframe = iframeRef.current;
46
- if (!iframe) return;
47
- // Detect dimensions from composition
48
- try {
49
- const doc = iframe.contentDocument;
50
- if (doc) {
51
- const root = doc.querySelector("[data-composition-id]");
52
- if (root) {
53
- const w = parseInt(root.getAttribute("data-width") ?? "0", 10);
54
- const h = parseInt(root.getAttribute("data-height") ?? "0", 10);
55
- if (w > 0 && h > 0) setDims({ w, h });
56
- }
57
- }
58
- } catch {
59
- /* cross-origin */
60
- }
61
-
62
- let attempts = 0;
63
- const interval = setInterval(() => {
64
- try {
65
- const win = iframe.contentWindow as Window & {
66
- __player?: { play: () => void; seek: (t: number) => void };
67
- __timelines?: Record<string, { play: () => void; seek: (t: number) => void }>;
68
- };
69
- if (win?.__player) {
70
- win.__player.seek(0.5);
71
- win.__player.play();
72
- clearInterval(interval);
73
- return;
74
- }
75
- if (win?.__timelines) {
76
- const keys = Object.keys(win.__timelines);
77
- const tl = keys.length > 0 ? win.__timelines[keys[keys.length - 1]] : null;
78
- if (tl) {
79
- tl.seek(0.5);
80
- tl.play();
81
- clearInterval(interval);
82
- }
83
- }
84
- } catch {
85
- /* cross-origin */
86
- }
87
- if (++attempts > 15) clearInterval(interval);
88
- }, 200);
89
- }, []);
90
-
91
- const offsetX = containerRef.current
92
- ? (containerRef.current.clientWidth - dims.w * scale) / 2
93
- : 0;
94
- const offsetY = containerRef.current
95
- ? (containerRef.current.clientHeight - dims.h * scale) / 2
96
- : 0;
97
-
98
- return (
99
- <div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
100
- <div ref={containerRef} className="flex-1 min-h-0 relative overflow-hidden bg-black">
101
- <iframe
102
- ref={iframeRef}
103
- src={previewUrl}
104
- sandbox="allow-scripts allow-same-origin"
105
- onLoad={handleLoad}
106
- className="absolute border-none"
107
- style={{
108
- left: Math.max(0, offsetX),
109
- top: Math.max(0, offsetY),
110
- width: dims.w,
111
- height: dims.h,
112
- transformOrigin: "0 0",
113
- transform: `scale(${scale})`,
114
- }}
115
- tabIndex={-1}
116
- />
117
- </div>
118
- <div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
119
- <div>
120
- <div className="text-sm font-medium text-neutral-200">{name}</div>
121
- <div className="text-[10px] text-neutral-600 font-mono mt-0.5">{comp}</div>
122
- </div>
123
- <button
124
- onClick={(e) => {
125
- e.stopPropagation();
126
- onSelect();
127
- }}
128
- className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
129
- >
130
- Open
131
- </button>
132
- </div>
133
- </div>
134
- );
135
- }
136
-
137
10
  function CompCard({
138
11
  projectId,
139
12
  comp,
@@ -145,28 +18,62 @@ function CompCard({
145
18
  isActive: boolean;
146
19
  onSelect: () => void;
147
20
  }) {
21
+ const [hovered, setHovered] = useState(false);
22
+ const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+ const handleEnter = () => {
24
+ hoverTimer.current = setTimeout(() => setHovered(true), 300);
25
+ };
26
+ const handleLeave = () => {
27
+ if (hoverTimer.current) {
28
+ clearTimeout(hoverTimer.current);
29
+ hoverTimer.current = null;
30
+ }
31
+ setHovered(false);
32
+ };
148
33
  const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
149
34
  const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
150
35
  const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
151
36
 
152
- const card = (
37
+ return (
153
38
  <div
39
+ onClick={onSelect}
40
+ onPointerEnter={handleEnter}
41
+ onPointerLeave={handleLeave}
154
42
  className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
155
43
  isActive
156
- ? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
44
+ ? "bg-studio-accent/10 border-l-2 border-studio-accent"
157
45
  : "border-l-2 border-transparent hover:bg-neutral-800/50"
158
46
  }`}
159
47
  >
160
- <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0">
161
- <img
162
- src={thumbnailUrl}
163
- alt={name}
164
- loading="lazy"
165
- className="w-full h-full object-contain"
166
- onError={(e) => {
167
- (e.target as HTMLImageElement).style.display = "none";
168
- }}
169
- />
48
+ <div className="w-20 h-[45px] rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
49
+ {/* Live iframe preview on hover */}
50
+ {hovered && (
51
+ <iframe
52
+ src={previewUrl}
53
+ sandbox="allow-scripts allow-same-origin"
54
+ className="absolute inset-0 w-[1920px] h-[1080px] border-none pointer-events-none"
55
+ style={{
56
+ transformOrigin: "0 0",
57
+ transform: `scale(${80 / 1920})`,
58
+ }}
59
+ tabIndex={-1}
60
+ />
61
+ )}
62
+ {/* Static thumbnail — hidden while hovering */}
63
+ <div
64
+ className="absolute inset-0 transition-opacity duration-150"
65
+ style={{ opacity: hovered ? 0 : 1 }}
66
+ >
67
+ <img
68
+ src={thumbnailUrl}
69
+ alt={name}
70
+ loading="lazy"
71
+ className="w-full h-full object-contain"
72
+ onError={(e) => {
73
+ (e.target as HTMLImageElement).style.display = "none";
74
+ }}
75
+ />
76
+ </div>
170
77
  </div>
171
78
  <div className="min-w-0 flex-1">
172
79
  <span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
@@ -174,27 +81,6 @@ function CompCard({
174
81
  </div>
175
82
  </div>
176
83
  );
177
-
178
- return (
179
- <ExpandOnHover
180
- expandedContent={(closeExpand) => (
181
- <ExpandedCompPreview
182
- previewUrl={previewUrl}
183
- name={name}
184
- comp={comp}
185
- onSelect={() => {
186
- closeExpand();
187
- onSelect();
188
- }}
189
- />
190
- )}
191
- onClick={onSelect}
192
- expandScale={0.5}
193
- delay={500}
194
- >
195
- {card}
196
- </ExpandOnHover>
197
- );
198
84
  }
199
85
 
200
86
  export const CompositionsTab = memo(function CompositionsTab({
@@ -1,15 +1,18 @@
1
- import { memo, useState, useCallback } from "react";
1
+ import { memo, useState, useCallback, type ReactNode } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { CompositionsTab } from "./CompositionsTab";
4
4
  import { AssetsTab } from "./AssetsTab";
5
+ import { FileTree } from "../editor/FileTree";
5
6
 
6
- type SidebarTab = "compositions" | "assets";
7
+ type SidebarTab = "compositions" | "assets" | "code";
7
8
 
8
9
  const STORAGE_KEY = "hf-studio-sidebar-tab";
9
10
 
10
11
  function getPersistedTab(): SidebarTab {
11
12
  const stored = localStorage.getItem(STORAGE_KEY);
12
- return stored === "assets" ? "assets" : "compositions";
13
+ if (stored === "assets") return "assets";
14
+ if (stored === "code") return "code";
15
+ return "compositions";
13
16
  }
14
17
 
15
18
  interface LeftSidebarProps {
@@ -20,6 +23,12 @@ interface LeftSidebarProps {
20
23
  activeComposition: string | null;
21
24
  onSelectComposition: (comp: string) => void;
22
25
  onImportFiles?: (files: FileList) => void;
26
+ fileTree?: string[];
27
+ editingFile?: { path: string; content: string | null } | null;
28
+ onSelectFile?: (path: string) => void;
29
+ codeChildren?: ReactNode;
30
+ onLint?: () => void;
31
+ linting?: boolean;
23
32
  }
24
33
 
25
34
  export const LeftSidebar = memo(function LeftSidebar({
@@ -30,6 +39,12 @@ export const LeftSidebar = memo(function LeftSidebar({
30
39
  activeComposition,
31
40
  onSelectComposition,
32
41
  onImportFiles,
42
+ fileTree: fileProp,
43
+ editingFile,
44
+ onSelectFile,
45
+ codeChildren,
46
+ onLint,
47
+ linting,
33
48
  }: LeftSidebarProps) {
34
49
  const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
35
50
 
@@ -60,14 +75,25 @@ export const LeftSidebar = memo(function LeftSidebar({
60
75
  className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
61
76
  style={{ width }}
62
77
  >
63
- {/* Tabs */}
78
+ {/* Tabs — Code first */}
64
79
  <div className="flex border-b border-neutral-800/50 flex-shrink-0">
80
+ <button
81
+ type="button"
82
+ onClick={() => selectTab("code")}
83
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
84
+ tab === "code"
85
+ ? "text-neutral-200 border-b-2 border-studio-accent"
86
+ : "text-neutral-500 hover:text-neutral-400"
87
+ }`}
88
+ >
89
+ Code
90
+ </button>
65
91
  <button
66
92
  type="button"
67
93
  onClick={() => selectTab("compositions")}
68
94
  className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
69
95
  tab === "compositions"
70
- ? "text-neutral-200 border-b-2 border-blue-500"
96
+ ? "text-neutral-200 border-b-2 border-studio-accent"
71
97
  : "text-neutral-500 hover:text-neutral-400"
72
98
  }`}
73
99
  >
@@ -78,7 +104,7 @@ export const LeftSidebar = memo(function LeftSidebar({
78
104
  onClick={() => selectTab("assets")}
79
105
  className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
80
106
  tab === "assets"
81
- ? "text-neutral-200 border-b-2 border-blue-500"
107
+ ? "text-neutral-200 border-b-2 border-studio-accent"
82
108
  : "text-neutral-500 hover:text-neutral-400"
83
109
  }`}
84
110
  >
@@ -87,16 +113,61 @@ export const LeftSidebar = memo(function LeftSidebar({
87
113
  </div>
88
114
 
89
115
  {/* Tab content */}
90
- {tab === "compositions" ? (
116
+ {tab === "compositions" && (
91
117
  <CompositionsTab
92
118
  projectId={projectId}
93
119
  compositions={compositions}
94
120
  activeComposition={activeComposition}
95
121
  onSelect={onSelectComposition}
96
122
  />
97
- ) : (
123
+ )}
124
+ {tab === "assets" && (
98
125
  <AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
99
126
  )}
127
+ {tab === "code" && (
128
+ <div className="flex flex-1 min-h-0">
129
+ {(fileProp?.length ?? 0) > 0 && (
130
+ <div className="w-[140px] flex-shrink-0 border-r border-neutral-800 overflow-y-auto">
131
+ <FileTree
132
+ files={fileProp ?? []}
133
+ activeFile={editingFile?.path ?? null}
134
+ onSelectFile={onSelectFile ?? (() => {})}
135
+ />
136
+ </div>
137
+ )}
138
+ <div className="flex-1 overflow-hidden min-w-0">
139
+ {codeChildren ?? (
140
+ <div className="flex items-center justify-center h-full text-neutral-600 text-sm">
141
+ Select a file to edit
142
+ </div>
143
+ )}
144
+ </div>
145
+ </div>
146
+ )}
147
+
148
+ {/* Lint button pinned at the bottom */}
149
+ {onLint && (
150
+ <div className="border-t border-neutral-800 p-2 flex-shrink-0">
151
+ <button
152
+ onClick={onLint}
153
+ disabled={linting}
154
+ className="w-full flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-md text-[11px] font-medium text-neutral-500 hover:text-amber-300 hover:bg-neutral-800 transition-colors disabled:opacity-40"
155
+ >
156
+ <svg
157
+ width="12"
158
+ height="12"
159
+ viewBox="0 0 24 24"
160
+ fill="none"
161
+ stroke="currentColor"
162
+ strokeWidth="2"
163
+ >
164
+ <path d="M9 11l3 3L22 4" />
165
+ <path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
166
+ </svg>
167
+ {linting ? "Linting…" : "Lint"}
168
+ </button>
169
+ </div>
170
+ )}
100
171
  </div>
101
172
  );
102
173
  });
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef } from "react";
1
+ import { useState, useEffect } from "react";
2
2
 
3
3
  /**
4
4
  * Extracts a representative JPEG frame from a video URL using a hidden
@@ -7,12 +7,8 @@ import { useState, useEffect, useRef } from "react";
7
7
  */
8
8
  export function VideoFrameThumbnail({ src }: { src: string }) {
9
9
  const [frame, setFrame] = useState<string | null>(null);
10
- const didExtract = useRef(false);
11
10
 
12
11
  useEffect(() => {
13
- if (didExtract.current) return;
14
- didExtract.current = true;
15
-
16
12
  const video = document.createElement("video");
17
13
  video.crossOrigin = "anonymous";
18
14
  video.muted = true;
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@ export {
9
9
  Player,
10
10
  PlayerControls,
11
11
  Timeline,
12
- PreviewPanel,
13
12
  VideoThumbnail,
14
13
  CompositionThumbnail,
15
14
  useTimelinePlayer,
@@ -28,8 +27,6 @@ export { FileTree } from "./components/editor/FileTree";
28
27
  export { StudioApp } from "./App";
29
28
 
30
29
  // Hooks
31
- export { useCodeEditor } from "./hooks/useCodeEditor";
32
- export type { OpenFile, UseCodeEditorReturn } from "./hooks/useCodeEditor";
33
30
  export { useElementPicker } from "./hooks/useElementPicker";
34
31
  export type { PickedElement } from "./hooks/useElementPicker";
35
32