mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
fix(studio): shell UX — data-loss guards, error surfacing, dialog contracts, toasts (#1964)
This commit is contained in:
@@ -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