mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(studio): remove home page, auto-select project (#145)
## Summary Removes the `ProjectPicker` home screen entirely. The studio now boots directly into the editor by auto-selecting the first available project from `/api/projects`. ## Changes - **Auto-select on load**: When no `#project/` hash is present, fetches the project list and navigates to the first one - **Type narrowing**: Added `if (resolving || !projectId)` early return so TypeScript narrows `projectId` to `string` for all downstream props - **Removed**: `ProjectPicker`, `ProjectCard`, `ExpandedPreviewIframe` components (~280 lines), `handleSelectProject` callback, `ProjectEntry` interface ## Why The CLI studio always has exactly one project. The home page was an extra click with no value. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
+22
-282
@@ -16,12 +16,6 @@ interface EditingFile {
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
interface ProjectEntry {
|
||||
id: string;
|
||||
title?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
interface LintFinding {
|
||||
severity: "error" | "warning";
|
||||
message: string;
|
||||
@@ -29,8 +23,6 @@ interface LintFinding {
|
||||
fixHint?: string;
|
||||
}
|
||||
|
||||
import { ExpandOnHover } from "./components/ui/ExpandOnHover";
|
||||
|
||||
// ── Media file detection and preview ──
|
||||
|
||||
const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
|
||||
@@ -122,246 +114,6 @@ function MediaPreview({ projectId, filePath }: { projectId: string; filePath: st
|
||||
);
|
||||
}
|
||||
|
||||
// ── Project Card with hover-to-preview ──
|
||||
|
||||
function ExpandedPreviewIframe({ src }: { src: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [dims, setDims] = useState({ w: 1920, h: 1080 });
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
// Recalculate scale when container resizes or dims change.
|
||||
// Note: useEffect with [dims] dep — syncs with ResizeObserver (external system).
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const cw = el.clientWidth;
|
||||
const ch = el.clientHeight;
|
||||
// Fit the composition inside the container (contain, not cover)
|
||||
const s = Math.min(cw / dims.w, ch / dims.h);
|
||||
setScale(s);
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [dims]);
|
||||
|
||||
// After iframe loads: detect composition dimensions, seek, and play
|
||||
const handleLoad = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
let attempts = 0;
|
||||
const interval = setInterval(() => {
|
||||
try {
|
||||
const doc = iframe.contentDocument;
|
||||
if (doc) {
|
||||
const comp = doc.querySelector("[data-composition-id]") as HTMLElement | null;
|
||||
if (comp) {
|
||||
const w = parseInt(comp.getAttribute("data-width") ?? "0", 10);
|
||||
const h = parseInt(comp.getAttribute("data-height") ?? "0", 10);
|
||||
if (w > 0 && h > 0) setDims({ w, h });
|
||||
}
|
||||
}
|
||||
const win = iframe.contentWindow as Window & {
|
||||
__player?: { seek: (t: number) => void; play: () => void };
|
||||
};
|
||||
if (win?.__player) {
|
||||
win.__player.seek(2);
|
||||
win.__player.play();
|
||||
clearInterval(interval);
|
||||
}
|
||||
} catch {
|
||||
/* cross-origin */
|
||||
}
|
||||
if (++attempts > 25) clearInterval(interval);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
// Center the scaled iframe
|
||||
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 ref={containerRef} className="w-full h-full relative overflow-hidden bg-black">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
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})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectCard({ project: p, onSelect }: { project: ProjectEntry; onSelect: () => void }) {
|
||||
const thumbnailUrl = `/api/projects/${p.id}/thumbnail/index.html?t=0.5`;
|
||||
const previewUrl = `/api/projects/${p.id}/preview`;
|
||||
|
||||
const card = (
|
||||
<div className="rounded-xl overflow-hidden bg-neutral-900 border border-neutral-800/60 hover:border-[#3CE6AC]/30 hover:shadow-lg hover:shadow-[#3CE6AC]/5 transition-all duration-200 cursor-pointer">
|
||||
<div className="aspect-video bg-neutral-950 relative overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={p.title ?? p.id}
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="px-3.5 py-3">
|
||||
<div className="text-sm font-medium text-neutral-200 truncate">{p.title ?? p.id}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono truncate mt-0.5">{p.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandOnHover
|
||||
expandedContent={(closeExpand) => (
|
||||
<div className="w-full h-full bg-neutral-950 rounded-[16px] overflow-hidden flex flex-col">
|
||||
<div className="flex-1 min-h-0">
|
||||
<ExpandedPreviewIframe src={previewUrl} />
|
||||
</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">{p.title ?? p.id}</div>
|
||||
<div className="text-[10px] text-neutral-600 font-mono mt-0.5">{p.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeExpand();
|
||||
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>
|
||||
)}
|
||||
onClick={onSelect}
|
||||
expandScale={0.6}
|
||||
delay={400}
|
||||
>
|
||||
{card}
|
||||
</ExpandOnHover>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Project Picker ──
|
||||
|
||||
function ProjectPicker({ onSelect }: { onSelect: (id: string) => void }) {
|
||||
const [projects, setProjects] = useState<ProjectEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useMountEffect(() => {
|
||||
fetch("/api/projects")
|
||||
.then((r) => r.json())
|
||||
.then((data: { projects?: ProjectEntry[] }) => {
|
||||
setProjects(data.projects ?? []);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-neutral-950 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="max-w-4xl mx-auto px-6 pt-16 pb-8">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<svg width="32" height="32" viewBox="0 0 512 512" className="flex-shrink-0">
|
||||
<rect width="512" height="512" rx="115" fill="#1A1913" />
|
||||
<g strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline
|
||||
points="156,176 76,256 156,336"
|
||||
fill="none"
|
||||
stroke="#7B7568"
|
||||
strokeWidth="32"
|
||||
/>
|
||||
<line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
|
||||
<polygon
|
||||
points="336,176 436,256 336,336"
|
||||
fill="#3CE6AC"
|
||||
stroke="#3CE6AC"
|
||||
strokeWidth="32"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<h1 className="text-2xl font-bold text-neutral-100 tracking-tight">HyperFrames Studio</h1>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 ml-11">Your projects</p>
|
||||
</div>
|
||||
|
||||
{/* Project grid */}
|
||||
<div className="max-w-4xl mx-auto px-6 pb-16">
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="aspect-video rounded-xl bg-neutral-900 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-4">
|
||||
<svg width="48" height="48" viewBox="0 0 512 512" className="opacity-20">
|
||||
<rect width="512" height="512" rx="115" fill="#1A1913" />
|
||||
<g strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline
|
||||
points="156,176 76,256 156,336"
|
||||
fill="none"
|
||||
stroke="#7B7568"
|
||||
strokeWidth="32"
|
||||
/>
|
||||
<line x1="206" y1="346" x2="286" y2="166" stroke="#D8D3C5" strokeWidth="32" />
|
||||
<polygon
|
||||
points="336,176 436,256 336,336"
|
||||
fill="#3CE6AC"
|
||||
stroke="#3CE6AC"
|
||||
strokeWidth="32"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-neutral-400 font-medium">No projects yet</p>
|
||||
<p className="text-xs text-neutral-600 mt-1">
|
||||
Run{" "}
|
||||
<code className="px-1.5 py-0.5 rounded bg-neutral-800 text-[#3CE6AC] text-[11px]">
|
||||
hyperframes init
|
||||
</code>{" "}
|
||||
to create one
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{projects.map((p) => (
|
||||
<ProjectCard key={p.id} project={p} onSelect={() => onSelect(p.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Lint Modal ──
|
||||
|
||||
function LintModal({
|
||||
@@ -502,28 +254,27 @@ export function StudioApp() {
|
||||
const [projectId, setProjectId] = useState<string | null>(null);
|
||||
const [resolving, setResolving] = useState(true);
|
||||
|
||||
useMountEffect(() => {
|
||||
const hash = window.location.hash;
|
||||
const projectMatch = hash.match(/project\/([^/]+)/);
|
||||
const sessionMatch = hash.match(/session\/([^/]+)/);
|
||||
if (projectMatch) {
|
||||
setProjectId(projectMatch[1]);
|
||||
setResolving(false);
|
||||
} else if (sessionMatch) {
|
||||
fetch(`/api/resolve-session/${sessionMatch[1]}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { projectId?: string }) => {
|
||||
if (data.projectId) {
|
||||
window.location.hash = `#project/${data.projectId}`;
|
||||
setProjectId(data.projectId);
|
||||
}
|
||||
setResolving(false);
|
||||
})
|
||||
.catch(() => setResolving(false));
|
||||
} else {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
const hashMatch = window.location.hash.match(/^#project\/([^/]+)/);
|
||||
if (hashMatch) {
|
||||
setProjectId(hashMatch[1]);
|
||||
setResolving(false);
|
||||
return;
|
||||
}
|
||||
});
|
||||
// No hash — auto-select first available project
|
||||
fetch("/api/projects")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const first = (data.projects ?? [])[0];
|
||||
if (first) {
|
||||
setProjectId(first.id);
|
||||
window.location.hash = `#project/${first.id}`;
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setResolving(false));
|
||||
}, []);
|
||||
|
||||
const [editingFile, setEditingFile] = useState<EditingFile | null>(null);
|
||||
const [rightTab, setRightTab] = useState<"code" | "renders">("code");
|
||||
@@ -677,15 +428,6 @@ export function StudioApp() {
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
const handleSelectProject = useCallback((id: string) => {
|
||||
window.location.hash = `#project/${id}`;
|
||||
setProjectId(id);
|
||||
setActiveCompPath(null);
|
||||
setEditingFile(null);
|
||||
setCompIdToSrc(new Map());
|
||||
setFileTree([]);
|
||||
}, []);
|
||||
|
||||
const handleFileSelect = useCallback((path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
@@ -779,17 +521,15 @@ export function StudioApp() {
|
||||
panelDragRef.current = null;
|
||||
}, []);
|
||||
|
||||
if (resolving) {
|
||||
if (resolving || !projectId) {
|
||||
return (
|
||||
<div className="h-screen w-screen bg-neutral-950 flex items-center justify-center">
|
||||
<div className="text-sm text-neutral-500">Loading...</div>
|
||||
<div className="w-4 h-4 rounded-full bg-[#3CE6AC] animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return <ProjectPicker onSelect={handleSelectProject} />;
|
||||
}
|
||||
// At this point projectId is guaranteed non-null (narrowed by the guard above)
|
||||
|
||||
const compositions = fileTree.filter((f) => f === "index.html" || f.startsWith("compositions/"));
|
||||
const assets = fileTree.filter(
|
||||
|
||||
Reference in New Issue
Block a user