From 467b54c40234cab26567e67179c6f803d2653b6f Mon Sep 17 00:00:00 2001 From: ruinivist <179396038+ruinivist@users.noreply.github.com> Date: Sat, 30 May 2026 17:56:30 +0000 Subject: [PATCH] feat: implement manual save via Ctrl+S with toast and increase autosave interval - Increased autosave interval to 30s. - Captured Ctrl+S / Meta+S to trigger manual save. - Added a minimalistic, translucent toast in the top right to display saving status. --- src/App.tsx | 58 ++++++++++++++++++++++++++++++++++++++++++++++++-- src/styles.css | 16 ++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0e8c1cf..e65ca65 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -96,6 +96,7 @@ export function App() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const [toastMessage, setToastMessage] = useState(null); const currentIdRef = useRef(activeId); const currentTitleRef = useRef(activeTitle); @@ -106,6 +107,7 @@ export function App() { const inFlightSaveRef = useRef | null>(null); const loadVersionRef = useRef(0); const themeSyncFrameRef = useRef(null); + const toastTimeoutRef = useRef(null); const activeDrawing = useMemo( () => drawings.find((drawing) => drawing.id === activeId) ?? null, @@ -118,7 +120,7 @@ export function App() { return list; }, []); - const saveScene = useCallback(async () => { + const saveScene = useCallback(async (isManual = false) => { const drawingId = currentIdRef.current; const nextScene = latestSceneRef.current; if (!drawingId || !nextScene) { @@ -126,15 +128,37 @@ export function App() { } setError(null); + if (isManual) { + setToastMessage("Saving..."); + if (toastTimeoutRef.current !== null) { + clearTimeout(toastTimeoutRef.current); + toastTimeoutRef.current = null; + } + } + const promise = requestJson<{ ok: true; drawing: DrawingMeta }>(`/api/drawings/${drawingId}`, { method: "PUT", body: JSON.stringify(nextScene), }) .then((body) => { setDrawings((current) => replaceMeta(current, body.drawing)); + if (isManual) { + setToastMessage("Saved."); + if (toastTimeoutRef.current !== null) { + clearTimeout(toastTimeoutRef.current); + } + toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000); + } }) .catch((saveError: unknown) => { setError(saveError instanceof Error ? saveError.message : "Save failed"); + if (isManual) { + setToastMessage("Save failed."); + if (toastTimeoutRef.current !== null) { + clearTimeout(toastTimeoutRef.current); + } + toastTimeoutRef.current = window.setTimeout(() => setToastMessage(null), 2000); + } throw saveError; }) .finally(() => { @@ -158,6 +182,17 @@ export function App() { } }, [saveScene]); + const triggerManualSave = useCallback(async () => { + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } + if (inFlightSaveRef.current) { + await inFlightSaveRef.current; + } + await saveScene(true); + }, [saveScene]); + const scheduleSave = useCallback(() => { if (timeoutRef.current !== null) { clearTimeout(timeoutRef.current); @@ -166,7 +201,7 @@ export function App() { timeoutRef.current = window.setTimeout(() => { timeoutRef.current = null; void saveScene(); - }, 1500); + }, 30000); }, [saveScene]); const loadDrawing = useCallback( @@ -329,6 +364,20 @@ export function App() { }); }, [syncThemeTokens]); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") { + e.preventDefault(); + e.stopPropagation(); + void triggerManualSave(); + } + }; + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [triggerManualSave]); + useEffect(() => { const currentPathId = pathToId(); if (currentPathId) { @@ -410,6 +459,11 @@ export function App() { return (
+ {toastMessage && ( +
+ {toastMessage} +
+ )}