mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
feat(studio): add left sidebar with Compositions and Assets tabs
- Left sidebar (240px) with two tabs: Compositions and Assets - Compositions tab: lists all HTML files with thumbnails, click to select - Assets tab: lists media files with type icons, click to copy path - Drag-and-drop import zone in Assets tab - Tab state persisted to localStorage - Keyboard shortcuts: Cmd+1 (Compositions), Cmd+2 (Assets)
This commit is contained in:
@@ -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";
|
||||
@@ -15,7 +16,7 @@ import {
|
||||
|
||||
interface EditingFile {
|
||||
path: string;
|
||||
content: string;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
interface ProjectEntry {
|
||||
@@ -543,8 +544,28 @@ 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) => {
|
||||
setEditingFile({ path: comp, content: null });
|
||||
fetch(`/api/projects/${projectId}/files/${comp}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => setEditingFile({ path: comp, content: data.content }))
|
||||
.catch(() => {});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* NLE: Preview + Timeline */}
|
||||
<div className="flex-1 relative min-w-0">
|
||||
<NLELayout
|
||||
@@ -634,7 +655,7 @@ export function StudioApp() {
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{editingFile ? (
|
||||
<SourceEditor
|
||||
content={editingFile.content}
|
||||
content={editingFile.content ?? ""}
|
||||
filePath={editingFile.path}
|
||||
onChange={handleContentChange}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { memo, useState, useCallback, useEffect } from "react";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
import { AssetsTab } from "./AssetsTab";
|
||||
|
||||
type SidebarTab = "compositions" | "assets";
|
||||
|
||||
const STORAGE_KEY = "hf-studio-sidebar-tab";
|
||||
|
||||
function getPersistedTab(): SidebarTab {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored === "assets" ? "assets" : "compositions";
|
||||
}
|
||||
|
||||
interface LeftSidebarProps {
|
||||
projectId: string;
|
||||
compositions: string[];
|
||||
assets: string[];
|
||||
activeComposition: string | null;
|
||||
onSelectComposition: (comp: string) => void;
|
||||
onImportFiles?: (files: FileList) => void;
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
projectId,
|
||||
compositions,
|
||||
assets,
|
||||
activeComposition,
|
||||
onSelectComposition,
|
||||
onImportFiles,
|
||||
}: LeftSidebarProps) {
|
||||
const [tab, setTab] = useState<SidebarTab>(getPersistedTab);
|
||||
|
||||
const selectTab = useCallback((t: SidebarTab) => {
|
||||
setTab(t);
|
||||
localStorage.setItem(STORAGE_KEY, t);
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!e.metaKey && !e.ctrlKey) return;
|
||||
if (e.key === "1") {
|
||||
e.preventDefault();
|
||||
selectTab("compositions");
|
||||
}
|
||||
if (e.key === "2") {
|
||||
e.preventDefault();
|
||||
selectTab("assets");
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [selectTab]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col h-full bg-neutral-950 border-r border-neutral-800/50"
|
||||
style={{ width: 240 }}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-neutral-800/50 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("compositions")}
|
||||
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
||||
tab === "compositions"
|
||||
? "text-neutral-200 border-b-2 border-blue-500"
|
||||
: "text-neutral-500 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
Compositions
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectTab("assets")}
|
||||
className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
|
||||
tab === "assets"
|
||||
? "text-neutral-200 border-b-2 border-blue-500"
|
||||
: "text-neutral-500 hover:text-neutral-400"
|
||||
}`}
|
||||
>
|
||||
Assets
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{tab === "compositions" ? (
|
||||
<CompositionsTab
|
||||
projectId={projectId}
|
||||
compositions={compositions}
|
||||
activeComposition={activeComposition}
|
||||
onSelect={onSelectComposition}
|
||||
/>
|
||||
) : (
|
||||
<AssetsTab projectId={projectId} assets={assets} onImport={onImportFiles} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user