Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, type ReactNode } from "react"
import { NLELayout } from "./components/nle/NLELayout";
import { SourceEditor } from "./components/editor/SourceEditor";
import { FileTree } from "./components/editor/FileTree";
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
import { CompositionThumbnail } from "./player/components/CompositionThumbnail";
import { VideoThumbnail } from "./player/components/VideoThumbnail";
import type { TimelineElement } from "./player/store/playerStore";
Expand All @@ -15,7 +16,7 @@ import {

interface EditingFile {
path: string;
content: string;
content: string | null;
}

interface ProjectEntry {
Expand Down Expand Up @@ -550,15 +551,47 @@ export function StudioApp() {
return <ProjectPicker onSelect={handleSelectProject} />;
}

const compositions = fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/"));
const assets = fileTree.filter(
(f) => !f.endsWith(".html") && !f.endsWith(".md") && !f.endsWith(".json"),
);

return (
<div className="flex h-screen w-screen bg-neutral-950">
{/* Left sidebar: Compositions + Assets */}
<LeftSidebar
projectId={projectId}
compositions={compositions}
assets={assets}
activeComposition={editingFile?.path ?? null}
onSelectComposition={(comp) => {
// Open code editor for this composition
const controller = new AbortController();
setEditingFile({ path: comp, content: null });
fetch(`/api/projects/${projectId}/files/${comp}`, { signal: controller.signal })
.then((r) => r.json())
.then((data) => {
// Only update if the path still matches (race condition guard)
setEditingFile((prev) =>
prev?.path === comp ? { path: comp, content: data.content } : prev,
);
})
.catch((err) => {
if (err.name !== "AbortError") console.warn("Failed to load composition:", err);
});
}}
/>

{/* NLE: Preview + Timeline */}
<div className="flex-1 relative min-w-0">
<NLELayout
projectId={projectId}
refreshKey={refreshKey}
renderClipContent={renderClipContent}
onCompIdToSrcChange={setCompIdToSrc}
activeCompositionPath={
editingFile?.path?.startsWith("compositions/") ? editingFile.path : null
}
onIframeRef={(iframe) => {
previewIframeRef.current = iframe;
}}
Expand Down Expand Up @@ -641,7 +674,7 @@ export function StudioApp() {
<div className="flex-1 overflow-hidden">
{editingFile ? (
<SourceEditor
content={editingFile.content}
content={editingFile.content ?? ""}
filePath={editingFile.path}
onChange={handleContentChange}
/>
Expand Down
228 changes: 228 additions & 0 deletions packages/studio/src/components/sidebar/AssetsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { memo, useState, useCallback, useRef } from "react";

interface AssetsTabProps {
projectId: string;
assets: string[];
onImport?: (files: FileList) => void;
}

const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|jpg|jpeg|png|gif|webp|svg)$/i;
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg)$/i;
const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
const AUDIO_EXT = /\.(mp3|wav|ogg|m4a)$/i;

function AssetIcon({ ext }: { ext: string }) {
if (VIDEO_EXT.test(ext)) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-blue-400"
>
<polygon points="5 3 19 12 5 21" />
</svg>
);
}
if (AUDIO_EXT.test(ext)) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-purple-400"
>
<path d="M9 18V5l12-2v13" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
);
}
if (IMAGE_EXT.test(ext)) {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-green-400"
>
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-500"
>
<path
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}

export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }: AssetsTabProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [dragOver, setDragOver] = useState(false);
const [copiedPath, setCopiedPath] = useState<string | null>(null);

const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files.length) onImport?.(e.dataTransfer.files);
},
[onImport],
);

const handleCopyPath = useCallback(async (path: string) => {
try {
await navigator.clipboard.writeText(path);
setCopiedPath(path);
setTimeout(() => setCopiedPath(null), 1500);
} catch {
// ignore
}
}, []);

const mediaAssets = assets.filter((a) => MEDIA_EXT.test(a));

