mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(studio): shell UX — data-loss guards, error surfacing, dialog contracts, toasts (#1964)
This commit is contained in:
+13
-14
@@ -27,6 +27,7 @@ import { useFrameCapture } from "./hooks/useFrameCapture";
|
||||
import { useLintModal } from "./hooks/useLintModal";
|
||||
import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
|
||||
import { useToast } from "./hooks/useToast";
|
||||
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
|
||||
import { useStudioUrlState } from "./hooks/useStudioUrlState";
|
||||
import {
|
||||
buildStudioContextValue,
|
||||
@@ -132,7 +133,7 @@ export function StudioApp() {
|
||||
return !v;
|
||||
});
|
||||
}, []);
|
||||
const { appToast, showToast, dismissToast } = useToast();
|
||||
const { toasts, showToast, dismissToast } = useToast();
|
||||
const panelLayout = usePanelLayout({
|
||||
rightCollapsed: initialUrlStateRef.current.rightCollapsed,
|
||||
rightPanelTab: initialUrlStateRef.current.rightPanelTab,
|
||||
@@ -389,17 +390,13 @@ export function StudioApp() {
|
||||
},
|
||||
[appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
|
||||
);
|
||||
const handleSelectComposition = useCallback(
|
||||
(comp: string) => {
|
||||
setActiveCompPath(comp.endsWith(".html") ? comp : null);
|
||||
fileManager.setEditingFile({ path: comp, content: null });
|
||||
fetch(`/api/projects/${projectId}/files/${comp}`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => fileManager.setEditingFile({ path: comp, content: data.content }))
|
||||
.catch(() => {});
|
||||
},
|
||||
[projectId, fileManager],
|
||||
);
|
||||
const { setEditingFile } = fileManager;
|
||||
const handleSelectComposition = useCompositionContentLoader({
|
||||
projectId,
|
||||
setEditingFile,
|
||||
setActiveCompPath,
|
||||
showToast,
|
||||
});
|
||||
const {
|
||||
designPanelActive,
|
||||
inspectorPanelActive,
|
||||
@@ -484,6 +481,7 @@ export function StudioApp() {
|
||||
captureFrameFilename={frameCapture.captureFrameFilename}
|
||||
handleCaptureFrameClick={frameCapture.handleCaptureFrameClick}
|
||||
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
|
||||
capturing={frameCapture.capturing}
|
||||
inspectorButtonActive={inspectorButtonActive}
|
||||
inspectorPanelActive={inspectorPanelActive}
|
||||
onExport={() => {
|
||||
@@ -496,7 +494,7 @@ export function StudioApp() {
|
||||
{previewPersistence.domEditSaveQueuePaused && (
|
||||
<SaveQueuePausedBanner
|
||||
message={previewPersistence.domEditSaveQueuePaused}
|
||||
onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
|
||||
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
|
||||
/>
|
||||
)}
|
||||
{viewModeValue.viewMode === "storyboard" && (
|
||||
@@ -578,6 +576,7 @@ export function StudioApp() {
|
||||
</div>
|
||||
<StudioOverlays
|
||||
projectId={projectId}
|
||||
projectDir={fileManager.projectDir}
|
||||
lintModal={lintModal}
|
||||
closeLintModal={closeLintModal}
|
||||
consoleErrors={consoleErrors}
|
||||
@@ -585,7 +584,7 @@ export function StudioApp() {
|
||||
domEditSession={domEditSession}
|
||||
activeCompPath={activeCompPath}
|
||||
dragOverlayActive={dragOverlay.active}
|
||||
appToast={appToast}
|
||||
toasts={toasts}
|
||||
dismissToast={dismissToast}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, type CSSProperties } from "react";
|
||||
import { useMountEffect } from "../hooks/useMountEffect";
|
||||
import { type AgentModalAnchorPoint, clampNumber } from "../utils/studioHelpers";
|
||||
import { useDialogBehavior } from "./ui/useDialogBehavior";
|
||||
|
||||
function getAgentModalPositionStyle(
|
||||
anchorPoint: AgentModalAnchorPoint | null,
|
||||
@@ -39,7 +40,16 @@ export function AskAgentModal({
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const modalPositionStyle = getAgentModalPositionStyle(anchorPoint);
|
||||
// A dirty draft vetoes Escape/backdrop closes — a stray click must not
|
||||
// discard typed instructions. The X button and Copy still close directly.
|
||||
const { requestClose } = useDialogBehavior({
|
||||
open: true,
|
||||
onClose,
|
||||
containerRef,
|
||||
canClose: () => !value.trim(),
|
||||
});
|
||||
|
||||
useMountEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
@@ -54,13 +64,18 @@ export function AskAgentModal({
|
||||
<div
|
||||
className={
|
||||
anchorPoint
|
||||
? "fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
|
||||
: "fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
? "hf-backdrop-in fixed inset-0 z-[100] bg-black/60 backdrop-blur-sm"
|
||||
: "hf-backdrop-in fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
}
|
||||
onClick={onClose}
|
||||
onClick={requestClose}
|
||||
>
|
||||
<div
|
||||
className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl ${
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Copy prompt to AI agent"
|
||||
tabIndex={-1}
|
||||
className={`w-[480px] rounded-2xl border border-neutral-800 bg-neutral-950 shadow-2xl outline-none ${
|
||||
anchorPoint ? "fixed" : ""
|
||||
}`}
|
||||
style={modalPositionStyle}
|
||||
@@ -74,8 +89,9 @@ export function AskAgentModal({
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50"
|
||||
className="p-1 rounded-md text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800/50 active:scale-[0.98]"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
@@ -100,7 +116,8 @@ export function AskAgentModal({
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleSubmit();
|
||||
if (e.key === "Escape") onClose();
|
||||
// Escape is handled at the document level by useDialogBehavior,
|
||||
// guarded against discarding a dirty draft.
|
||||
}}
|
||||
/>
|
||||
{contextPreview && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { XIcon, WarningIcon, CheckCircleIcon, CaretRightIcon } from "@phosphor-icons/react";
|
||||
import { copyTextToClipboard } from "../utils/clipboard";
|
||||
import { useDialogBehavior } from "./ui/useDialogBehavior";
|
||||
|
||||
export interface LintFinding {
|
||||
severity: "error" | "warning";
|
||||
@@ -12,16 +13,28 @@ export interface LintFinding {
|
||||
export function LintModal({
|
||||
findings,
|
||||
projectId,
|
||||
projectDir,
|
||||
title = "HyperFrame Lint Results",
|
||||
promptIntro = "Fix these HyperFrames lint issues",
|
||||
onClose,
|
||||
}: {
|
||||
findings: LintFinding[];
|
||||
projectId: string;
|
||||
/** Real on-disk project directory for the agent prompt (not the browser URL). */
|
||||
projectDir?: string | null;
|
||||
/** Header subtitle — parameterize so console errors don't masquerade as lint results. */
|
||||
title?: string;
|
||||
/** First line of the copied agent prompt. */
|
||||
promptIntro?: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const errors = findings.filter((f) => f.severity === "error");
|
||||
const warnings = findings.filter((f) => f.severity === "warning");
|
||||
const hasIssues = findings.length > 0;
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copyFailed, setCopyFailed] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { requestClose } = useDialogBehavior({ open: true, onClose, containerRef });
|
||||
|
||||
const handleCopyToAgent = async () => {
|
||||
const lines = findings.map((f) => {
|
||||
@@ -30,21 +43,31 @@ export function LintModal({
|
||||
if (f.fixHint) line += `\n Fix: ${f.fixHint}`;
|
||||
return line;
|
||||
});
|
||||
const text = `Fix these HyperFrames lint issues for project "${projectId}":\n\nProject path: ${window.location.href}\n\n${lines.join("\n\n")}`;
|
||||
const pathLine = projectDir ? `Project path: ${projectDir}\n\n` : "";
|
||||
const text = `${promptIntro} for project "${projectId}":\n\n${pathLine}${lines.join("\n\n")}`;
|
||||
const copiedText = await copyTextToClipboard(text);
|
||||
if (copiedText) {
|
||||
setCopied(true);
|
||||
setCopyFailed(false);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} else {
|
||||
setCopyFailed(true);
|
||||
setTimeout(() => setCopyFailed(false), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
className="hf-backdrop-in fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<div
|
||||
className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden"
|
||||
ref={containerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
tabIndex={-1}
|
||||
className="bg-neutral-950 border border-neutral-800 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col overflow-hidden outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -65,12 +88,13 @@ export function LintModal({
|
||||
? `${errors.length} error${errors.length !== 1 ? "s" : ""}, ${warnings.length} warning${warnings.length !== 1 ? "s" : ""}`
|
||||
: "All checks passed"}
|
||||
</h2>
|
||||
<p className="text-xs text-neutral-500">HyperFrame Lint Results</p>
|
||||
<p className="text-xs text-neutral-500">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
|
||||
aria-label="Close"
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors active:scale-[0.98]"
|
||||
>
|
||||
<XIcon size={16} />
|
||||
</button>
|
||||
@@ -81,13 +105,19 @@ export function LintModal({
|
||||
<div className="flex items-center justify-end px-5 py-2 border-b border-neutral-800/50">
|
||||
<button
|
||||
onClick={handleCopyToAgent}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors ${
|
||||
className={`px-3 py-1 text-xs font-medium rounded-lg transition-colors active:scale-[0.98] ${
|
||||
copied
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-studio-accent hover:bg-studio-accent/80 text-white"
|
||||
: copyFailed
|
||||
? "bg-red-600 text-white"
|
||||
: "bg-studio-accent hover:bg-studio-accent/80 text-white"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to Agent"}
|
||||
{copied
|
||||
? "Copied!"
|
||||
: copyFailed
|
||||
? "Copy failed — check permissions"
|
||||
: "Copy to Agent"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes";
|
||||
|
||||
function MediaErrorPanel({ name, filePath }: { name: string; filePath: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-4 bg-neutral-950 gap-2">
|
||||
<svg
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-neutral-600"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" strokeLinecap="round" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="text-sm text-neutral-400 font-medium">{name}</span>
|
||||
<span className="text-[11px] text-neutral-600 font-mono">{filePath}</span>
|
||||
<span className="text-[10px] text-neutral-500">
|
||||
Couldn't load this file — it may be missing or corrupt
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) {
|
||||
const serveUrl = `/api/projects/${projectId}/preview/${filePath}`;
|
||||
const name = filePath.split("/").pop() ?? filePath;
|
||||
// Keyed by path so switching to another file clears a previous failure.
|
||||
const [failedPath, setFailedPath] = useState<string | null>(null);
|
||||
const failed = failedPath === filePath;
|
||||
const setFailed = () => setFailedPath(filePath);
|
||||
|
||||
if (failed) return <MediaErrorPanel name={name} filePath={filePath} />;
|
||||
|
||||
if (IMAGE_EXT.test(filePath)) {
|
||||
return (
|
||||
@@ -10,6 +43,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
|
||||
<img
|
||||
src={serveUrl}
|
||||
alt={name}
|
||||
onError={setFailed}
|
||||
className="max-w-full max-h-[70%] object-contain rounded border border-neutral-800"
|
||||
/>
|
||||
<span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
@@ -23,6 +57,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
|
||||
<video
|
||||
src={serveUrl}
|
||||
controls
|
||||
onError={setFailed}
|
||||
className="max-w-full max-h-[70%] rounded border border-neutral-800"
|
||||
/>
|
||||
<span className="mt-3 text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
@@ -46,7 +81,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
|
||||
<circle cx="6" cy="18" r="3" />
|
||||
<circle cx="18" cy="16" r="3" />
|
||||
</svg>
|
||||
<audio src={serveUrl} controls className="w-full max-w-[280px]" />
|
||||
<audio src={serveUrl} controls onError={setFailed} className="w-full max-w-[280px]" />
|
||||
<span className="text-[11px] text-neutral-500 font-mono">{filePath}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
interface SaveQueuePausedBannerProps {
|
||||
message: string;
|
||||
onDismiss: () => void;
|
||||
/** Resets the save-queue circuit breaker so persistence resumes. */
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
/** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
|
||||
export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
|
||||
export function SaveQueuePausedBanner({ message, onRetry }: SaveQueuePausedBannerProps) {
|
||||
return (
|
||||
<div
|
||||
className="absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
|
||||
className="hf-backdrop-in absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
|
||||
role="alert"
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
|
||||
onClick={onRetry}
|
||||
className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10 active:scale-[0.98]"
|
||||
>
|
||||
Dismiss
|
||||
Retry saving
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -30,39 +30,27 @@ export class StudioErrorBoundary extends Component<Props, State> {
|
||||
if (!this.state.error) return this.props.children;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#0a0a0a",
|
||||
color: "#e5e5e5",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>Something went wrong</div>
|
||||
<div style={{ fontSize: 13, color: "#888", maxWidth: 480, textAlign: "center" }}>
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center gap-4 bg-neutral-950 font-sans text-neutral-200">
|
||||
<div className="text-lg font-semibold">Something went wrong</div>
|
||||
<div className="max-w-[480px] text-center text-[13px] text-neutral-500">
|
||||
{this.state.error.message}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => this.setState({ error: null })}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
padding: "8px 20px",
|
||||
background: "#2563eb",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => this.setState({ error: null })}
|
||||
className="rounded-md bg-studio-accent px-5 py-2 text-sm font-medium text-neutral-950 transition-[filter] hover:brightness-110 active:scale-[0.98]"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
{/* If the error recurs immediately, "Try again" loops — a full reload
|
||||
is the recovery path that always works. */}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-md border border-neutral-700 px-5 py-2 text-sm font-medium text-neutral-300 transition-colors hover:bg-neutral-800 active:scale-[0.98]"
|
||||
>
|
||||
Reload Studio
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ function markPrompted(): void {
|
||||
// fallow-ignore-next-line complexity
|
||||
export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [rating, setRating] = useState<number | null>(null);
|
||||
const [comment, setComment] = useState("");
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
@@ -88,6 +89,14 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
|
||||
return () => clearTimeout(showTimer);
|
||||
}, []);
|
||||
|
||||
// Animate height in on entrance — appearing 3s after load, an instant 32px
|
||||
// bar shoves the whole preview stack up mid-task.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const raf = requestAnimationFrame(() => setEntered(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [visible]);
|
||||
|
||||
// Auto-dismiss timer — reset when user interacts (sets rating)
|
||||
useEffect(() => {
|
||||
if (!visible || rating !== null || submitted) return;
|
||||
@@ -141,8 +150,8 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"flex items-center gap-3 px-4 h-8 border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300",
|
||||
exiting ? "opacity-0 translate-y-2" : "opacity-100 translate-y-0",
|
||||
"flex items-center gap-3 px-4 overflow-hidden border-t border-neutral-800/50 bg-neutral-900/80 text-[11px] transition-all duration-300 motion-reduce:transition-none",
|
||||
entered && !exiting ? "h-8 opacity-100" : "h-0 opacity-0 border-t-transparent",
|
||||
].join(" ")}
|
||||
>
|
||||
{submitted ? (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function StudioGlobalDragOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
||||
<div className="hf-backdrop-in absolute inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-3 px-8 py-6 rounded-xl border-2 border-dashed border-studio-accent/60 bg-studio-accent/[0.06]">
|
||||
<svg
|
||||
width="32"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import { useRef, type MouseEvent } from "react";
|
||||
import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
|
||||
import {
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED,
|
||||
@@ -9,12 +9,14 @@ import { useStudioShellContext } from "../contexts/StudioContext";
|
||||
import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
|
||||
import { useViewMode, type StudioViewMode } from "../contexts/ViewModeContext";
|
||||
import { trackStudioEvent } from "../utils/studioTelemetry";
|
||||
import { Tooltip } from "./ui";
|
||||
|
||||
export interface StudioHeaderProps {
|
||||
captureFrameHref: string;
|
||||
captureFrameFilename: string;
|
||||
handleCaptureFrameClick: (event: MouseEvent<HTMLAnchorElement>) => void;
|
||||
refreshCaptureFrameTime: () => void;
|
||||
capturing?: boolean;
|
||||
inspectorButtonActive: boolean;
|
||||
inspectorPanelActive: boolean;
|
||||
onExport?: () => void;
|
||||
@@ -149,26 +151,45 @@ const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [
|
||||
/** Segmented control switching the main stage between storyboard and preview. */
|
||||
function ViewModeToggle() {
|
||||
const { viewMode, setViewMode } = useViewMode();
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
const selectMode = (mode: StudioViewMode) => {
|
||||
if (mode === viewMode) return;
|
||||
trackStudioEvent("view_mode_toggle", { mode });
|
||||
setViewMode(mode);
|
||||
};
|
||||
|
||||
// Complete APG tabs pattern: roving tabIndex + arrow-key navigation.
|
||||
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
const dir = e.key === "ArrowLeft" ? -1 : 1;
|
||||
const next = (index + dir + VIEW_MODE_OPTIONS.length) % VIEW_MODE_OPTIONS.length;
|
||||
tabRefs.current[next]?.focus();
|
||||
selectMode(VIEW_MODE_OPTIONS[next].mode);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-md bg-neutral-800 p-0.5"
|
||||
role="tablist"
|
||||
aria-label="Studio view"
|
||||
>
|
||||
{VIEW_MODE_OPTIONS.map(({ mode, label }) => {
|
||||
{VIEW_MODE_OPTIONS.map(({ mode, label }, index) => {
|
||||
const active = viewMode === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
ref={(el) => {
|
||||
tabRefs.current[index] = el;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
onClick={() => {
|
||||
if (active) return;
|
||||
trackStudioEvent("view_mode_toggle", { mode });
|
||||
setViewMode(mode);
|
||||
}}
|
||||
className={`rounded px-3 py-1 text-[11px] font-medium transition-colors ${
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => selectMode(mode)}
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
className={`rounded px-3 py-1 text-[11px] font-medium transition-colors active:scale-[0.98] outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
|
||||
active ? "bg-neutral-200 text-neutral-900" : "text-neutral-400 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
@@ -186,12 +207,14 @@ export function StudioHeader({
|
||||
captureFrameFilename,
|
||||
handleCaptureFrameClick,
|
||||
refreshCaptureFrameTime,
|
||||
capturing,
|
||||
inspectorButtonActive,
|
||||
inspectorPanelActive,
|
||||
onExport,
|
||||
}: StudioHeaderProps) {
|
||||
const { projectId, editHistory, handleUndo, handleRedo } = useStudioShellContext();
|
||||
const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
|
||||
const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
|
||||
const isRendering = renderQueue.isRendering;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 flex-shrink-0">
|
||||
@@ -207,118 +230,173 @@ export function StudioHeader({
|
||||
<ViewModeToggle />
|
||||
{/* Right: toolbar buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackStudioEvent("toolbar_action", { action: "undo" });
|
||||
void handleUndo();
|
||||
}}
|
||||
disabled={!editHistory.canUndo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
|
||||
editHistory.canUndo
|
||||
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
: "text-neutral-700 cursor-default"
|
||||
}`}
|
||||
title={
|
||||
<Tooltip
|
||||
label={
|
||||
editHistory.undoLabel
|
||||
? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
|
||||
: `Undo (${getHistoryShortcutLabel("undo")})`
|
||||
}
|
||||
aria-label="Undo"
|
||||
side="bottom"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackStudioEvent("toolbar_action", { action: "redo" });
|
||||
void handleRedo();
|
||||
}}
|
||||
disabled={!editHistory.canRedo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors ${
|
||||
editHistory.canRedo
|
||||
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
: "text-neutral-700 cursor-default"
|
||||
}`}
|
||||
title={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackStudioEvent("toolbar_action", { action: "undo" });
|
||||
void handleUndo();
|
||||
}}
|
||||
disabled={!editHistory.canUndo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
|
||||
editHistory.canUndo
|
||||
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
: "text-neutral-700 cursor-default"
|
||||
}`}
|
||||
aria-label="Undo"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
editHistory.redoLabel
|
||||
? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
|
||||
: `Redo (${getHistoryShortcutLabel("redo")})`
|
||||
}
|
||||
aria-label="Redo"
|
||||
side="bottom"
|
||||
>
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
<a
|
||||
href={captureFrameHref}
|
||||
download={captureFrameFilename}
|
||||
onClick={(e) => {
|
||||
trackStudioEvent("toolbar_action", { action: "capture_frame" });
|
||||
handleCaptureFrameClick(e);
|
||||
}}
|
||||
onFocus={refreshCaptureFrameTime}
|
||||
onPointerDown={refreshCaptureFrameTime}
|
||||
className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200 hover:bg-neutral-800"
|
||||
title="Capture current frame"
|
||||
aria-label="Capture current frame"
|
||||
>
|
||||
<Camera size={14} />
|
||||
<span>Capture</span>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
||||
if (rightCollapsed || !inspectorPanelActive) {
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
||||
setRightPanelTab("design");
|
||||
setRightCollapsed(false);
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
|
||||
// Keep the current selection when collapsing the Inspector — closing
|
||||
// the panel shouldn't deselect the element.
|
||||
setRightCollapsed(true);
|
||||
}}
|
||||
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
|
||||
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors ${
|
||||
inspectorButtonActive
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: STUDIO_INSPECTOR_PANELS_ENABLED
|
||||
? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
|
||||
: "cursor-not-allowed border-transparent text-neutral-700"
|
||||
}`}
|
||||
title={
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
||||
}
|
||||
aria-label={
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
trackStudioEvent("toolbar_action", { action: "redo" });
|
||||
void handleRedo();
|
||||
}}
|
||||
disabled={!editHistory.canRedo}
|
||||
className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
|
||||
editHistory.canRedo
|
||||
? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
|
||||
: "text-neutral-700 cursor-default"
|
||||
}`}
|
||||
aria-label="Redo"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
Inspector
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRightPanelTab("renders");
|
||||
setRightCollapsed(false);
|
||||
onExport?.();
|
||||
}}
|
||||
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors"
|
||||
<RotateCw size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={capturing ? "Capturing frame…" : "Capture current frame"} side="bottom">
|
||||
<a
|
||||
href={captureFrameHref}
|
||||
download={captureFrameFilename}
|
||||
onClick={(e) => {
|
||||
if (capturing) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("toolbar_action", { action: "capture_frame" });
|
||||
handleCaptureFrameClick(e);
|
||||
}}
|
||||
onFocus={refreshCaptureFrameTime}
|
||||
onPointerDown={refreshCaptureFrameTime}
|
||||
aria-disabled={capturing || undefined}
|
||||
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium transition-colors ${
|
||||
capturing
|
||||
? "text-neutral-600 cursor-default"
|
||||
: "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.98]"
|
||||
}`}
|
||||
aria-label={capturing ? "Capturing frame" : "Capture current frame"}
|
||||
>
|
||||
{capturing ? (
|
||||
<svg
|
||||
className="animate-spin motion-reduce:animate-none h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<Camera size={14} />
|
||||
)}
|
||||
<span>{capturing ? "Capturing…" : "Capture"}</span>
|
||||
</a>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
||||
}
|
||||
side="bottom"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!STUDIO_INSPECTOR_PANELS_ENABLED) return;
|
||||
if (rightCollapsed || !inspectorPanelActive) {
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
|
||||
setRightPanelTab("design");
|
||||
setRightCollapsed(false);
|
||||
return;
|
||||
}
|
||||
trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
|
||||
// Keep the current selection when collapsing the Inspector — closing
|
||||
// the panel shouldn't deselect the element.
|
||||
setRightCollapsed(true);
|
||||
}}
|
||||
disabled={!STUDIO_INSPECTOR_PANELS_ENABLED}
|
||||
aria-pressed={inspectorButtonActive}
|
||||
className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors active:scale-[0.98] ${
|
||||
inspectorButtonActive
|
||||
? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
|
||||
: STUDIO_INSPECTOR_PANELS_ENABLED
|
||||
? "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
|
||||
: "cursor-not-allowed border-transparent text-neutral-700"
|
||||
}`}
|
||||
aria-label={
|
||||
STUDIO_INSPECTOR_PANELS_ENABLED ? "Inspector" : STUDIO_MANUAL_EDITING_DISABLED_TITLE
|
||||
}
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
Inspector
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
isRendering ? "A render is already in progress" : "Render and export this composition"
|
||||
}
|
||||
side="bottom"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isRendering}
|
||||
onClick={() => {
|
||||
if (isRendering) return;
|
||||
setRightPanelTab("renders");
|
||||
setRightCollapsed(false);
|
||||
onExport?.();
|
||||
}}
|
||||
className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] enabled:hover:brightness-110 transition-[filter,transform] enabled:active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isRendering ? "Rendering…" : "Export"}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,7 @@ export function StudioLeftSidebar({
|
||||
const {
|
||||
leftCollapsed,
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
toggleLeftSidebar,
|
||||
handlePanelResizeStart,
|
||||
handlePanelResizeMove,
|
||||
@@ -114,14 +115,22 @@ export function StudioLeftSidebar({
|
||||
onRenameFile={handleRenameFile}
|
||||
onDuplicateFile={handleDuplicateFile}
|
||||
onMoveFile={handleMoveFile}
|
||||
onImportFiles={handleImportFiles}
|
||||
onImportFiles={async (files, dir) => {
|
||||
await handleImportFiles(files, dir);
|
||||
}}
|
||||
codeChildren={
|
||||
editingFile ? (
|
||||
isMediaFile(editingFile.path) ? (
|
||||
<MediaPreview projectId={projectId} filePath={editingFile.path} />
|
||||
) : editingFile.content == null ? (
|
||||
// Never mount the editor on unloaded content: a keystroke would
|
||||
// autosave an empty document over the real file.
|
||||
<div className="flex h-full items-center justify-center text-[11px] text-neutral-600">
|
||||
Loading {editingFile.path}…
|
||||
</div>
|
||||
) : (
|
||||
<SourceEditor
|
||||
content={editingFile.content ?? ""}
|
||||
content={editingFile.content}
|
||||
filePath={editingFile.path}
|
||||
onChange={handleContentChange}
|
||||
revealOffset={revealSourceOffset}
|
||||
@@ -140,11 +149,23 @@ export function StudioLeftSidebar({
|
||||
onPreviewBlock={onPreviewBlock}
|
||||
/>
|
||||
<div
|
||||
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
||||
role="separator"
|
||||
aria-label="Resize sidebar"
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
||||
onPointerMove={handlePanelResizeMove}
|
||||
onPointerUp={handlePanelResizeEnd}
|
||||
onPointerCancel={handlePanelResizeEnd}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
const delta = e.key === "ArrowLeft" ? -16 : 16;
|
||||
const maxLeft = Math.floor(window.innerWidth * 0.5);
|
||||
setLeftWidth(Math.max(160, Math.min(maxLeft, leftWidth + delta)));
|
||||
}}
|
||||
>
|
||||
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ type LintFindings = ComponentProps<typeof LintModal>["findings"];
|
||||
|
||||
export interface StudioOverlaysProps {
|
||||
projectId: string;
|
||||
projectDir?: string | null;
|
||||
lintModal: LintFindings | null;
|
||||
closeLintModal: () => void;
|
||||
consoleErrors: LintFindings | null;
|
||||
@@ -18,8 +19,8 @@ export interface StudioOverlaysProps {
|
||||
domEditSession: ReturnType<typeof useDomEditSession>;
|
||||
activeCompPath: string | null;
|
||||
dragOverlayActive: boolean;
|
||||
appToast: ReturnType<typeof useToast>["appToast"];
|
||||
dismissToast: () => void;
|
||||
toasts: ReturnType<typeof useToast>["toasts"];
|
||||
dismissToast: (id: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,7 @@ export interface StudioOverlaysProps {
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioOverlays({
|
||||
projectId,
|
||||
projectDir,
|
||||
lintModal,
|
||||
closeLintModal,
|
||||
consoleErrors,
|
||||
@@ -37,16 +39,30 @@ export function StudioOverlays({
|
||||
domEditSession,
|
||||
activeCompPath,
|
||||
dragOverlayActive,
|
||||
appToast,
|
||||
toasts,
|
||||
dismissToast,
|
||||
}: StudioOverlaysProps) {
|
||||
return (
|
||||
<>
|
||||
{lintModal !== null && (
|
||||
<LintModal findings={lintModal} projectId={projectId} onClose={closeLintModal} />
|
||||
<LintModal
|
||||
findings={lintModal}
|
||||
projectId={projectId}
|
||||
projectDir={projectDir}
|
||||
onClose={closeLintModal}
|
||||
/>
|
||||
)}
|
||||
{consoleErrors !== null && consoleErrors.length > 0 && (
|
||||
<LintModal findings={consoleErrors} projectId={projectId} onClose={clearConsoleErrors} />
|
||||
{/* One modal at a time — console errors wait behind an open lint modal
|
||||
instead of stacking two full-screen overlays. */}
|
||||
{lintModal === null && consoleErrors !== null && consoleErrors.length > 0 && (
|
||||
<LintModal
|
||||
findings={consoleErrors}
|
||||
projectId={projectId}
|
||||
projectDir={projectDir}
|
||||
title="Console errors in preview"
|
||||
promptIntro="Fix these runtime console errors from the composition preview"
|
||||
onClose={clearConsoleErrors}
|
||||
/>
|
||||
)}
|
||||
{domEditSession.agentModalOpen && domEditSession.domEditSelection && (
|
||||
<AskAgentModal
|
||||
@@ -62,8 +78,18 @@ export function StudioOverlays({
|
||||
/>
|
||||
)}
|
||||
{dragOverlayActive && <StudioGlobalDragOverlay />}
|
||||
{appToast && (
|
||||
<StudioToast message={appToast.message} tone={appToast.tone} onDismiss={dismissToast} />
|
||||
{toasts.length > 0 && (
|
||||
<div className="absolute bottom-6 right-6 z-[91] flex flex-col items-end gap-2">
|
||||
{toasts.map((toast) => (
|
||||
<StudioToast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
tone={toast.tone}
|
||||
leaving={toast.leaving}
|
||||
onDismiss={() => dismissToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ export function StudioRightPanel({
|
||||
}: StudioRightPanelProps) {
|
||||
const {
|
||||
rightWidth,
|
||||
setRightWidth,
|
||||
rightPanelTab,
|
||||
setRightPanelTab,
|
||||
rightInspectorPanes,
|
||||
@@ -400,6 +401,11 @@ export function StudioRightPanel({
|
||||
jobs={renderJobs}
|
||||
projectId={projectId}
|
||||
onDelete={renderQueue.deleteRender}
|
||||
onCancel={renderQueue.cancelRender}
|
||||
loadError={renderQueue.loadError}
|
||||
onRetryLoad={renderQueue.reloadRenders}
|
||||
actionError={renderQueue.actionError}
|
||||
onDismissActionError={renderQueue.dismissActionError}
|
||||
onClearCompleted={renderQueue.clearCompleted}
|
||||
onStartRender={async (format, quality, resolution, fps) => {
|
||||
await waitForPendingDomEditSaves();
|
||||
@@ -421,11 +427,23 @@ export function StudioRightPanel({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
||||
role="separator"
|
||||
aria-label="Resize inspector panel"
|
||||
aria-orientation="vertical"
|
||||
tabIndex={0}
|
||||
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
|
||||
style={{ touchAction: "none" }}
|
||||
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
||||
onPointerMove={handlePanelResizeMove}
|
||||
onPointerUp={handlePanelResizeEnd}
|
||||
onPointerCancel={handlePanelResizeEnd}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault();
|
||||
// Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
|
||||
const delta = e.key === "ArrowLeft" ? 16 : -16;
|
||||
setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
|
||||
}}
|
||||
>
|
||||
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
||||
</div>
|
||||
@@ -444,7 +462,8 @@ export function StudioRightPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInspectorPaneButtonClick("design")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
aria-pressed={designPaneOpen}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
designPaneOpen
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
@@ -457,7 +476,8 @@ export function StudioRightPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInspectorPaneButtonClick("layers")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
aria-pressed={layersPaneOpen}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
layersPaneOpen
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
@@ -472,7 +492,8 @@ export function StudioRightPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("renders")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
aria-pressed={rightPanelTab === "renders"}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
rightPanelTab === "renders"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
@@ -485,7 +506,8 @@ export function StudioRightPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("slideshow")}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
|
||||
aria-pressed={rightPanelTab === "slideshow"}
|
||||
className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
|
||||
rightPanelTab === "slideshow"
|
||||
? "bg-neutral-800 text-white"
|
||||
: "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
|
||||
@@ -537,6 +559,24 @@ export function StudioRightPanel({
|
||||
<LayersPanel />
|
||||
) : designPaneOpen ? (
|
||||
propertyPanel
|
||||
) : inspectorTabActive ? (
|
||||
// Inspector tab selected but no pane can render (panes toggled
|
||||
// off, or inspector inactive during playback/recording): show an
|
||||
// explanation instead of silently rendering the render queue
|
||||
// under a highlighted inspector tab.
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<p className="text-xs text-neutral-500">
|
||||
Inspector is unavailable right now — select the Design or Layers pane above, or
|
||||
pause playback/recording to inspect elements.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRightPanelTab("renders")}
|
||||
className="h-7 rounded-md border border-neutral-800 px-3 text-[11px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200 active:scale-[0.98]"
|
||||
>
|
||||
Show Renders
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
renderQueuePanel
|
||||
)}
|
||||
|
||||
@@ -2,15 +2,18 @@ export function StudioSplash({ waiting }: { waiting?: boolean }) {
|
||||
return (
|
||||
<div className="h-full w-full bg-neutral-950 flex items-center justify-center">
|
||||
{waiting ? (
|
||||
<div className="flex flex-col items-center gap-3 text-center px-6">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin" />
|
||||
<div className="flex flex-col items-center gap-3 text-center px-6" role="status">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin motion-reduce:animate-none" />
|
||||
<p className="text-xs text-neutral-600">
|
||||
Waiting for preview server… run{" "}
|
||||
<code className="text-neutral-500 font-mono">npm run dev</code>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
|
||||
<div className="flex flex-col items-center gap-3 text-center px-6" role="status">
|
||||
<div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse motion-reduce:animate-none" />
|
||||
<p className="text-xs text-neutral-600">Connecting to project…</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
interface StudioToastProps {
|
||||
message: string;
|
||||
tone?: "error" | "info";
|
||||
/** Plays the exit animation when true (owner removes the node after ~160ms). */
|
||||
leaving?: boolean;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
||||
export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastProps) {
|
||||
const isError = tone === "error";
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-6 right-6 z-[91] animate-in fade-in slide-in-from-bottom-2"
|
||||
onClick={onDismiss}
|
||||
role={onDismiss ? "button" : undefined}
|
||||
style={onDismiss ? { cursor: "pointer" } : undefined}
|
||||
role={isError ? "alert" : "status"}
|
||||
className={`motion-reduce:animate-none ${leaving ? "hf-toast-exit" : "hf-toast-enter"}`}
|
||||
>
|
||||
<div
|
||||
className="relative flex max-w-[min(420px,calc(100vw-48px))] items-center gap-3 overflow-hidden rounded-2xl py-3 pl-4 pr-2 text-[12px]"
|
||||
@@ -38,10 +37,7 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
onClick={onDismiss}
|
||||
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
|
||||
@@ -30,21 +30,27 @@ function renderToolbar() {
|
||||
describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
|
||||
it("renders enabled (pressed) by default with no selection", () => {
|
||||
const { host, root } = renderToolbar();
|
||||
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
|
||||
const btn = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Auto-record manual edits as keyframes"]',
|
||||
);
|
||||
expect(btn).not.toBeNull();
|
||||
expect(btn?.getAttribute("aria-pressed")).toBe("true");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
it("flips autoKeyframeEnabled in the store when clicked", () => {
|
||||
const { host, root } = renderToolbar();
|
||||
const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!;
|
||||
const btn = host.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Auto-record manual edits as keyframes"]',
|
||||
);
|
||||
if (!btn) throw new Error("auto-keyframe toggle not rendered");
|
||||
|
||||
act(() => {
|
||||
btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
|
||||
expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull();
|
||||
expect(btn.getAttribute("aria-pressed")).toBe("false");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +112,9 @@ export function TimelineToolbar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTool("select")}
|
||||
className={`flex h-6 w-6 items-center justify-center transition-colors ${
|
||||
aria-label="Selection tool"
|
||||
aria-pressed={activeTool === "select"}
|
||||
className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
|
||||
activeTool === "select"
|
||||
? "bg-neutral-700 text-neutral-200"
|
||||
: "text-neutral-500 hover:text-neutral-300"
|
||||
@@ -123,11 +125,13 @@ export function TimelineToolbar({
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Razor tool (B)">
|
||||
<Tooltip label="Razor tool (B) — Shift+click splits all tracks">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTool("razor")}
|
||||
className={`flex h-6 w-6 items-center justify-center transition-colors ${
|
||||
aria-label="Razor tool"
|
||||
aria-pressed={activeTool === "razor"}
|
||||
className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
|
||||
activeTool === "razor"
|
||||
? "bg-neutral-700 text-neutral-200"
|
||||
: "text-neutral-500 hover:text-neutral-300"
|
||||
@@ -153,7 +157,12 @@ export function TimelineToolbar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleKeyframe}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
aria-label={
|
||||
keyframeState === "active"
|
||||
? "Remove keyframe at playhead"
|
||||
: "Add keyframe at playhead"
|
||||
}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
|
||||
keyframeState === "active"
|
||||
? "text-studio-accent"
|
||||
: keyframeState === "inactive"
|
||||
@@ -187,8 +196,9 @@ export function TimelineToolbar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
|
||||
aria-label="Auto-record manual edits as keyframes"
|
||||
aria-pressed={autoKeyframeEnabled}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
|
||||
autoKeyframeEnabled
|
||||
? "text-red-400 hover:text-red-300"
|
||||
: "text-neutral-600 hover:text-neutral-400"
|
||||
@@ -213,23 +223,35 @@ export function TimelineToolbar({
|
||||
)}
|
||||
{onSplitElement &&
|
||||
(() => {
|
||||
// Render the button unconditionally (disabled when unusable):
|
||||
// mounting/unmounting mid-task shifts the neighboring controls.
|
||||
const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
|
||||
const el = selectedElementId
|
||||
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
|
||||
: null;
|
||||
if (!el || !canSplitElement(el)) return null;
|
||||
const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
|
||||
const splittable = el != null && canSplitElement(el);
|
||||
const canSplit =
|
||||
splittable && currentTime > el.start && currentTime < el.start + el.duration;
|
||||
return (
|
||||
<Tooltip label="Split clip at playhead (S)">
|
||||
<Tooltip
|
||||
label={
|
||||
canSplit
|
||||
? "Split clip at playhead (S)"
|
||||
: splittable
|
||||
? "Move the playhead inside the clip to split"
|
||||
: "Select a clip to split"
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canSplit}
|
||||
aria-label="Split clip at playhead"
|
||||
onClick={() => {
|
||||
if (canSplit) onSplitElement(el, currentTime);
|
||||
if (canSplit && el) onSplitElement(el, currentTime);
|
||||
}}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
canSplit
|
||||
? "text-neutral-500 hover:text-neutral-200"
|
||||
? "text-neutral-500 hover:text-neutral-200 active:scale-[0.98]"
|
||||
: "text-neutral-700 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
@@ -239,26 +261,38 @@ export function TimelineToolbar({
|
||||
);
|
||||
})()}
|
||||
{beatAnalysisReady &&
|
||||
canAddBeatAt(currentTime) &&
|
||||
(() => (
|
||||
<Tooltip label="Add beat at playhead">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addBeatAtCompositionTime(currentTime)}
|
||||
className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-[#22c55e]"
|
||||
(() => {
|
||||
const canAdd = canAddBeatAt(currentTime);
|
||||
return (
|
||||
<Tooltip
|
||||
label={canAdd ? "Add beat at playhead" : "A beat already exists at the playhead"}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))()}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canAdd}
|
||||
aria-label="Add beat at playhead"
|
||||
onClick={() => {
|
||||
if (canAdd) addBeatAtCompositionTime(currentTime);
|
||||
}}
|
||||
className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
|
||||
canAdd
|
||||
? "text-neutral-500 hover:text-[#22c55e] active:scale-[0.98]"
|
||||
: "text-neutral-700 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip label="Fit timeline to width">
|
||||
|
||||
@@ -16,6 +16,7 @@ export function PanelLayoutProvider({
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
rightWidth,
|
||||
setRightWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
@@ -39,6 +40,7 @@ export function PanelLayoutProvider({
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
rightWidth,
|
||||
setRightWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
@@ -56,6 +58,7 @@ export function PanelLayoutProvider({
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
rightWidth,
|
||||
setRightWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
/**
|
||||
* Loads a composition file's content for the source editor when a composition
|
||||
* is selected. Content stays null until the fetch resolves — the source editor
|
||||
* must not mount on a null-content file, or its autosave would overwrite the
|
||||
* real file with an empty document. Load failures surface as an error toast
|
||||
* instead of silently rendering an empty (and autosave-armed) editor.
|
||||
*/
|
||||
export function useCompositionContentLoader({
|
||||
projectId,
|
||||
setEditingFile,
|
||||
setActiveCompPath,
|
||||
showToast,
|
||||
}: {
|
||||
projectId: string | null;
|
||||
setEditingFile: (file: { path: string; content: string | null }) => void;
|
||||
setActiveCompPath: (path: string | null) => void;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
}) {
|
||||
return useCallback(
|
||||
(comp: string) => {
|
||||
setActiveCompPath(comp.endsWith(".html") ? comp : null);
|
||||
setEditingFile({ path: comp, content: null });
|
||||
fetch(`/api/projects/${projectId}/files/${comp}`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data: { content?: string }) => {
|
||||
if (typeof data.content !== "string") throw new Error(`No content returned for ${comp}`);
|
||||
setEditingFile({ path: comp, content: data.content });
|
||||
})
|
||||
.catch((err) => {
|
||||
showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error");
|
||||
});
|
||||
},
|
||||
[projectId, setEditingFile, setActiveCompPath, showToast],
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ interface UseEditorSaveOptions {
|
||||
recordEdit: (input: RecordEditInput) => Promise<void>;
|
||||
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
||||
setRefreshKey: React.Dispatch<React.SetStateAction<number>>;
|
||||
showToast: (message: string, tone?: "error" | "info") => void;
|
||||
}
|
||||
|
||||
export function useEditorSave({
|
||||
@@ -28,9 +29,13 @@ export function useEditorSave({
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
setRefreshKey,
|
||||
showToast,
|
||||
}: UseEditorSaveOptions) {
|
||||
const saveRafRef = useRef<number | null>(null);
|
||||
const refreshRafRef = useRef<number | null>(null);
|
||||
// One error toast per burst of failures — every keystroke retries the save,
|
||||
// and error toasts persist until dismissed, so don't stack duplicates.
|
||||
const lastFailureToastAtRef = useRef(0);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(content: string) => {
|
||||
@@ -61,6 +66,14 @@ export function useEditorSave({
|
||||
source: "code_editor",
|
||||
error_message: error instanceof Error ? error.message : "unknown",
|
||||
});
|
||||
const now = Date.now();
|
||||
if (now - lastFailureToastAtRef.current > 5000) {
|
||||
lastFailureToastAtRef.current = now;
|
||||
showToast(
|
||||
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
|
||||
"error",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -71,6 +84,7 @@ export function useEditorSave({
|
||||
readProjectFile,
|
||||
recordEdit,
|
||||
setRefreshKey,
|
||||
showToast,
|
||||
writeProjectFile,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -126,6 +126,7 @@ export function useFileManager({
|
||||
recordEdit,
|
||||
domEditSaveTimestampRef,
|
||||
setRefreshKey,
|
||||
showToast,
|
||||
});
|
||||
|
||||
// ── File select ──
|
||||
@@ -133,26 +134,34 @@ export function useFileManager({
|
||||
const revealRequestIdRef = useRef(0);
|
||||
const revealAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleFileSelect = useCallback((path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
revealAbortRef.current?.abort();
|
||||
revealAbortRef.current = null;
|
||||
revealRequestIdRef.current++;
|
||||
// Skip fetching binary content for media files — just set the path for preview
|
||||
if (isMediaFile(path)) {
|
||||
setEditingFile({ path, content: null });
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { content?: string }) => {
|
||||
if (data.content != null) {
|
||||
setEditingFile({ path, content: data.content });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const handleFileSelect = useCallback(
|
||||
(path: string) => {
|
||||
const pid = projectIdRef.current;
|
||||
if (!pid) return;
|
||||
revealAbortRef.current?.abort();
|
||||
revealAbortRef.current = null;
|
||||
revealRequestIdRef.current++;
|
||||
// Skip fetching binary content for media files — just set the path for preview
|
||||
if (isMediaFile(path)) {
|
||||
setEditingFile({ path, content: null });
|
||||
return;
|
||||
}
|
||||
fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`);
|
||||
return r.json();
|
||||
})
|
||||
.then((data: { content?: string }) => {
|
||||
if (data.content != null) {
|
||||
setEditingFile({ path, content: data.content });
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error");
|
||||
});
|
||||
},
|
||||
[showToast],
|
||||
);
|
||||
|
||||
// ── Click-to-source ──
|
||||
|
||||
@@ -253,9 +262,10 @@ export function useFileManager({
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Create file failed: ${err.error}`);
|
||||
showToast(`Couldn't create ${path}: ${err.error}`, "error");
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect],
|
||||
[refreshFileTree, handleFileSelect, showToast],
|
||||
);
|
||||
|
||||
const handleCreateFolder = useCallback(
|
||||
@@ -275,9 +285,10 @@ export function useFileManager({
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Create folder failed: ${err.error}`);
|
||||
showToast(`Couldn't create folder ${path}: ${err.error}`, "error");
|
||||
}
|
||||
},
|
||||
[refreshFileTree],
|
||||
[refreshFileTree, showToast],
|
||||
);
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
@@ -293,9 +304,10 @@ export function useFileManager({
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Delete failed: ${err.error}`);
|
||||
showToast(`Couldn't delete ${path}: ${err.error}`, "error");
|
||||
}
|
||||
},
|
||||
[refreshFileTree],
|
||||
[refreshFileTree, showToast],
|
||||
);
|
||||
|
||||
const handleRenameFile = useCallback(
|
||||
@@ -316,9 +328,10 @@ export function useFileManager({
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Rename failed: ${err.error}`);
|
||||
showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error");
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect, setRefreshKey],
|
||||
[refreshFileTree, handleFileSelect, setRefreshKey, showToast],
|
||||
);
|
||||
|
||||
const handleDuplicateFile = useCallback(
|
||||
@@ -337,9 +350,10 @@ export function useFileManager({
|
||||
} else {
|
||||
const err = await res.json().catch(() => ({ error: "unknown" }));
|
||||
console.error(`Duplicate failed: ${err.error}`);
|
||||
showToast(`Couldn't duplicate ${path}: ${err.error}`, "error");
|
||||
}
|
||||
},
|
||||
[refreshFileTree, handleFileSelect],
|
||||
[refreshFileTree, handleFileSelect, showToast],
|
||||
);
|
||||
|
||||
const handleMoveFile = handleRenameFile;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, type MouseEvent } from "react";
|
||||
import { useState, useCallback, useRef, type MouseEvent } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import { liveTime, usePlayerStore } from "../player";
|
||||
import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture";
|
||||
@@ -17,6 +17,8 @@ export function useFrameCapture({
|
||||
waitForPendingDomEditSaves,
|
||||
}: UseFrameCaptureParams) {
|
||||
const [captureFrameTime, setCaptureFrameTime] = useState(0);
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const capturingRef = useRef(false);
|
||||
|
||||
useMountEffect(() => {
|
||||
setCaptureFrameTime(usePlayerStore.getState().currentTime);
|
||||
@@ -31,6 +33,11 @@ export function useFrameCapture({
|
||||
async (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!projectId) return;
|
||||
event.preventDefault();
|
||||
// A capture can take up to ~35s (save drain + server render) — ignore
|
||||
// re-entrant clicks instead of firing parallel captures.
|
||||
if (capturingRef.current) return;
|
||||
capturingRef.current = true;
|
||||
setCapturing(true);
|
||||
try {
|
||||
const time = usePlayerStore.getState().currentTime;
|
||||
setCaptureFrameTime(time);
|
||||
@@ -79,6 +86,9 @@ export function useFrameCapture({
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : "Capture failed", "error");
|
||||
} finally {
|
||||
capturingRef.current = false;
|
||||
setCapturing(false);
|
||||
}
|
||||
},
|
||||
[activeCompPath, projectId, showToast, waitForPendingDomEditSaves],
|
||||
@@ -98,5 +108,6 @@ export function useFrameCapture({
|
||||
captureFrameFilename,
|
||||
handleCaptureFrameClick,
|
||||
refreshCaptureFrameTime,
|
||||
capturing,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) {
|
||||
leftWidth,
|
||||
setLeftWidth,
|
||||
rightWidth,
|
||||
setRightWidth,
|
||||
leftCollapsed,
|
||||
setLeftCollapsed,
|
||||
rightCollapsed,
|
||||
|
||||
@@ -2,24 +2,77 @@ import { useState, useCallback, useRef } from "react";
|
||||
import { useMountEffect } from "./useMountEffect";
|
||||
import type { AppToast } from "../utils/studioHelpers";
|
||||
|
||||
export function useToast() {
|
||||
const [appToast, setAppToast] = useState<AppToast | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
interface ToastItem extends AppToast {
|
||||
id: number;
|
||||
/** True while the exit animation plays, just before removal. */
|
||||
leaving?: boolean;
|
||||
}
|
||||
|
||||
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setAppToast({ message, tone });
|
||||
timerRef.current = setTimeout(() => setAppToast(null), 4000);
|
||||
const AUTO_DISMISS_MS = 4000;
|
||||
const EXIT_MS = 160;
|
||||
const MAX_TOASTS = 3;
|
||||
|
||||
let nextToastId = 1;
|
||||
|
||||
/**
|
||||
* Stacked toasts (max 3). Info toasts auto-dismiss after 4s; error toasts
|
||||
* persist until explicitly dismissed so failures can't silently vanish.
|
||||
*/
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
||||
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const clearTimer = useCallback((id: number) => {
|
||||
const timer = timersRef.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback(
|
||||
(id: number) => {
|
||||
clearTimer(id);
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
},
|
||||
[clearTimer],
|
||||
);
|
||||
|
||||
const dismissToast = useCallback(
|
||||
(id: number) => {
|
||||
clearTimer(id);
|
||||
// Mark leaving so the exit animation plays, then remove.
|
||||
setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
|
||||
const timer = setTimeout(() => removeToast(id), EXIT_MS);
|
||||
timersRef.current.set(id, timer);
|
||||
},
|
||||
[clearTimer, removeToast],
|
||||
);
|
||||
|
||||
const showToast = useCallback(
|
||||
(message: string, tone: AppToast["tone"] = "error") => {
|
||||
const id = nextToastId++;
|
||||
setToasts((prev) => {
|
||||
const next = [...prev, { id, message, tone }];
|
||||
// Cap the stack; drop the oldest (and its pending timer).
|
||||
while (next.length > MAX_TOASTS) {
|
||||
const dropped = next.shift();
|
||||
if (dropped) clearTimer(dropped.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (tone !== "error") {
|
||||
const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS);
|
||||
timersRef.current.set(id, timer);
|
||||
}
|
||||
},
|
||||
[clearTimer, dismissToast],
|
||||
);
|
||||
|
||||
useMountEffect(() => () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
for (const timer of timersRef.current.values()) clearTimeout(timer);
|
||||
timersRef.current.clear();
|
||||
});
|
||||
|
||||
const dismissToast = useCallback(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
setAppToast(null);
|
||||
}, []);
|
||||
|
||||
return { appToast, showToast, dismissToast };
|
||||
return { toasts, showToast, dismissToast };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user