feat: support multi-canvas (designs) in frontend-only standalone mode

Standalone mode (VITE_STANDALONE=true) skipped the designs system entirely:
the design list stayed empty (switcher hidden) and all canvases collapsed onto
a single localStorage key. Add a localStorage-backed design layer mirroring the
backend designs API.

- standaloneStorage util: list/create/update/delete designs + per-design canvas
  storage (homelable_designs + homelable_canvas:<id>). ensureSeed migrates a
  legacy single-canvas install into a default design so existing data survives.
- App.tsx: load/seed designs, save + switch canvases per design id in standalone.
- Sidebar.tsx: create/update/delete dispatch to standaloneStorage when standalone.
- Tests for the new storage util.

ha-relevant: no
This commit is contained in:
Pouzor
2026-06-29 23:13:56 +02:00
parent c7b4db206b
commit ca171089c2
4 changed files with 271 additions and 28 deletions
+43 -23
View File
@@ -38,6 +38,7 @@ 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'
import { canvasApi, designsApi, liveviewApi } from '@/api/client' import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { demoNodes, demoEdges } from '@/utils/demoData' import { demoNodes, demoEdges } from '@/utils/demoData'
import { useStatusPolling } from '@/hooks/useStatusPolling' import { useStatusPolling } from '@/hooks/useStatusPolling'
import type { NodeData, EdgeData, CustomStyleDef } from '@/types' import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
@@ -45,7 +46,6 @@ import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types' import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
const STANDALONE_STORAGE_KEY = 'homelable_canvas'
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 } = useCanvasStore() 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 } = useCanvasStore()
@@ -90,7 +90,8 @@ export default function App() {
try { try {
const saveDesignId = designIdOverride ?? activeDesignId const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) { if (STANDALONE) {
localStorage.setItem(STANDALONE_STORAGE_KEY, JSON.stringify({ nodes, edges, theme_id: activeTheme, custom_style: customStyle })) if (!saveDesignId) return false
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return true return true
@@ -135,8 +136,30 @@ export default function App() {
} }
}, [loadCanvas, setTheme, setCustomStyle]) }, [loadCanvas, setTheme, setCustomStyle])
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
// localStorage, falling back to the demo canvas when it has never been saved.
const loadStandaloneCanvas = useCallback((designId: string) => {
const saved = standaloneStorage.loadCanvas(designId)
if (saved && saved.nodes.length > 0) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
loadCanvas(saved.nodes, saved.edges)
} else {
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle])
const loadDesignsAndCanvas = useCallback(async () => { const loadDesignsAndCanvas = useCallback(async () => {
if (STANDALONE) return if (STANDALONE) {
const designs = standaloneStorage.ensureSeed()
setDesigns(designs)
const targetId = activeDesignId ?? designs[0]?.id
if (targetId) {
setActiveDesign(targetId)
loadStandaloneCanvas(targetId)
}
return
}
try { try {
const res = await designsApi.list() const res = await designsApi.list()
const loadedDesigns = res.data const loadedDesigns = res.data
@@ -150,29 +173,23 @@ export default function App() {
// If API fails (e.g. fresh DB with no designs), fall back to demo data // If API fails (e.g. fresh DB with no designs), fall back to demo data
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
}, [setDesigns, setActiveDesign, loadCanvasFromApi, activeDesignId, loadCanvas]) }, [setDesigns, setActiveDesign, loadCanvasFromApi, loadStandaloneCanvas, activeDesignId, loadCanvas])
// Load canvas on auth (or immediately in standalone mode) // Keep a ref so the auth effect can call the latest loader without listing it
// as a dependency (which would re-fire on every design switch).
const loadDesignsAndCanvasRef = useRef(loadDesignsAndCanvas)
useEffect(() => { loadDesignsAndCanvasRef.current = loadDesignsAndCanvas }, [loadDesignsAndCanvas])
// Load designs + canvas on auth (or immediately in standalone mode, which has
// no auth gate).
useEffect(() => { useEffect(() => {
if (STANDALONE) { if (STANDALONE) {
try { loadDesignsAndCanvasRef.current()
const saved = localStorage.getItem(STANDALONE_STORAGE_KEY)
if (saved) {
const { nodes: savedNodes, edges: savedEdges, theme_id, custom_style } = JSON.parse(saved)
if (theme_id) setTheme(theme_id)
if (custom_style) setCustomStyle(custom_style)
loadCanvas(savedNodes, savedEdges)
} else {
loadCanvas(demoNodes, demoEdges)
}
} catch {
loadCanvas(demoNodes, demoEdges)
}
return return
} }
if (!isAuthenticated) return if (!isAuthenticated) return
loadDesignsAndCanvas() loadDesignsAndCanvasRef.current()
}, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change }, [isAuthenticated]) // only on auth change, not design change
// Reload canvas when active design changes (after initial load) // Reload canvas when active design changes (after initial load)
const initialLoadDone = useRef(false) const initialLoadDone = useRef(false)
@@ -186,7 +203,10 @@ export default function App() {
prevDesignRef.current = activeDesignId prevDesignRef.current = activeDesignId
return return
} }
if (!STANDALONE && isAuthenticated && activeDesignId && initialLoadDone.current) { // Standalone has no auth gate; backed mode requires authentication.
const ready = STANDALONE || isAuthenticated
const loadForDesign = STANDALONE ? loadStandaloneCanvas : loadCanvasFromApi
if (ready && activeDesignId && initialLoadDone.current) {
const oldId = prevDesignRef.current const oldId = prevDesignRef.current
// If the previous design was deleted (no longer in the list), don't try to // If the previous design was deleted (no longer in the list), don't try to
// save into it — just load the newly-selected design. // save into it — just load the newly-selected design.
@@ -199,7 +219,7 @@ export default function App() {
const targetId = activeDesignId const targetId = activeDesignId
handleSave(oldId).then((ok) => { handleSave(oldId).then((ok) => {
if (ok) { if (ok) {
loadCanvasFromApi(targetId) loadForDesign(targetId)
} else { } else {
// Save failed: don't load the new design — that would overwrite the // Save failed: don't load the new design — that would overwrite the
// unsaved in-memory canvas. Revert the selection back to the old // unsaved in-memory canvas. Revert the selection back to the old
@@ -210,7 +230,7 @@ export default function App() {
} }
}) })
} else { } else {
loadCanvasFromApi(activeDesignId) loadForDesign(activeDesignId)
} }
} }
if (activeDesignId) { if (activeDesignId) {
+14 -5
View File
@@ -6,6 +6,7 @@ import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore' import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { designsApi } from '@/api/client' import { designsApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
import type { Design } from '@/types' import type { Design } from '@/types'
@@ -43,11 +44,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
if (!designModal) return if (!designModal) return
try { try {
if (designModal.mode === 'create') { if (designModal.mode === 'create') {
const res = await designsApi.create({ name: data.name, icon: data.icon }) const created = STANDALONE
addDesign(res.data) ? standaloneStorage.createDesign(data.name, data.icon)
: (await designsApi.create({ name: data.name, icon: data.icon })).data
addDesign(created)
} else if (designModal.design) { } else if (designModal.design) {
const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon }) const updated = STANDALONE
updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon }) ? standaloneStorage.updateDesign(designModal.design.id, { name: data.name, icon: data.icon })
: (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data
if (updated) updateDesign(updated.id, { name: updated.name, icon: updated.icon })
} }
setDesignModal(null) setDesignModal(null)
} catch { } catch {
@@ -59,7 +64,11 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return if (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return
try { try {
await designsApi.delete(d.id) if (STANDALONE) {
standaloneStorage.deleteDesign(d.id)
} else {
await designsApi.delete(d.id)
}
removeDesign(d.id) removeDesign(d.id)
toast.success('Canvas deleted') toast.success('Canvas deleted')
} catch { } catch {
@@ -0,0 +1,102 @@
/**
* Standalone multi-canvas (designs) persistence tests.
*
* Verifies the localStorage-backed design list + per-design canvas storage used
* when VITE_STANDALONE=true, including migration of a legacy single-canvas
* install into a default design.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import type { Node, Edge } from '@xyflow/react'
import type { NodeData, EdgeData } from '@/types'
import * as ss from '@/utils/standaloneStorage'
const DESIGNS_KEY = 'homelable_designs'
const LEGACY_CANVAS_KEY = 'homelable_canvas'
const canvasKey = (id: string) => `${LEGACY_CANVAS_KEY}:${id}`
function node(id: string): Node<NodeData> {
return { id, type: 'server', position: { x: 0, y: 0 }, data: { label: id, type: 'server', status: 'unknown', services: [] } }
}
const noEdges: Edge<EdgeData>[] = []
beforeEach(() => {
localStorage.clear()
})
describe('standaloneStorage designs', () => {
it('listDesigns returns empty before any seed', () => {
expect(ss.listDesigns()).toEqual([])
})
it('ensureSeed creates a default design once and is idempotent', () => {
const first = ss.ensureSeed()
expect(first).toHaveLength(1)
expect(first[0].name).toBe('My Homelab')
expect(first[0].design_type).toBe('network')
const second = ss.ensureSeed()
expect(second).toHaveLength(1)
expect(second[0].id).toBe(first[0].id) // same design, not recreated
})
it('ensureSeed migrates legacy single-canvas data into the default design', () => {
const legacy = { nodes: [node('a'), node('b')], edges: noEdges, theme_id: 'dark', custom_style: null }
localStorage.setItem(LEGACY_CANVAS_KEY, JSON.stringify(legacy))
const [design] = ss.ensureSeed()
const migrated = ss.loadCanvas(design.id)
expect(migrated?.nodes).toHaveLength(2)
expect(migrated?.theme_id).toBe('dark')
// Legacy bare key is consumed so it can't shadow per-design data later.
expect(localStorage.getItem(LEGACY_CANVAS_KEY)).toBeNull()
})
it('createDesign appends and persists a new design', () => {
ss.ensureSeed()
const created = ss.createDesign('Garage', 'network')
expect(ss.listDesigns().map((d) => d.id)).toContain(created.id)
expect(ss.listDesigns()).toHaveLength(2)
})
it('saveCanvas / loadCanvas round-trips per design without cross-talk', () => {
const a = ss.createDesign('A')
const b = ss.createDesign('B')
ss.saveCanvas(a.id, { nodes: [node('a1')], edges: noEdges, theme_id: 'default' })
ss.saveCanvas(b.id, { nodes: [node('b1'), node('b2')], edges: noEdges, theme_id: 'default' })
expect(ss.loadCanvas(a.id)?.nodes).toHaveLength(1)
expect(ss.loadCanvas(b.id)?.nodes).toHaveLength(2)
})
it('loadCanvas returns null for an unsaved design', () => {
const d = ss.createDesign('Empty')
expect(ss.loadCanvas(d.id)).toBeNull()
})
it('updateDesign patches name/icon and bumps updated_at', () => {
const d = ss.createDesign('Old')
const updated = ss.updateDesign(d.id, { name: 'New', icon: 'router' })
expect(updated?.name).toBe('New')
expect(updated?.icon).toBe('router')
expect(ss.listDesigns()[0].name).toBe('New')
})
it('updateDesign returns null for an unknown id', () => {
expect(ss.updateDesign('nope', { name: 'x' })).toBeNull()
})
it('deleteDesign removes the design and its canvas data', () => {
const a = ss.createDesign('A')
const b = ss.createDesign('B')
ss.saveCanvas(a.id, { nodes: [node('a1')], edges: noEdges })
ss.deleteDesign(a.id)
expect(ss.listDesigns().map((d) => d.id)).toEqual([b.id])
expect(localStorage.getItem(canvasKey(a.id))).toBeNull()
})
it('tolerates corrupt JSON in the designs key', () => {
localStorage.setItem(DESIGNS_KEY, '{not valid')
expect(ss.listDesigns()).toEqual([])
})
})
+112
View File
@@ -0,0 +1,112 @@
/**
* Standalone-mode persistence (VITE_STANDALONE=true).
*
* No backend is available, so designs (multi-canvas) and their canvas data are
* persisted directly to localStorage:
* - `homelable_designs` → Design[] (the canvas list)
* - `homelable_canvas:<id>` → { nodes, edges, theme_id, custom_style } per design
*
* Legacy single-canvas installs stored everything under `homelable_canvas`
* (no per-design key, no design list). `ensureSeed()` migrates that data into a
* default design on first run so existing users keep their canvas.
*/
import type { Node, Edge } from '@xyflow/react'
import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef } from '@/types'
import { generateUUID } from '@/utils/uuid'
const DESIGNS_KEY = 'homelable_designs'
const LEGACY_CANVAS_KEY = 'homelable_canvas'
const canvasKey = (designId: string) => `${LEGACY_CANVAS_KEY}:${designId}`
export interface StandaloneCanvas {
nodes: Node<NodeData>[]
edges: Edge<EdgeData>[]
theme_id?: string
custom_style?: CustomStyleDef | null
}
function readJSON<T>(key: string): T | null {
try {
const raw = localStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : null
} catch {
return null
}
}
function nowIso(): string {
return new Date().toISOString()
}
/** Read the design list. Returns [] when none have been created yet. */
export function listDesigns(): Design[] {
return readJSON<Design[]>(DESIGNS_KEY) ?? []
}
function writeDesigns(designs: Design[]): void {
localStorage.setItem(DESIGNS_KEY, JSON.stringify(designs))
}
/**
* Guarantee at least one design exists and return the full list.
* Migrates a legacy single-canvas install into a default design on first run.
*/
export function ensureSeed(): Design[] {
const existing = listDesigns()
if (existing.length > 0) return existing
const design: Design = {
id: generateUUID(),
name: 'My Homelab',
design_type: 'network',
icon: null,
created_at: nowIso(),
updated_at: nowIso(),
}
writeDesigns([design])
// Migrate legacy canvas data (stored under the bare key) into this design.
const legacy = readJSON<StandaloneCanvas>(LEGACY_CANVAS_KEY)
if (legacy && localStorage.getItem(canvasKey(design.id)) === null) {
localStorage.setItem(canvasKey(design.id), JSON.stringify(legacy))
localStorage.removeItem(LEGACY_CANVAS_KEY)
}
return [design]
}
export function createDesign(name: string, icon?: string | null, design_type: DesignType = 'network'): Design {
const design: Design = {
id: generateUUID(),
name,
design_type,
icon: icon ?? null,
created_at: nowIso(),
updated_at: nowIso(),
}
writeDesigns([...listDesigns(), design])
return design
}
export function updateDesign(id: string, patch: Partial<Pick<Design, 'name' | 'icon'>>): Design | null {
const designs = listDesigns()
const idx = designs.findIndex((d) => d.id === id)
if (idx === -1) return null
const updated: Design = { ...designs[idx], ...patch, updated_at: nowIso() }
designs[idx] = updated
writeDesigns(designs)
return updated
}
export function deleteDesign(id: string): void {
writeDesigns(listDesigns().filter((d) => d.id !== id))
localStorage.removeItem(canvasKey(id))
}
/** Load a design's canvas. Returns null when the design has never been saved. */
export function loadCanvas(designId: string): StandaloneCanvas | null {
return readJSON<StandaloneCanvas>(canvasKey(designId))
}
export function saveCanvas(designId: string, data: StandaloneCanvas): void {
localStorage.setItem(canvasKey(designId), JSON.stringify(data))
}