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:
Miguel Ángel
2026-03-28 21:28:43 +01:00
committed by GitHub
parent 9ae239d177
commit 40bd159103
24 changed files with 2495 additions and 796 deletions
@@ -1,5 +1,13 @@
import { memo } from "react";
import { FileCode, Image, Film, Music, File } from "../../icons/SystemIcons";
import { memo, useState, useCallback } from "react";
import {
FileCode,
Image,
Film,
Music,
File,
ChevronDown,
ChevronRight,
} from "../../icons/SystemIcons";
interface FileTreeProps {
files: string[];
@@ -13,12 +21,23 @@ const FILE_ICONS: Record<string, { icon: typeof File; color: string }> = {
js: { icon: FileCode, color: "#F59E0B" },
ts: { icon: FileCode, color: "#3B82F6" },
json: { icon: File, color: "#22C55E" },
md: { icon: File, color: "#737373" },
png: { icon: Image, color: "#22C55E" },
jpg: { icon: Image, color: "#22C55E" },
jpeg: { icon: Image, color: "#22C55E" },
webp: { icon: Image, color: "#22C55E" },
gif: { icon: Image, color: "#22C55E" },
svg: { icon: Image, color: "#F97316" },
mp4: { icon: Film, color: "#A855F7" },
webm: { icon: Film, color: "#A855F7" },
mov: { icon: Film, color: "#A855F7" },
mp3: { icon: Music, color: "#F59E0B" },
wav: { icon: Music, color: "#F59E0B" },
ogg: { icon: Music, color: "#F59E0B" },
m4a: { icon: Music, color: "#F59E0B" },
woff: { icon: File, color: "#525252" },
woff2: { icon: File, color: "#525252" },
ttf: { icon: File, color: "#525252" },
};
function getFileIcon(path: string) {
@@ -26,13 +45,152 @@ function getFileIcon(path: string) {
return FILE_ICONS[ext] ?? { icon: File, color: "#737373" };
}
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
const sorted = [...files].sort((a, b) => {
// index.html first, then alphabetical
if (a === "index.html") return -1;
if (b === "index.html") return 1;
return a.localeCompare(b);
interface TreeNode {
name: string;
fullPath: string;
children: Map<string, TreeNode>;
isFile: boolean;
}
function buildTree(files: string[]): TreeNode {
const root: TreeNode = { name: "", fullPath: "", children: new Map(), isFile: false };
for (const file of files) {
const parts = file.split("/");
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const isLast = i === parts.length - 1;
const fullPath = parts.slice(0, i + 1).join("/");
if (!current.children.has(part)) {
current.children.set(part, {
name: part,
fullPath,
children: new Map(),
isFile: isLast,
});
}
current = current.children.get(part)!;
if (isLast) current.isFile = true;
}
}
return root;
}
function sortChildren(children: Map<string, TreeNode>): TreeNode[] {
return Array.from(children.values()).sort((a, b) => {
// index.html always first
if (a.name === "index.html") return -1;
if (b.name === "index.html") return 1;
// Directories before files
if (!a.isFile && b.isFile) return -1;
if (a.isFile && !b.isFile) return 1;
return a.name.localeCompare(b.name);
});
}
function TreeFolder({
node,
depth,
activeFile,
onSelectFile,
defaultOpen,
}: {
node: TreeNode;
depth: number;
activeFile: string | null;
onSelectFile: (path: string) => void;
defaultOpen: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const toggle = useCallback(() => setIsOpen((v) => !v), []);
const children = sortChildren(node.children);
const Chevron = isOpen ? ChevronDown : ChevronRight;
return (
<>
<button
onClick={toggle}
className="w-full flex items-center gap-1.5 px-2.5 py-1 min-h-7 text-left text-xs text-neutral-400 hover:bg-neutral-800/30 hover:text-neutral-300 transition-colors"
style={{ paddingLeft: `${8 + depth * 12}px` }}
>
<Chevron size={10} className="flex-shrink-0 text-neutral-600" />
<span className="truncate font-medium">{node.name}</span>
</button>
{isOpen &&
children.map((child) =>
child.isFile && child.children.size === 0 ? (
<TreeFile
key={child.fullPath}
node={child}
depth={depth + 1}
activeFile={activeFile}
onSelectFile={onSelectFile}
/>
) : child.children.size > 0 ? (
<TreeFolder
key={child.fullPath}
node={child}
depth={depth + 1}
activeFile={activeFile}
onSelectFile={onSelectFile}
defaultOpen={isActiveInSubtree(child, activeFile)}
/>
) : (
<TreeFile
key={child.fullPath}
node={child}
depth={depth + 1}
activeFile={activeFile}
onSelectFile={onSelectFile}
/>
),
)}
</>
);
}
function TreeFile({
node,
depth,
activeFile,
onSelectFile,
}: {
node: TreeNode;
depth: number;
activeFile: string | null;
onSelectFile: (path: string) => void;
}) {
const { icon: Icon, color } = getFileIcon(node.name);
const isActive = node.fullPath === activeFile;
return (
<button
onClick={() => onSelectFile(node.fullPath)}
className={`w-full flex items-center gap-2 py-1 min-h-7 text-left transition-all text-xs ${
isActive
? "bg-neutral-800/60 text-neutral-200"
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300"
}`}
style={{ paddingLeft: `${8 + depth * 12 + 14}px` }}
>
<Icon size={12} style={{ color }} className="flex-shrink-0" />
<span className="truncate">{node.name}</span>
</button>
);
}
function isActiveInSubtree(node: TreeNode, activeFile: string | null): boolean {
if (!activeFile) return false;
if (node.fullPath === activeFile) return true;
for (const child of node.children.values()) {
if (isActiveInSubtree(child, activeFile)) return true;
}
return false;
}
export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile }: FileTreeProps) {
const tree = buildTree(files);
const children = sortChildren(tree.children);
return (
<div className="flex flex-col h-full min-h-0">
@@ -40,30 +198,26 @@ export const FileTree = memo(function FileTree({ files, activeFile, onSelectFile
<span className="text-2xs font-medium text-neutral-500 uppercase tracking-caps">Files</span>
</div>
<div className="flex-1 overflow-y-auto py-1">
{sorted.map((path) => {
const { icon: Icon, color } = getFileIcon(path);
const isActive = path === activeFile;
const name = path.split("/").pop() ?? path;
const dir = path.includes("/") ? path.split("/").slice(0, -1).join("/") + "/" : "";
return (
<button
key={path}
onClick={() => onSelectFile(path)}
className={`w-full flex items-center gap-2 px-2.5 py-1 min-h-7 text-left transition-all duration-press text-xs ${
isActive
? "bg-neutral-800/60 text-neutral-200"
: "text-neutral-500 hover:bg-neutral-800/30 hover:text-neutral-300 active:scale-[0.98]"
}`}
>
<Icon size={12} style={{ color }} className="flex-shrink-0" />
<span className="truncate">
{dir && <span className="text-neutral-600">{dir}</span>}
{name}
</span>
</button>
);
})}
{children.map((child) =>
child.isFile && child.children.size === 0 ? (
<TreeFile
key={child.fullPath}
node={child}
depth={0}
activeFile={activeFile}
onSelectFile={onSelectFile}
/>
) : (
<TreeFolder
key={child.fullPath}
node={child}
depth={0}
activeFile={activeFile}
onSelectFile={onSelectFile}
defaultOpen={isActiveInSubtree(child, activeFile)}
/>
),
)}
</div>
</div>
);
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, memo, type ReactNode } from "react";
import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "react";
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
@@ -54,15 +54,16 @@ export const NLELayout = memo(function NLELayout({
seek,
onIframeLoad: baseOnIframeLoad,
saveSeekPosition,
resetPlayer,
} = useTimelinePlayer();
// Reset timeline state when the project changes to prevent stale data from a
// previous project leaking into the new one.
const prevProjectIdRef = useRef<string | null>(null);
const prevProjectIdRef = useRef(projectId);
if (prevProjectIdRef.current !== projectId) {
prevProjectIdRef.current = projectId;
resetPlayer();
// Only reset Zustand state during render (safe — pure state update).
// Imperative cleanup (RAF, intervals) happens in resetPlayer's store reset.
usePlayerStore.getState().reset();
}
// Preserve seek position when refreshKey changes (iframe will remount via key prop).
@@ -100,6 +101,49 @@ export const NLELayout = memo(function NLELayout({
.catch(() => {});
});
// Patch elements with compositionSrc whenever elements or compIdToSrc change.
// The runtime strips data-composition-src from the DOM after loading, so elements
// arrive without it. This bridges the gap using the map built from raw HTML.
// Map keys are composition IDs (e.g. "dark-intro"), while element IDs may be
// DOM IDs with suffixes (e.g. "dark-intro-host"), so we try multiple lookups.
const compIdToSrcRef = useRef(compIdToSrc);
compIdToSrcRef.current = compIdToSrc;
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (compIdToSrc.size === 0) return;
const patchElements = (elements: TimelineElement[]): TimelineElement[] | null => {
const map = compIdToSrcRef.current;
if (map.size === 0) return null;
let patched = false;
const updated = elements.map((el) => {
if (el.compositionSrc) return el;
// Try exact match, then strip common suffixes (-host, -comp, -layer)
const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, ""));
if (src) {
patched = true;
return { ...el, compositionSrc: src };
}
return el;
});
return patched ? updated : null;
};
// Patch current elements immediately
const patched = patchElements(usePlayerStore.getState().elements);
if (patched) usePlayerStore.getState().setElements(patched);
// Subscribe for future element updates — use a flag to prevent re-entrant patching
let patching = false;
return usePlayerStore.subscribe((state, prev) => {
if (patching) return;
if (state.elements === prev.elements || state.elements.length === 0) return;
// Skip if all elements already have compositionSrc
if (state.elements.every((el) => el.compositionSrc)) return;
patching = true;
const result = patchElements(state.elements);
if (result) state.setElements(result);
patching = false;
});
}, [compIdToSrc]);
// Composition drill-down stack
const [compositionStack, setCompositionStack] = useState<CompositionLevel[]>([
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
@@ -126,11 +170,18 @@ export const NLELayout = memo(function NLELayout({
const currentLevel = compositionStack[compositionStack.length - 1];
const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined;
// Save master seek position before drilling down so we can restore it on back-navigation.
// saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads.
const masterSeekRef = useRef(0);
// Drill-down: push a sub-composition onto the stack
const iframeRef_ = iframeRef; // stable ref for the callback
const handleDrillDown = useCallback(
(element: TimelineElement) => {
if (!element.compositionSrc) return;
// Save current master playback position for back-navigation
masterSeekRef.current = usePlayerStore.getState().currentTime;
saveSeekPosition();
// compositionSrc may be a full URL (from runtime manifest) or a relative path
// Extract the element's composition ID from its timeline ID
const compId = element.id;
@@ -185,33 +236,39 @@ export const NLELayout = memo(function NLELayout({
// Navigate back to a specific breadcrumb level
const handleNavigateComposition = useCallback((index: number) => {
// When going back to master (index 0), restore the saved master position
if (index === 0 && masterSeekRef.current > 0) {
usePlayerStore.getState().setCurrentTime(masterSeekRef.current);
}
saveSeekPosition();
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => prev.slice(0, index + 1));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Navigate to a composition when activeCompositionPath changes
const prevActiveCompRef = useRef<string | null>(null);
if (activeCompositionPath && activeCompositionPath !== prevActiveCompRef.current) {
prevActiveCompRef.current = activeCompositionPath;
queueMicrotask(() => usePlayerStore.getState().setElements([]));
// Navigate to a composition when activeCompositionPath changes.
// Uses useEffect to ensure state updates happen after render commit,
// avoiding render-time mutations that React can swallow during batching.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (activeCompositionPath === "index.html") {
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev));
} else if (activeCompositionPath.startsWith("compositions/")) {
} else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) {
const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, "");
const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`;
usePlayerStore.getState().setElements([]);
updateCompositionStack((prev) => {
if (prev[prev.length - 1].id === activeCompositionPath) return prev;
if (prev[prev.length - 1]?.id === activeCompositionPath) return prev;
return [
{ id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` },
{ id: activeCompositionPath, label, previewUrl },
];
});
} else if (!activeCompositionPath) {
usePlayerStore.getState().setElements([]);
}
} else if (!activeCompositionPath && prevActiveCompRef.current) {
prevActiveCompRef.current = null;
queueMicrotask(() => usePlayerStore.getState().setElements([]));
}
}, [activeCompositionPath, projectId, updateCompositionStack]);
// Resize divider handlers
const handleDividerPointerDown = useCallback((e: React.PointerEvent) => {
@@ -0,0 +1,123 @@
import { memo, useState, useRef } from "react";
import { RenderQueueItem } from "./RenderQueueItem";
import type { RenderJob } from "./useRenderQueue";
interface RenderQueueProps {
jobs: RenderJob[];
onDelete: (jobId: string) => void;
onClearCompleted: () => void;
onStartRender: (format: "mp4" | "webm") => void;
isRendering: boolean;
}
function FormatExportButton({
onStartRender,
isRendering,
}: {
onStartRender: (format: "mp4" | "webm") => void;
isRendering: boolean;
}) {
const [format, setFormat] = useState<"mp4" | "webm">("mp4");
return (
<div className="flex items-center gap-0.5">
<select
value={format}
onChange={(e) => setFormat(e.target.value as "mp4" | "webm")}
disabled={isRendering}
className="h-5 px-1 text-[10px] rounded-l bg-neutral-800 border border-neutral-700 text-neutral-300 outline-none disabled:opacity-50"
>
<option value="mp4">MP4</option>
<option value="webm">WebM</option>
</select>
<button
onClick={() => onStartRender(format)}
disabled={isRendering}
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-[#3CE6AC] text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
>
{isRendering ? "Rendering..." : "Export"}
</button>
</div>
);
}
export const RenderQueue = memo(function RenderQueue({
jobs,
onDelete,
onClearCompleted,
onStartRender,
isRendering,
}: RenderQueueProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevCount = useRef(jobs.length);
// Auto-scroll to bottom when new jobs are added (adjust during render)
if (jobs.length > prevCount.current && listRef.current) {
queueMicrotask(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });
});
}
prevCount.current = jobs.length;
const completedCount = jobs.filter((j) => j.status !== "rendering").length;
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/50 flex-shrink-0">
<span className="text-[11px] font-medium text-neutral-500 uppercase tracking-wider">
Renders ({jobs.length})
</span>
<div className="flex items-center gap-1.5">
{completedCount > 0 && (
<button
onClick={onClearCompleted}
className="text-[10px] text-neutral-600 hover:text-neutral-400 transition-colors"
>
Clear
</button>
)}
<FormatExportButton onStartRender={onStartRender} isRendering={isRendering} />
</div>
</div>
{/* Job list */}
<div ref={listRef} className="flex-1 overflow-y-auto">
{jobs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full px-4 gap-2">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-neutral-700"
>
<rect
x="2"
y="2"
width="20"
height="20"
rx="2.18"
ry="2.18"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M7 2v20M17 2v20M2 12h20M2 7h5M2 17h5M17 17h5M17 7h5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<p className="text-[10px] text-neutral-600 text-center">No renders yet</p>
</div>
) : (
jobs.map((job) => (
<RenderQueueItem key={job.id} job={job} onDelete={() => onDelete(job.id)} />
))
)}
</div>
</div>
);
});
@@ -0,0 +1,133 @@
import { memo, useCallback, useState } from "react";
import type { RenderJob } from "./useRenderQueue";
interface RenderQueueItemProps {
job: RenderJob;
onDelete: () => void;
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
}
function formatTimeAgo(timestamp: number): string {
const diff = Date.now() - timestamp;
if (diff < 60000) return "just now";
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
return `${Math.floor(diff / 3600000)}h ago`;
}
export const RenderQueueItem = memo(function RenderQueueItem({
job,
onDelete,
}: RenderQueueItemProps) {
const [hovered, setHovered] = useState(false);
const handleDownload = useCallback(() => {
const a = document.createElement("a");
a.href = `/api/render/${job.id}/download`;
a.download = job.filename;
a.click();
}, [job.id, job.filename]);
return (
<div
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
className="px-3 py-2.5 border-b border-neutral-800/30 last:border-0"
>
<div className="flex items-center gap-2">
{/* Status indicator */}
<div className="flex-shrink-0">
{job.status === "rendering" && (
<div className="w-2 h-2 rounded-full bg-[#3CE6AC] animate-pulse" />
)}
{job.status === "complete" && <div className="w-2 h-2 rounded-full bg-green-400" />}
{job.status === "failed" && <div className="w-2 h-2 rounded-full bg-red-400" />}
{job.status === "cancelled" && <div className="w-2 h-2 rounded-full bg-neutral-600" />}
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-neutral-300 truncate">
{job.filename}
</span>
{job.durationMs && (
<span className="text-[9px] text-neutral-600 flex-shrink-0">
{formatDuration(job.durationMs)}
</span>
)}
</div>
{/* Progress bar + percentage */}
{job.status === "rendering" && (
<div className="mt-1">
<div className="flex items-center justify-between mb-0.5">
<span className="text-[9px] text-neutral-500">{job.stage || "Rendering"}</span>
<span className="text-[9px] font-mono text-[#3CE6AC]">{job.progress}%</span>
</div>
<div className="w-full h-1 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-[#3CE6AC] rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
)}
{job.status !== "rendering" && (
<span className="text-[9px] text-neutral-600">{formatTimeAgo(job.createdAt)}</span>
)}
</div>
{/* Actions */}
{hovered && (
<div className="flex items-center gap-1 flex-shrink-0">
{job.status === "complete" && (
<button
onClick={handleDownload}
className="p-1 rounded text-neutral-500 hover:text-green-400 transition-colors"
title="Download"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}
<button
onClick={onDelete}
className="p-1 rounded text-neutral-500 hover:text-red-400 transition-colors"
title="Remove"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
</div>
</div>
);
});
@@ -0,0 +1,151 @@
import { useState, useEffect, useCallback, useRef } from "react";
export interface RenderJob {
id: string;
status: "rendering" | "complete" | "failed" | "cancelled";
progress: number;
stage?: string;
filename: string;
createdAt: number;
durationMs?: number;
}
export function useRenderQueue(projectId: string | null) {
const [jobs, setJobs] = useState<RenderJob[]>([]);
const eventSourceRef = useRef<EventSource | null>(null);
const activeJobRef = useRef<string | null>(null);
// Load completed renders from the server
const loadRenders = useCallback(async () => {
if (!projectId) return;
try {
const res = await fetch(`/api/projects/${projectId}/renders`);
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.renders)) {
setJobs((prev) => {
const existing = new Set(prev.map((j) => j.id));
const fromServer: RenderJob[] = data.renders
.filter((r: { id: string }) => !existing.has(r.id))
.map((r: { id: string; filename: string; createdAt: number; size: number }) => ({
id: r.id,
status: "complete" as const,
progress: 100,
filename: r.filename,
createdAt: r.createdAt,
}));
return [...prev, ...fromServer];
});
}
} catch {
// ignore
}
}, [projectId]);
useEffect(() => {
loadRenders();
}, [loadRenders]);
// Start a render and track progress via SSE
const startRender = useCallback(
async (fps = 30, quality = "standard", format: "mp4" | "webm" = "mp4") => {
if (!projectId) return;
const startTime = Date.now();
const res = await fetch(`/api/projects/${projectId}/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fps, quality, format }),
});
if (!res.ok) return;
const { jobId } = await res.json();
const ext = format === "webm" ? ".webm" : ".mp4";
const job: RenderJob = {
id: jobId,
status: "rendering",
progress: 0,
filename: `${jobId}${ext}`,
createdAt: startTime,
};
setJobs((prev) => [...prev, job]);
activeJobRef.current = jobId;
// Track progress via SSE
const es = new EventSource(`/api/render/${jobId}/progress`);
eventSourceRef.current = es;
es.addEventListener("progress", (event) => {
try {
const data = JSON.parse(event.data);
setJobs((prev) =>
prev.map((j) =>
j.id === jobId
? {
...j,
progress: data.progress ?? j.progress,
stage: data.stage ?? data.message ?? j.stage,
status:
data.status === "complete"
? "complete"
: data.status === "failed"
? "failed"
: j.status,
durationMs: data.status === "complete" ? Date.now() - startTime : undefined,
}
: j,
),
);
if (data.status === "complete" || data.status === "failed") {
es.close();
activeJobRef.current = null;
}
} catch {
// ignore parse errors
}
});
es.onerror = () => {
es.close();
setJobs((prev) =>
prev.map((j) =>
j.id === jobId && j.status === "rendering" ? { ...j, status: "failed" } : j,
),
);
activeJobRef.current = null;
};
return jobId;
},
[projectId],
);
const deleteRender = useCallback(async (jobId: string) => {
try {
await fetch(`/api/render/${jobId}`, { method: "DELETE" });
} catch {
// ignore
}
setJobs((prev) => prev.filter((j) => j.id !== jobId));
}, []);
const clearCompleted = useCallback(() => {
setJobs((prev) => prev.filter((j) => j.status === "rendering"));
}, []);
// Clean up EventSource on unmount or projectId change
useEffect(() => {
return () => {
eventSourceRef.current?.close();
eventSourceRef.current = null;
};
}, [projectId]);
return {
jobs,
startRender,
deleteRender,
clearCompleted,
isRendering: jobs.some((j) => j.status === "rendering"),
};
}
@@ -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">
@@ -1,164 +0,0 @@
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import { usePlayerStore } from "../../player/store/playerStore";
import { formatTime } from "../../player/lib/time";
interface EditPopoverProps {
rangeStart: number;
rangeEnd: number;
anchorX: number;
anchorY: number;
onClose: () => void;
}
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
const elements = usePlayerStore((s) => s.elements);
const [prompt, setPrompt] = useState("");
const [copied, setCopied] = useState(false);
const popoverRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const start = Math.min(rangeStart, rangeEnd);
const end = Math.max(rangeStart, rangeEnd);
const elementsInRange = useMemo(() => {
return elements.filter((el) => {
const elEnd = el.start + el.duration;
return el.start < end && elEnd > start;
});
}, [elements, start, end]);
useEffect(() => {
setTimeout(() => textareaRef.current?.focus(), 50);
}, []);
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handleKey);
return () => window.removeEventListener("keydown", handleKey);
}, [onClose]);
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
setTimeout(() => window.addEventListener("mousedown", handleClick), 100);
return () => window.removeEventListener("mousedown", handleClick);
}, [onClose]);
const buildClipboardText = useCallback(() => {
const elementLines = elementsInRange
.map(
(el) =>
`- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
)
.join("\n");
return `Edit the following HyperFrames composition:
Time range: ${formatTime(start)}${formatTime(end)}
Elements in range:
${elementLines || "(none)"}
User request:
${prompt.trim() || "(no prompt provided)"}
Instructions:
Modify only the elements listed above within the specified time range.
The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
Preserve all other elements and timing outside this range.`;
}, [start, end, elementsInRange, prompt]);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(buildClipboardText());
} catch {
const ta = document.createElement("textarea");
ta.value = buildClipboardText();
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => {
setCopied(false);
onClose();
}, 800);
}, [buildClipboardText, onClose]);
const style: React.CSSProperties = {
position: "fixed",
left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
top: Math.max(8, anchorY - 280),
zIndex: 200,
};
return (
<div ref={popoverRef} style={style}>
<div className="w-80 bg-neutral-900 border border-neutral-700/60 rounded-xl shadow-2xl shadow-black/40 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-neutral-800/60">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-blue-400" />
<span className="text-[11px] font-medium text-neutral-300">
{formatTime(start)} {formatTime(end)}
</span>
</div>
<span className="text-[10px] text-neutral-600">
{elementsInRange.length} element{elementsInRange.length !== 1 ? "s" : ""}
</span>
</div>
{/* Elements */}
{elementsInRange.length > 0 && (
<div className="px-4 py-2 border-b border-neutral-800/40 max-h-24 overflow-y-auto">
{elementsInRange.map((el) => (
<div key={el.id} className="flex items-center justify-between py-0.5">
<span className="text-[10px] font-mono text-blue-400/80">#{el.id}</span>
<span className="text-[10px] text-neutral-600">{el.tag}</span>
</div>
))}
</div>
)}
{/* Prompt */}
<div className="p-3">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleCopy();
}
}}
placeholder="What should change?"
rows={2}
className="w-full px-3 py-2 text-xs bg-neutral-800/60 border border-neutral-700/40 rounded-lg text-neutral-200 placeholder:text-neutral-600 resize-none focus:outline-none focus:border-blue-500/40 transition-colors"
/>
</div>
{/* Action */}
<div className="px-3 pb-3">
<button
onClick={handleCopy}
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
copied
? "bg-green-500/20 text-green-400 border border-green-500/30"
: "bg-blue-500/15 text-blue-400 border border-blue-500/25 hover:bg-blue-500/25"
}`}
>
{copied ? "Copied!" : "Copy to Agent"}
{!copied && <span className="text-[9px] text-blue-400/50 ml-1.5">Cmd+Enter</span>}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,194 @@
import React, { useState, useRef, useCallback, useEffect, type ReactNode } from "react";
import { motion, AnimatePresence } from "motion/react";
interface ExpandOnHoverProps {
children: ReactNode;
expandedContent?: ReactNode | ((close: () => void) => ReactNode);
expandScale?: number;
delay?: number;
className?: string;
onClick?: () => void;
}
export function ExpandOnHover({
children,
expandedContent,
expandScale = 0.75,
delay = 300,
className = "",
onClick,
}: ExpandOnHoverProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [origin, setOrigin] = useState({ x: 0, y: 0, w: 0, h: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const expandedRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const close = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
setIsExpanded(false);
}, []);
const open = useCallback(() => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
setOrigin({ x: rect.left, y: rect.top, w: rect.width, h: rect.height });
setIsExpanded(true);
}, []);
const handleCardEnter = useCallback(() => {
if (isExpanded) return;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(open, delay);
}, [delay, open, isExpanded]);
const handleCardLeave = useCallback(() => {
if (isExpanded) return;
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, [isExpanded]);
// When expanded: track mouse position. If mouse stays outside the expanded
// card for 600ms continuously, close. Any re-entry resets the timer.
// Note: useEffect with [isExpanded] is acceptable — subscribes to window mousemove
// only while expanded, with cleanup on collapse. Can't be a mount effect.
// eslint-disable-next-line no-restricted-syntax
useEffect(() => {
if (!isExpanded) return;
const CLOSE_DELAY = 600; // ms mouse must be outside to close
const START_DELAY = 400; // ms before we start checking (let animation settle)
let tracking = false;
const startTracking = setTimeout(() => {
tracking = true;
}, START_DELAY);
const handleMouseMove = (e: MouseEvent) => {
if (!tracking) return;
const el = expandedRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// Add generous padding so edge movements don't trigger close
const pad = 20;
const inside =
e.clientX >= rect.left - pad &&
e.clientX <= rect.right + pad &&
e.clientY >= rect.top - pad &&
e.clientY <= rect.bottom + pad;
if (inside) {
// Mouse is inside — cancel any pending close
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
} else {
// Mouse is outside — start close countdown if not already started
if (!closeTimerRef.current) {
closeTimerRef.current = setTimeout(() => {
setIsExpanded(false);
}, CLOSE_DELAY);
}
}
};
window.addEventListener("mousemove", handleMouseMove);
return () => {
clearTimeout(startTracking);
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
closeTimerRef.current = null;
}
window.removeEventListener("mousemove", handleMouseMove);
};
}, [isExpanded]);
const vw = typeof window !== "undefined" ? window.innerWidth : 1440;
const vh = typeof window !== "undefined" ? window.innerHeight : 900;
const targetW = vw * expandScale;
const targetH = vh * expandScale;
const targetX = (vw - targetW) / 2;
const targetY = (vh - targetH) / 2;
return (
<>
<div
ref={containerRef}
className={className}
onMouseEnter={handleCardEnter}
onMouseLeave={handleCardLeave}
onClick={onClick}
style={{ opacity: isExpanded ? 0 : 1, transition: "opacity 100ms ease-out" }}
>
{children}
</div>
<AnimatePresence>
{isExpanded && (
<>
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onClick={close}
/>
{/* Expanded card */}
<motion.div
ref={expandedRef}
initial={{
left: origin.x,
top: origin.y,
width: origin.w,
height: origin.h,
}}
animate={{
left: targetX,
top: targetY,
width: targetW,
height: targetH,
}}
exit={{
left: origin.x,
top: origin.y,
width: origin.w,
height: origin.h,
}}
transition={{
type: "spring",
stiffness: 280,
damping: 28,
mass: 0.8,
}}
className="fixed z-50 overflow-hidden rounded-[16px] shadow-dialog"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
close();
onClick?.();
}}
>
{typeof expandedContent === "function"
? expandedContent(close)
: (expandedContent ?? children)}
</motion.div>
</>
)}
</AnimatePresence>
</>
);
}