mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): render queue, layout restructure, home page, hover preview (#95)
## Summary - Add render queue panel with progress tracking, download, and delete actions - Restructure App layout: home page with project picker, session-based routing - Add ExpandOnHover component for preview-on-hover interactions (uses motion/react) - CompositionsTab now supports hover preview with expanded iframe view - Vite config: guard setInterval cleanup to dev-only (fixes CI build timeout) - Add favicon and update studio package deps ## Test plan - [x] Render queue shows progress, completes, and allows download - [x] Home page lists projects and navigates to session view - [x] ExpandOnHover shows expanded preview on mouse hover with spring animation - [x] `vite build` exits cleanly (no hanging process from setInterval) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { memo, useState, useCallback, useRef } from "react";
|
||||
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
||||
|
||||
interface AssetsTabProps {
|
||||
projectId: string;
|
||||
@@ -11,81 +12,239 @@ 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>
|
||||
);
|
||||
}
|
||||
function AssetThumbnail({
|
||||
serveUrl,
|
||||
name,
|
||||
isImage,
|
||||
isVideo,
|
||||
isAudio,
|
||||
}: {
|
||||
serveUrl: string;
|
||||
name: string;
|
||||
isImage: boolean;
|
||||
isVideo: boolean;
|
||||
isAudio: boolean;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-500"
|
||||
<div className="w-16 h-10 rounded overflow-hidden bg-neutral-900 flex-shrink-0 relative">
|
||||
{isImage && (
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isVideo && (
|
||||
<>
|
||||
<video
|
||||
src={serveUrl}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="white" className="opacity-80">
|
||||
<polygon points="6,3 20,12 6,21" />
|
||||
</svg>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center bg-neutral-900">
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{!isImage && !isVideo && !isAudio && (
|
||||
<div className="w-full h-full flex items-center justify-center bg-neutral-900">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-600"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedAssetPreview({
|
||||
serveUrl,
|
||||
name,
|
||||
asset,
|
||||
isImage,
|
||||
isVideo,
|
||||
isAudio,
|
||||
onCopy,
|
||||
}: {
|
||||
serveUrl: string;
|
||||
name: string;
|
||||
asset: string;
|
||||
isImage: boolean;
|
||||
isVideo: boolean;
|
||||
isAudio: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center bg-black p-4">
|
||||
{isImage && (
|
||||
<img src={serveUrl} alt={name} className="max-w-full max-h-full object-contain rounded" />
|
||||
)}
|
||||
{isVideo && (
|
||||
<video
|
||||
src={serveUrl}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
{isAudio && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
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>
|
||||
<audio src={serveUrl} controls autoPlay className="w-64" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{asset}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
||||
>
|
||||
Copy Path
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetCard({
|
||||
projectId,
|
||||
asset,
|
||||
onCopy,
|
||||
isCopied,
|
||||
}: {
|
||||
projectId: string;
|
||||
asset: string;
|
||||
onCopy: (path: string) => void;
|
||||
isCopied: boolean;
|
||||
}) {
|
||||
const name = asset.split("/").pop() ?? asset;
|
||||
const serveUrl = `/api/projects/${projectId}/preview/${asset}`;
|
||||
const isImage = IMAGE_EXT.test(asset);
|
||||
const isVideo = VIDEO_EXT.test(asset);
|
||||
const isAudio = AUDIO_EXT.test(asset);
|
||||
const hasExpandablePreview = isImage || isVideo || isAudio;
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
||||
isCopied
|
||||
? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<AssetThumbnail
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
isImage={isImage}
|
||||
isVideo={isVideo}
|
||||
isAudio={isAudio}
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-[11px] font-medium text-neutral-300 truncate block">{name}</span>
|
||||
{isCopied ? (
|
||||
<span className="text-[9px] text-[#3CE6AC]">Copied!</span>
|
||||
) : (
|
||||
<span className="text-[9px] text-neutral-600 truncate block">{asset}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!hasExpandablePreview) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onCopy(asset)}
|
||||
title="Click to copy path"
|
||||
className="w-full"
|
||||
>
|
||||
{card}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpandOnHover
|
||||
expandedContent={(closeExpand) => (
|
||||
<ExpandedAssetPreview
|
||||
serveUrl={serveUrl}
|
||||
name={name}
|
||||
asset={asset}
|
||||
isImage={isImage}
|
||||
isVideo={isVideo}
|
||||
isAudio={isAudio}
|
||||
onCopy={() => {
|
||||
closeExpand();
|
||||
onCopy(asset);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onClick={() => onCopy(asset)}
|
||||
expandScale={0.45}
|
||||
delay={500}
|
||||
>
|
||||
{card}
|
||||
</ExpandOnHover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -185,42 +344,15 @@ export const AssetsTab = memo(function AssetsTab({ projectId, assets, onImport }
|
||||
<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>
|
||||
);
|
||||
})
|
||||
mediaAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset}
|
||||
projectId={projectId}
|
||||
asset={asset}
|
||||
onCopy={handleCopyPath}
|
||||
isCopied={copiedPath === asset}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useRef, useState, useCallback, useEffect } from "react";
|
||||
import { ExpandOnHover } from "../ui/ExpandOnHover";
|
||||
|
||||
interface CompositionsTabProps {
|
||||
projectId: string;
|
||||
@@ -7,14 +8,201 @@ interface CompositionsTabProps {
|
||||
onSelect: (comp: string) => void;
|
||||
}
|
||||
|
||||
function ExpandedCompPreview({
|
||||
previewUrl,
|
||||
name,
|
||||
comp,
|
||||
onSelect,
|
||||
}: {
|
||||
previewUrl: string;
|
||||
name: string;
|
||||
comp: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [dims, setDims] = useState({ w: 1920, h: 1080 });
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
const updateScale = useCallback(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const s = Math.min(el.clientWidth / dims.w, el.clientHeight / dims.h);
|
||||
setScale(s);
|
||||
}, [dims]);
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
updateScale();
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(updateScale);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [updateScale]);
|
||||
|
||||
const handleLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
// Detect dimensions from composition
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (doc) {
|
||||
const root = doc.querySelector("[data-composition-id]");
|
||||
if (root) {
|
||||
const w = parseInt(root.getAttribute("data-width") ?? "0", 10);
|
||||
const h = parseInt(root.getAttribute("data-height") ?? "0", 10);
|
||||
if (w > 0 && h > 0) setDims({ w, h });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const interval = setInterval(() => {
|
||||
try {
|
||||
const win = iframe.contentWindow as Window & {
|
||||
__player?: { play: () => void; seek: (t: number) => void };
|
||||
__timelines?: Record<string, { play: () => void; seek: (t: number) => void }>;
|
||||
};
|
||||
if (win?.__player) {
|
||||
win.__player.seek(0.5);
|
||||
win.__player.play();
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
if (win?.__timelines) {
|
||||
const keys = Object.keys(win.__timelines);
|
||||
const tl = keys.length > 0 ? win.__timelines[keys[keys.length - 1]] : null;
|
||||
if (tl) {
|
||||
tl.seek(0.5);
|
||||
tl.play();
|
||||
clearInterval(interval);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
if (++attempts > 15) clearInterval(interval);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
const offsetX = containerRef.current
|
||||
? (containerRef.current.clientWidth - dims.w * scale) / 2
|
||||
: 0;
|
||||
const offsetY = containerRef.current
|
||||
? (containerRef.current.clientHeight - dims.h * scale) / 2
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div ref={containerRef} className="flex-1 min-h-0 relative overflow-hidden bg-black">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={previewUrl}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
onLoad={handleLoad}
|
||||
className="absolute border-none"
|
||||
style={{
|
||||
left: Math.max(0, offsetX),
|
||||
top: Math.max(0, offsetY),
|
||||
width: dims.w,
|
||||
height: dims.h,
|
||||
transformOrigin: "0 0",
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-5 py-3 bg-neutral-900 border-t border-neutral-800/50 flex items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-200">{name}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{comp}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
className="px-4 py-1.5 text-xs font-semibold text-[#09090B] bg-[#3CE6AC] rounded-lg hover:brightness-110 transition-colors"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CompCard({
|
||||
projectId,
|
||||
comp,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: {
|
||||
projectId: string;
|
||||
comp: string;
|
||||
isActive: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
|
||||
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=0.5`;
|
||||
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
|
||||
|
||||
const card = (
|
||||
<div
|
||||
className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-[#3CE6AC]/10 border-l-2 border-[#3CE6AC]"
|
||||
: "border-l-2 border-transparent hover:bg-neutral-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="w-20 h-[45px] 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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandOnHover
|
||||
expandedContent={(closeExpand) => (
|
||||
<ExpandedCompPreview
|
||||
previewUrl={previewUrl}
|
||||
name={name}
|
||||
comp={comp}
|
||||
onSelect={() => {
|
||||
closeExpand();
|
||||
onSelect();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
onClick={onSelect}
|
||||
expandScale={0.5}
|
||||
delay={500}
|
||||
>
|
||||
{card}
|
||||
</ExpandOnHover>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -25,53 +213,15 @@ export const CompositionsTab = memo(function CompositionsTab({
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{compositions.map((comp) => (
|
||||
<CompCard
|
||||
key={comp}
|
||||
projectId={projectId}
|
||||
comp={comp}
|
||||
isActive={activeComposition === comp}
|
||||
onSelect={() => onSelect(comp)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo, useState, useCallback, useEffect } from "react";
|
||||
import { memo, useState, useCallback } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { CompositionsTab } from "./CompositionsTab";
|
||||
import { AssetsTab } from "./AssetsTab";
|
||||
|
||||
@@ -12,6 +13,7 @@ function getPersistedTab(): SidebarTab {
|
||||
}
|
||||
|
||||
interface LeftSidebarProps {
|
||||
width?: number;
|
||||
projectId: string;
|
||||
compositions: string[];
|
||||
assets: string[];
|
||||
@@ -21,6 +23,7 @@ interface LeftSidebarProps {
|
||||
}
|
||||
|
||||
export const LeftSidebar = memo(function LeftSidebar({
|
||||
width = 240,
|
||||
projectId,
|
||||
compositions,
|
||||
assets,
|
||||
@@ -36,7 +39,7 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
}, []);
|
||||
|
||||
// Keyboard shortcuts: Cmd+1 for Compositions, Cmd+2 for Assets
|
||||
useEffect(() => {
|
||||
useMountEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (!e.metaKey && !e.ctrlKey) return;
|
||||
if (e.key === "1") {
|
||||
@@ -50,12 +53,12 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
};
|
||||
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 }}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-neutral-800/50 flex-shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user