feat: autosave canvas after configurable inactivity delay
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.
This commit is contained in:
+16
-4
@@ -37,6 +37,7 @@ import { ScanHistoryModal } from '@/components/modals/ScanHistoryModal'
|
|||||||
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
import { ShortcutsModal } from '@/components/modals/ShortcutsModal'
|
||||||
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
import { ConfirmAddToGroupModal } from '@/components/modals/ConfirmAddToGroupModal'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
import { readAutosaveSettings, subscribeAutosaveSettings, type AutosaveSettings } from '@/utils/autosaveSettings'
|
||||||
import { useDesignStore } from '@/stores/designStore'
|
import { useDesignStore } from '@/stores/designStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
@@ -53,7 +54,7 @@ import { buildProxmoxClusterEdges } from '@/components/proxmox/clusterEdges'
|
|||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
export default function App() {
|
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<HTMLDivElement>(null)
|
const canvasRef = useRef<HTMLDivElement>(null)
|
||||||
const { isAuthenticated } = useAuthStore()
|
const { isAuthenticated } = useAuthStore()
|
||||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||||
@@ -61,6 +62,9 @@ export default function App() {
|
|||||||
|
|
||||||
useStatusPolling()
|
useStatusPolling()
|
||||||
|
|
||||||
|
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||||
|
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||||
|
|
||||||
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
const [themeModalOpen, setThemeModalOpen] = useState(false)
|
||||||
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
const [styleEditorType, setStyleEditorType] = useState<NodeType | null>(null)
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
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.
|
// Declare handleSave before the Ctrl+S effect so it is in scope.
|
||||||
// Returns true on success, false on failure — the design-switch effect relies
|
// 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.
|
// on this to avoid loading (and clobbering) the canvas when a save fails.
|
||||||
const handleSave = useCallback(async (designIdOverride?: string): Promise<boolean> => {
|
const handleSave = useCallback(async (designIdOverride?: string, options?: { silent?: boolean }): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
const saveDesignId = designIdOverride ?? activeDesignId
|
const saveDesignId = designIdOverride ?? activeDesignId
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
@@ -101,7 +105,7 @@ export default function App() {
|
|||||||
// Floor plans are backend-only (upload/serve), so standalone never persists one.
|
// Floor plans are backend-only (upload/serve), so standalone never persists one.
|
||||||
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
if (!options?.silent) toast.success('Canvas saved')
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const nodesToSave = nodes.map(serializeNode)
|
const nodesToSave = nodes.map(serializeNode)
|
||||||
@@ -110,7 +114,7 @@ export default function App() {
|
|||||||
if (floorMap) viewport.floor_map = floorMap
|
if (floorMap) viewport.floor_map = floorMap
|
||||||
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
|
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
|
||||||
markSaved()
|
markSaved()
|
||||||
toast.success('Canvas saved')
|
if (!options?.silent) toast.success('Canvas saved')
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Save failed')
|
toast.error('Save failed')
|
||||||
@@ -122,6 +126,14 @@ export default function App() {
|
|||||||
const handleSaveRef = useRef(handleSave)
|
const handleSaveRef = useRef(handleSave)
|
||||||
useEffect(() => { handleSaveRef.current = handleSave }, [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) => {
|
const loadCanvasFromApi = useCallback(async (designId?: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await canvasApi.load(designId)
|
const res = await canvasApi.load(designId)
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import {
|
|||||||
writeAlignmentSettings,
|
writeAlignmentSettings,
|
||||||
subscribeAlignmentSettings,
|
subscribeAlignmentSettings,
|
||||||
} from '@/utils/alignmentSettings'
|
} from '@/utils/alignmentSettings'
|
||||||
|
import {
|
||||||
|
type AutosaveSettings,
|
||||||
|
readAutosaveSettings,
|
||||||
|
writeAutosaveSettings,
|
||||||
|
subscribeAutosaveSettings,
|
||||||
|
} from '@/utils/autosaveSettings'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
@@ -124,6 +130,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
const [zwInterval, setZwInterval] = useState(3600)
|
const [zwInterval, setZwInterval] = useState(3600)
|
||||||
const [zwSyncing, setZwSyncing] = useState(false)
|
const [zwSyncing, setZwSyncing] = useState(false)
|
||||||
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
const [alignment, setAlignment] = useState<AlignmentSettings>(readAlignmentSettings)
|
||||||
|
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
|
||||||
const hideIp = useCanvasStore((s) => s.hideIp)
|
const hideIp = useCanvasStore((s) => s.hideIp)
|
||||||
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
const setHideIp = useCanvasStore((s) => s.setHideIp)
|
||||||
|
|
||||||
@@ -160,6 +167,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
useEffect(() => subscribeAlignmentSettings(setAlignment), [])
|
||||||
|
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
|
||||||
|
|
||||||
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
const updateAlignment = (patch: Partial<AlignmentSettings>) => {
|
||||||
const next = { ...alignment, ...patch }
|
const next = { ...alignment, ...patch }
|
||||||
@@ -167,6 +175,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps) {
|
|||||||
writeAlignmentSettings(next)
|
writeAlignmentSettings(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateAutosave = (patch: Partial<AutosaveSettings>) => {
|
||||||
|
const next = { ...autosave, ...patch }
|
||||||
|
setAutosave(next)
|
||||||
|
writeAutosaveSettings(next)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSyncNow = async () => {
|
const handleSyncNow = async () => {
|
||||||
setPmSyncing(true)
|
setPmSyncing(true)
|
||||||
try {
|
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.
|
Distance at which dragged nodes snap to neighbours. Hold Alt while dragging to disable.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center justify-between gap-2 cursor-pointer">
|
||||||
|
<span className="text-xs text-foreground">Autosave canvas</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autosave.enabled}
|
||||||
|
onChange={(e) => updateAutosave({ enabled: e.target.checked })}
|
||||||
|
className="cursor-pointer accent-[#00d4ff]"
|
||||||
|
aria-label="Toggle autosave"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className={autosave.enabled ? 'space-y-1.5' : 'space-y-1.5 opacity-50 pointer-events-none'}>
|
||||||
|
<label className="text-xs text-muted-foreground">Save after</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={autosave.delay}
|
||||||
|
onChange={(e) => updateAutosave({ delay: Number(e.target.value) })}
|
||||||
|
disabled={!autosave.enabled}
|
||||||
|
className="px-2 py-1 rounded-md text-xs bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
|
||||||
|
aria-label="Autosave delay"
|
||||||
|
>
|
||||||
|
<option value={3}>3 s</option>
|
||||||
|
<option value={5}>5 s</option>
|
||||||
|
<option value={10}>10 s</option>
|
||||||
|
<option value={30}>30 s</option>
|
||||||
|
<option value={60}>60 s</option>
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-muted-foreground">of inactivity</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-muted-foreground leading-tight">
|
||||||
|
Saves silently after this many seconds with no changes. Manual Ctrl+S still works.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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<AutosaveSettings>
|
||||||
|
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<AutosaveSettings>(EVENT, { detail: s }))
|
||||||
|
} catch {
|
||||||
|
/* quota / SSR */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribeAutosaveSettings(listener: (s: AutosaveSettings) => void): () => void {
|
||||||
|
const handler = (e: Event) => listener((e as CustomEvent<AutosaveSettings>).detail)
|
||||||
|
window.addEventListener(EVENT, handler)
|
||||||
|
return () => window.removeEventListener(EVENT, handler)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user