mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat(studio): redesign left panel — Code tab, Renders header, Lint bottom (#141)
## Summary Major layout redesign matching the Paper mockup. The Code editor moves to the left panel, Renders moves to the header, and interactions are simplified. ## Layout changes | Before | After | |---|---| | Left: Compositions \| Assets | Left: **Code** \| Compositions \| Assets | | Right: Code \| Renders tabs | Right: Renders-only (hidden by default) | | Header: Projects ← / Lint / panel toggles | Header: title / sidebar toggle / **Renders button** | | Lint: header button | Lint: **pinned to bottom** of left panel | ## Interaction changes - **Renders panel** opens via a dedicated header button (teal when active), hidden by default - **ExpandOnHover removed** — replaced with inline autoplay on thumbnails: - Compositions: hover shows a tiny iframe (1920px scaled to 80px, 300ms debounce) - Assets: hover shows `<video autoPlay muted loop>` in the thumbnail cell - **Deleted** `ExpandOnHover.tsx` (194 lines) and `ExpandedVideoPreview.tsx` (35 lines) - **Left panel max width**: 50% viewport, auto-expands to 50% when opening a file in Code tab ## Visual polish - Equal-width tabs (Code \| Compositions \| Assets) - Sidebar toggle button highlighted when panel is open - Phosphor duotone file-type icons (`FileHtml`, `FileCss`, `FileJs`, `FileTs`, etc.) - "FILES" header removed from file tree - Redundant "RENDERS (N)" title removed from renders panel 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -1,194 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
interface ExpandedVideoPreviewProps {
|
||||
src: string;
|
||||
name: string;
|
||||
subtitle: string;
|
||||
action: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared expanded video preview used by AssetsTab (video assets) and
|
||||
* the Renders panel. Autoplays the video muted+looped inside a full-bleed
|
||||
* card. Caller provides the footer action slot (Copy Path, Open, etc.).
|
||||
*/
|
||||
export function ExpandedVideoPreview({ src, name, subtitle, action }: ExpandedVideoPreviewProps) {
|
||||
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">
|
||||
<video
|
||||
src={src}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
className="max-w-full max-h-full object-contain rounded"
|
||||
/>
|
||||
</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 className="min-w-0 flex-1 mr-4">
|
||||
<div className="text-sm font-medium text-neutral-200 truncate">{name}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5 truncate">{subtitle}</div>
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user