return (
<div
className={`flex-1 flex flex-col min-h-0 transition-colors ${dragOver ? "bg-blue-950/20" : ""}`}
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
>
{/* Import button */}
{onImport && (
<div className="px-3 py-2 border-b border-neutral-800/40 flex-shrink-0">
<button
onClick={() => fileInputRef.current?.click()}
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"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
>
<path d="M12 5v14M5 12h14" />
</svg>
Import media
</button>
<input
ref={fileInputRef}
type="file"
accept="video/*,image/*,audio/*"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onImport(e.target.files);
e.target.value = "";
}
}}
/>
</div>
)}

{/* Asset list */}
<div className="flex-1 overflow-y-auto">
{mediaAssets.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-700"
>
<path
d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline points="17 8 12 3 7 8" strokeLinecap="round" strokeLinejoin="round" />
<line x1="12" y1="3" x2="12" y2="15" strokeLinecap="round" />
</svg>
<p className="text-[10px] text-neutral-600 text-center">Drop media files here</p>
</div>
) : (
mediaAssets.map((asset) => {
const name = asset.split("/").pop() ?? asset;
const ext = "." + (name.split(".").pop() ?? "");
const isImage = IMAGE_EXT.test(asset);
const isCopied = copiedPath === asset;
const serveUrl = `/api/projects/${projectId}/serve/${asset}`;

return (
<button
key={asset}
type="button"
onClick={() => handleCopyPath(asset)}
title="Click to copy path"
className="w-full text-left px-3 py-2 flex items-center gap-2.5 hover:bg-neutral-800/40 transition-colors"
>
{isImage ? (
<div className="w-8 h-8 rounded overflow-hidden bg-neutral-900 flex-shrink-0">
<img
src={serveUrl}
alt={name}
loading="lazy"
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-8 h-8 rounded bg-neutral-900 flex items-center justify-center flex-shrink-0">
<AssetIcon ext={ext} />
</div>
)}
<div className="min-w-0 flex-1">
<span className="text-[11px] text-neutral-300 truncate block">{name}</span>
{isCopied && <span className="text-[9px] text-green-400">Copied!</span>}
</div>
</button>
);
})
)}
</div>
</div>
);
});
77 changes: 77 additions & 0 deletions packages/studio/src/components/sidebar/CompositionsTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { memo, useState } from "react";

interface CompositionsTabProps {
projectId: string;
compositions: string[];
activeComposition: string | null;
onSelect: (comp: string) => void;
}

export const CompositionsTab = memo(function CompositionsTab({
projectId,
compositions,
activeComposition,
onSelect,
}: CompositionsTabProps) {
const [hoveredComp, setHoveredComp] = useState<string | null>(null);

if (compositions.length === 0) {
return (
<div className="flex-1 flex items-center justify-center px-4">
<p className="text-xs text-neutral-600 text-center">No compositions found</p>
</div>
);
}

return (
<div className="flex-1 overflow-y-auto">
{compositions.map((comp) => {
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
const isActive = activeComposition === comp;
const isHovered = hoveredComp === comp;
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;

return (
<button
key={comp}
type="button"
onClick={() => onSelect(comp)}
onPointerEnter={() => setHoveredComp(comp)}
onPointerLeave={() => setHoveredComp(null)}
className={`w-full text-left px-3 py-2 flex items-center gap-3 transition-colors ${
isActive
? "bg-blue-500/10 border-l-2 border-blue-500"
: isHovered
? "bg-neutral-800/50"
: ""
} ${!isActive ? "border-l-2 border-transparent" : ""}`}
>
{/* Thumbnail */}
<div className="w-16 h-9 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
<img
src={thumbnailUrl}
alt={name}
loading="lazy"
className="w-full h-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.opacity = "0";
}}
/>
{/* Fallback: show name initial when thumbnail fails */}
<div className="absolute inset-0 flex items-center justify-center text-[10px] text-neutral-600 font-mono pointer-events-none">
{name.charAt(0).toUpperCase()}
</div>
</div>
{/* Name */}
<div className="min-w-0 flex-1">
<span className="text-[11px] font-medium text-neutral-300 truncate block">
{name}
</span>
<span className="text-[9px] text-neutral-600 truncate block">{comp}</span>
</div>
</button>
);
})}
</div>
);
});
Loading
Loading