From a7b7327ce595627b59f30497221d1401a8eb196a Mon Sep 17 00:00:00 2001 From: Nicola Bottini Date: Thu, 16 Jul 2026 12:22:18 -0400 Subject: [PATCH] feat: autosave canvas after configurable inactivity delay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in autosave feature (disabled by default) that silently saves the canvas after a configurable period of inactivity. - frontend/src/utils/autosaveSettings.ts (new) — localStorage-backed setting {enabled, delay} with pub/sub CustomEvent pattern (mirrors alignmentSettings.ts). Key: homelable.autosave. - frontend/src/App.tsx: * handleSave accepts optional { silent?: boolean } — skips the success toast when called from autosave to avoid noise on every periodic save * autosave state (useState + subscribeAutosaveSettings) * debounce useEffect: resets on nodes/edges change; when quiet for autosave.delay seconds and hasUnsavedChanges is true, calls handleSaveRef.current silently - frontend/src/components/modals/SettingsModal.tsx: * Canvas section: Autosave canvas toggle + Save after delay selector (3/5/10/30/60 s). Changes persist immediately to localStorage via writeAutosaveSettings + propagate cross-tab via CustomEvent. Default: disabled — no behaviour change for existing users. Manual Ctrl+S and the Save button continue to work normally. Errors always show a toast regardless of the silent flag. --- frontend/src/App.tsx | 20 ++++++-- .../src/components/modals/SettingsModal.tsx | 48 +++++++++++++++++++ frontend/src/utils/autosaveSettings.ts | 43 +++++++++++++++++ 3 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 frontend/src/utils/autosaveSettings.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d46f8e4..f28626e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -37,6 +37,7 @@ import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal' import { ShortcutsModal } from '@/components/modals/ShortcutsModal' import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal' import { useCanvasStore } from '@/stores/canvasStore' +import { readAutosaveSettings, subscribeAutosaveSettings, type AutosaveSettings } from '@/utils/autosaveSettings' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' @@ -53,7 +54,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, hasUnsavedChanges, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -61,6 +62,9 @@ export default function App() { useStatusPolling() + const [autosave, setAutosave] = useState(readAutosaveSettings) + useEffect(() => subscribeAutosaveSettings(setAutosave), []) + const [themeModalOpen, setThemeModalOpen] = useState(false) const [styleEditorType, setStyleEditorType] = useState(null) const [searchOpen, setSearchOpen] = useState(false) @@ -93,7 +97,7 @@ export default function App() { // Declare handleSave before the Ctrl+S effect so it is in scope. // Returns true on success, false on failure — the design-switch effect relies // on this to avoid loading (and clobbering) the canvas when a save fails. - const handleSave = useCallback(async (designIdOverride?: string): Promise => { + const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise => { try { const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { @@ -101,7 +105,7 @@ export default function App() { // Floor plans are backend-only (upload/serve), so standalone never persists one. standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle }) markSaved() - toast.success('Canvas saved') + if (!options?.silent) toast.success('Canvas saved') return true } const nodesToSave = nodes.map(serializeNode) @@ -110,7 +114,7 @@ export default function App() { if (floorMap) viewport.floor_map = floorMap await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId }) markSaved() - toast.success('Canvas saved') + if (!options?.silent) toast.success('Canvas saved') return true } catch { toast.error('Save failed') @@ -122,6 +126,14 @@ export default function App() { const handleSaveRef = useRef(handleSave) useEffect(() => { handleSaveRef.current = handleSave }, [handleSave]) + // Autosave: debounce — resets on every node/edge change, fires after `delay` seconds + // of inactivity if there are unsaved changes and autosave is enabled. + useEffect(() => { + if (!autosave.enabled || !hasUnsavedChanges) return + const t = setTimeout(() => { void handleSaveRef.current(undefined, { silent: true }) }, autosave.delay * 1000) + return () => clearTimeout(t) + }, [nodes, edges, autosave.enabled, autosave.delay, hasUnsavedChanges]) + const loadCanvasFromApi = useCallback(async (designId?: string) => { try { const res = await canvasApi.load(designId) diff --git a/frontend/src/components/modals/SettingsModal.tsx b/frontend/src/components/modals/SettingsModal.tsx index fa16651..58f98a5 100644 --- a/frontend/src/components/modals/SettingsModal.tsx +++ b/frontend/src/components/modals/SettingsModal.tsx @@ -18,6 +18,12 @@ import { writeAlignmentSettings, subscribeAlignmentSettings, } from '@/utils/alignmentSettings' +import { + type AutosaveSettings, + readAutosaveSettings, + writeAutosaveSettings, + subscribeAutosaveSettings, +} from '@/utils/autosaveSettings' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' @@ -124,6 +130,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { const [zwInterval, setZwInterval] = useState(3600) const [zwSyncing, setZwSyncing] = useState(false) const [alignment, setAlignment] = useState(readAlignmentSettings) + const [autosave, setAutosave] = useState(readAutosaveSettings) const hideIp = useCanvasStore((s) => s.hideIp) const setHideIp = useCanvasStore((s) => s.setHideIp) @@ -160,6 +167,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { }, [open]) useEffect(() => subscribeAlignmentSettings(setAlignment), []) + useEffect(() => subscribeAutosaveSettings(setAutosave), []) const updateAlignment = (patch: Partial) => { const next = { ...alignment, ...patch } @@ -167,6 +175,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { writeAlignmentSettings(next) } + const updateAutosave = (patch: Partial) => { + const next = { ...autosave, ...patch } + setAutosave(next) + writeAutosaveSettings(next) + } + const handleSyncNow = async () => { setPmSyncing(true) try { @@ -353,6 +367,40 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) { Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.

+ + + +
+ +
+ + of inactivity +
+

+ Saves silently after this many seconds with no changes. Manual Ctrl+S still works. +

+
diff --git a/frontend/src/utils/autosaveSettings.ts b/frontend/src/utils/autosaveSettings.ts new file mode 100644 index 0000000..3b54941 --- /dev/null +++ b/frontend/src/utils/autosaveSettings.ts @@ -0,0 +1,43 @@ +// Persisted client-side autosave preference. +// Kept in localStorage (per-user UI preference, not canvas data). +// Same-tab updates propagate via a CustomEvent so App.tsx and SettingsModal +// can stay in sync without a global store. + +export interface AutosaveSettings { + enabled: boolean + delay: number // seconds of inactivity before auto-saving +} + +export const DEFAULT_AUTOSAVE_SETTINGS: AutosaveSettings = { enabled: false, delay: 5 } + +const KEY = 'homelable.autosave' +const EVENT = 'homelable:autosave-settings-changed' + +export function readAutosaveSettings(): AutosaveSettings { + try { + const raw = localStorage.getItem(KEY) + if (!raw) return DEFAULT_AUTOSAVE_SETTINGS + const parsed = JSON.parse(raw) as Partial + return { + enabled: parsed.enabled ?? DEFAULT_AUTOSAVE_SETTINGS.enabled, + delay: typeof parsed.delay === 'number' && parsed.delay > 0 ? parsed.delay : DEFAULT_AUTOSAVE_SETTINGS.delay, + } + } catch { + return DEFAULT_AUTOSAVE_SETTINGS + } +} + +export function writeAutosaveSettings(s: AutosaveSettings): void { + try { + localStorage.setItem(KEY, JSON.stringify(s)) + window.dispatchEvent(new CustomEvent(EVENT, { detail: s })) + } catch { + /* quota / SSR */ + } +} + +export function subscribeAutosaveSettings(listener: (s: AutosaveSettings) => void): () => void { + const handler = (e: Event) => listener((e as CustomEvent).detail) + window.addEventListener(EVENT, handler) + return () => window.removeEventListener(EVENT, handler) +}