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)
+}