From ca171089c27a3a3dfab16c0e450c1eadc974abe4 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Mon, 29 Jun 2026 23:13:56 +0200 Subject: [PATCH 1/5] 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:). 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 --- frontend/src/App.tsx | 66 +++++++---- frontend/src/components/panels/Sidebar.tsx | 19 ++- .../standaloneStorageDesigns.test.ts | 102 ++++++++++++++++ frontend/src/utils/standaloneStorage.ts | 112 ++++++++++++++++++ 4 files changed, 271 insertions(+), 28 deletions(-) create mode 100644 frontend/src/utils/__tests__/standaloneStorageDesigns.test.ts create mode 100644 frontend/src/utils/standaloneStorage.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 733b8b4..d755085 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,7 @@ import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { useThemeStore } from '@/stores/themeStore' import { canvasApi, designsApi, liveviewApi } from '@/api/client' +import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' 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' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' -const STANDALONE_STORAGE_KEY = 'homelable_canvas' 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() @@ -90,7 +90,8 @@ export default function App() { try { const saveDesignId = designIdOverride ?? activeDesignId 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() toast.success('Canvas saved') return true @@ -135,8 +136,30 @@ export default function App() { } }, [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 () => { - 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 { const res = await designsApi.list() 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 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(() => { if (STANDALONE) { - try { - 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) - } + loadDesignsAndCanvasRef.current() return } if (!isAuthenticated) return - loadDesignsAndCanvas() - }, [isAuthenticated, loadCanvas, setTheme, setCustomStyle]) // only on auth change, not design change + loadDesignsAndCanvasRef.current() + }, [isAuthenticated]) // only on auth change, not design change // Reload canvas when active design changes (after initial load) const initialLoadDone = useRef(false) @@ -186,7 +203,10 @@ export default function App() { prevDesignRef.current = activeDesignId 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 // If the previous design was deleted (no longer in the list), don't try to // save into it — just load the newly-selected design. @@ -199,7 +219,7 @@ export default function App() { const targetId = activeDesignId handleSave(oldId).then((ok) => { if (ok) { - loadCanvasFromApi(targetId) + loadForDesign(targetId) } else { // Save failed: don't load the new design — that would overwrite the // unsaved in-memory canvas. Revert the selection back to the old @@ -210,7 +230,7 @@ export default function App() { } }) } else { - loadCanvasFromApi(activeDesignId) + loadForDesign(activeDesignId) } } if (activeDesignId) { diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 32186c9..7144304 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -6,6 +6,7 @@ import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' import { designsApi } from '@/api/client' +import * as standaloneStorage from '@/utils/standaloneStorage' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' import type { Design } from '@/types' @@ -43,11 +44,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee if (!designModal) return try { if (designModal.mode === 'create') { - const res = await designsApi.create({ name: data.name, icon: data.icon }) - addDesign(res.data) + const created = STANDALONE + ? standaloneStorage.createDesign(data.name, data.icon) + : (await designsApi.create({ name: data.name, icon: data.icon })).data + addDesign(created) } else if (designModal.design) { - const res = await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon }) - updateDesign(res.data.id, { name: res.data.name, icon: res.data.icon }) + const updated = STANDALONE + ? 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) } 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 (!window.confirm(`Delete canvas "${d.name}"? Its nodes and links will be removed.`)) return try { - await designsApi.delete(d.id) + if (STANDALONE) { + standaloneStorage.deleteDesign(d.id) + } else { + await designsApi.delete(d.id) + } removeDesign(d.id) toast.success('Canvas deleted') } catch { diff --git a/frontend/src/utils/__tests__/standaloneStorageDesigns.test.ts b/frontend/src/utils/__tests__/standaloneStorageDesigns.test.ts new file mode 100644 index 0000000..eacdf63 --- /dev/null +++ b/frontend/src/utils/__tests__/standaloneStorageDesigns.test.ts @@ -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 { + return { id, type: 'server', position: { x: 0, y: 0 }, data: { label: id, type: 'server', status: 'unknown', services: [] } } +} +const noEdges: Edge[] = [] + +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([]) + }) +}) diff --git a/frontend/src/utils/standaloneStorage.ts b/frontend/src/utils/standaloneStorage.ts new file mode 100644 index 0000000..c8245d2 --- /dev/null +++ b/frontend/src/utils/standaloneStorage.ts @@ -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:` → { 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[] + edges: Edge[] + theme_id?: string + custom_style?: CustomStyleDef | null +} + +function readJSON(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(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(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>): 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(canvasKey(designId)) +} + +export function saveCanvas(designId: string, data: StandaloneCanvas): void { + localStorage.setItem(canvasKey(designId), JSON.stringify(data)) +} From c356a65a5fff6b0c1345bff72e9356d3fe0b818e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 30 Jun 2026 00:05:13 +0200 Subject: [PATCH 2/5] fix: repair live view + settings in standalone multi-canvas mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps surfaced after adding per-design localStorage storage: - Live view (/view) still read the legacy bare `homelable_canvas` key, which no longer exists once canvases are keyed per design — the read-only tab rendered empty. Now passes the active design id (?design=) and reads that design's canvas, falling back to the first design. - The Settings modal was gated out entirely in standalone, leaving the Settings button dead even though canvas prefs (snap, hide-IP) work without a backend. Mount it in standalone; only the backend status-check section stays hidden. ha-relevant: no --- frontend/src/App.tsx | 11 +++-- frontend/src/components/LiveView.tsx | 20 +++++---- .../components/__tests__/LiveView.test.tsx | 45 ++++++++++++++++--- 3 files changed, 58 insertions(+), 18 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d755085..0f833c2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -498,7 +498,10 @@ export default function App() { // Otherwise fetch the configured live view key and build /view?key=...&design=. const handleViewOnly = useCallback(async () => { if (STANDALONE) { - window.open('/view', '_blank', 'noopener,noreferrer') + // Standalone reads canvas from localStorage; pass the active design id so + // the read-only tab renders the same canvas the user is viewing. + const url = activeDesignId ? `/view?design=${encodeURIComponent(activeDesignId)}` : '/view' + window.open(url, '_blank', 'noopener,noreferrer') return } try { @@ -933,9 +936,9 @@ export default function App() { onCancel={() => setPendingContainerAdd(null)} /> - {!STANDALONE && ( - setSettingsOpen(false)} /> - )} + {/* Mounted in standalone too: status-check settings are hidden inside, + but canvas prefs (snap, hide-IP) still apply. */} + setSettingsOpen(false)} /> { if (STANDALONE) { - try { - const saved = localStorage.getItem(STORAGE_KEY) - if (saved) { - const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved) - loadCanvas(savedNodes, savedEdges) - } - } catch { - // empty canvas on parse error — show empty canvas + // ?design= selects which canvas to render; fall back to the first + // design when omitted. Standalone stores full React Flow nodes/edges, so + // no API deserialization is needed. + const designId = new URLSearchParams(window.location.search).get('design') + ?? standaloneStorage.listDesigns()[0]?.id + const saved = designId ? standaloneStorage.loadCanvas(designId) : null + if (saved) { + if (saved.theme_id) setTheme(saved.theme_id) + if (saved.custom_style) setCustomStyle(saved.custom_style) + loadCanvas(saved.nodes, saved.edges) } return } diff --git a/frontend/src/components/__tests__/LiveView.test.tsx b/frontend/src/components/__tests__/LiveView.test.tsx index 8a8bd4f..f230501 100644 --- a/frontend/src/components/__tests__/LiveView.test.tsx +++ b/frontend/src/components/__tests__/LiveView.test.tsx @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, waitFor } from '@testing-library/react' import { useCanvasStore } from '@/stores/canvasStore' import { useThemeStore } from '@/stores/themeStore' +import * as standaloneStorage from '@/utils/standaloneStorage' // ── Mock heavy dependencies ──────────────────────────────────────────────── @@ -213,9 +214,13 @@ describe('LiveView (non-standalone)', () => { // ── Standalone mode ──────────────────────────────────────────────────────── +// Captures props from the re-imported (resetModules) ReactFlow so standalone +// tests can assert which nodes were rendered without reaching into the fresh +// canvas-store module instance. +let standaloneRfProps: Record = {} const XYFLOW_MOCK = { ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}, - ReactFlow: () =>
, + ReactFlow: (props: Record) => { standaloneRfProps = props; return
}, Background: () => null, Controls: () => null, BackgroundVariant: { Dots: 'dots' }, @@ -235,16 +240,45 @@ describe('LiveView (standalone — localStorage)', () => { vi.unstubAllEnvs() }) - it('loads canvas from localStorage without calling the API', async () => { - const stored = { + it('loads the active design canvas from localStorage without calling the API', async () => { + const design = standaloneStorage.createDesign('Main') + standaloneStorage.saveCanvas(design.id, { nodes: [{ id: 'ls-node', type: 'router', position: { x: 10, y: 20 }, data: { label: 'Router', type: 'router', status: 'unknown', services: [] }, }], edges: [], - } - localStorage.setItem('homelable_canvas', JSON.stringify(stored)) + }) + + vi.stubEnv('VITE_STANDALONE', 'true') + vi.resetModules() + const mockLoad = vi.fn() + vi.doMock('@xyflow/react', () => XYFLOW_MOCK) + vi.doMock('@xyflow/react/dist/style.css', () => ({})) + vi.doMock('@/api/client', () => ({ liveviewApi: { load: mockLoad } })) + const { default: LiveViewStandalone } = await import('../LiveView') + + setSearch(`?design=${design.id}`) + render() + + await waitFor(() => { + expect(screen.getByTestId('react-flow')).toBeDefined() + }) + expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('ls-node') + expect(mockLoad).not.toHaveBeenCalled() + }) + + it('falls back to the first design when no ?design= param is given', async () => { + const design = standaloneStorage.createDesign('Only') + standaloneStorage.saveCanvas(design.id, { + nodes: [{ + id: 'fb-node', type: 'server', + position: { x: 0, y: 0 }, + data: { label: 'Srv', type: 'server', status: 'unknown', services: [] }, + }], + edges: [], + }) vi.stubEnv('VITE_STANDALONE', 'true') vi.resetModules() @@ -260,6 +294,7 @@ describe('LiveView (standalone — localStorage)', () => { await waitFor(() => { expect(screen.getByTestId('react-flow')).toBeDefined() }) + expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('fb-node') expect(mockLoad).not.toHaveBeenCalled() }) From 79cac7d80f55c9a98facc8d7cc4eac4e512e6707 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 30 Jun 2026 00:23:48 +0200 Subject: [PATCH 3/5] feat: hide node stats footer in standalone mode The Total/Online/Offline counts reflect live status-check results, which require a backend scanner. In frontend-only standalone mode there is nothing to populate them, so hide the footer (kept in full mode and live view). ha-relevant: no --- frontend/src/components/panels/Sidebar.tsx | 4 +- .../panels/__tests__/Sidebar.test.tsx | 105 +++++++++++++++++- 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 7144304..4a3165a 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -202,8 +202,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee {!collapsed &&
} - {/* Stats footer */} - {!collapsed && ( + {/* Stats footer — hidden in standalone (no scan / live status to count) */} + {!collapsed && !STANDALONE && (
Total diff --git a/frontend/src/components/panels/__tests__/Sidebar.test.tsx b/frontend/src/components/panels/__tests__/Sidebar.test.tsx index 23ea1e0..55266ff 100644 --- a/frontend/src/components/panels/__tests__/Sidebar.test.tsx +++ b/frontend/src/components/panels/__tests__/Sidebar.test.tsx @@ -1,10 +1,11 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { Sidebar } from '../Sidebar' import { useCanvasStore } from '@/stores/canvasStore' import { useAuthStore } from '@/stores/authStore' import type { Node } from '@xyflow/react' -import type { NodeData } from '@/types' +import type { NodeData, Design } from '@/types' +import * as standaloneStorage from '@/utils/standaloneStorage' // ── Mocks ──────────────────────────────────────────────────────────────────── @@ -277,3 +278,103 @@ describe('Sidebar', () => { expect(mockLogout).toHaveBeenCalledOnce() }) }) + +// ── Standalone mode ──────────────────────────────────────────────────────────── +// VITE_STANDALONE is read at module load, so re-import Sidebar after stubbing it. +// The hoisted vi.mock auto-mocks re-apply on re-import; configure the fresh mock +// instances after the dynamic import. +describe('Sidebar (standalone)', () => { + const makeDesign = (id: string, name: string): Design => ({ + id, name, design_type: 'network', icon: null, + created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', + }) + + beforeEach(() => { + localStorage.clear() + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + + // Re-import Sidebar after stubbing VITE_STANDALONE. Returns the design-store + // instance the re-imported Sidebar uses, seeded with `designs` so we can drive + // and assert the switcher. + async function renderStandalone(nodes: Node[] = [], designs: Design[] = []) { + vi.stubEnv('VITE_STANDALONE', 'true') + vi.resetModules() + const { useCanvasStore: cs } = await import('@/stores/canvasStore') + const { useAuthStore: as } = await import('@/stores/authStore') + const { useDesignStore: ds } = await import('@/stores/designStore') + vi.mocked(cs).mockReturnValue({ + nodes, hasUnsavedChanges: false, addNode: vi.fn(), scanEventTs: 0, + } as ReturnType) + vi.mocked(as).mockImplementation((selector: (s: { logout: () => void }) => unknown) => + selector({ logout: mockLogout }) as ReturnType + ) + ds.setState({ designs, activeDesignId: designs[0]?.id ?? null, loaded: true }) + const { Sidebar: SB } = await import('../Sidebar') + render() + return ds + } + + it('hides the Total/Online/Offline stats footer', async () => { + await renderStandalone([makeNode('n1', 'online'), makeNode('n2', 'offline')]) + expect(screen.queryByText('Total')).not.toBeInTheDocument() + expect(screen.queryByText('Online')).not.toBeInTheDocument() + expect(screen.queryByText('Offline')).not.toBeInTheDocument() + }) + + it('hides scan-dependent items but keeps canvas actions', async () => { + await renderStandalone() + expect(screen.queryByText('Scan Network')).not.toBeInTheDocument() + expect(screen.queryByText('Device Inventory')).not.toBeInTheDocument() + expect(screen.queryByText('Logout')).not.toBeInTheDocument() + expect(screen.getByText('Add Node')).toBeInTheDocument() + expect(screen.getByText('Save Canvas')).toBeInTheDocument() + }) + + it('creates a canvas via localStorage (no API) and adds it to the store', async () => { + const ds = await renderStandalone([], [makeDesign('d1', 'Main')]) + + fireEvent.click(screen.getByText('Main')) // open switcher + fireEvent.click(screen.getByText('New Canvas')) // open create modal + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Garage' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + + await screen.findByText('Garage') + expect(ds.getState().designs.map((d) => d.name)).toContain('Garage') + expect(standaloneStorage.listDesigns().map((d) => d.name)).toContain('Garage') + }) + + it('renames a canvas via localStorage (no API)', async () => { + standaloneStorage.createDesign('Old') + const seeded = standaloneStorage.listDesigns() + const ds = await renderStandalone([], seeded) + + fireEvent.click(screen.getByText('Old')) + fireEvent.click(screen.getByLabelText('Edit Old')) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Renamed' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + + await screen.findByText('Renamed') + expect(ds.getState().designs.map((d) => d.name)).toContain('Renamed') + expect(standaloneStorage.listDesigns()[0].name).toBe('Renamed') + }) + + it('deletes a canvas via localStorage (no API)', async () => { + standaloneStorage.createDesign('Keep') + standaloneStorage.createDesign('Drop') + const seeded = standaloneStorage.listDesigns() + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true) + const ds = await renderStandalone([], seeded) + + fireEvent.click(screen.getByText('Keep')) // open switcher + fireEvent.click(screen.getByLabelText('Delete Drop')) + + expect(confirmSpy).toHaveBeenCalled() + expect(standaloneStorage.listDesigns().map((d) => d.name)).toEqual(['Keep']) + expect(ds.getState().designs.map((d) => d.name)).toEqual(['Keep']) + confirmSpy.mockRestore() + }) +}) From d1c052c9e8e83903ace1cc0648c6c3d8f44457aa Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 30 Jun 2026 00:48:56 +0200 Subject: [PATCH 4/5] feat: hide header View link in standalone mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "View" link opens the read-only live view of the canvas. In frontend-only standalone mode the editor already renders the only (localStorage) copy, so the live view adds nothing — hide the link. Kept in full mode. ha-relevant: no --- frontend/src/components/panels/Toolbar.tsx | 12 +++++-- .../panels/__tests__/Toolbar.test.tsx | 31 ++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/panels/Toolbar.tsx b/frontend/src/components/panels/Toolbar.tsx index 87d1d72..6f1b2b5 100644 --- a/frontend/src/components/panels/Toolbar.tsx +++ b/frontend/src/components/panels/Toolbar.tsx @@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button' import { Logo } from '@/components/ui/Logo' import { useCanvasStore } from '@/stores/canvasStore' +const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' + interface ToolbarProps { onSave: () => void onAutoLayout: () => void @@ -82,9 +84,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo, - + {/* Live view reads backend/localStorage canvas; pointless in standalone + where the editor already shows the only (localStorage) copy. */} + {!STANDALONE && ( + + )} diff --git a/frontend/src/components/panels/__tests__/Toolbar.test.tsx b/frontend/src/components/panels/__tests__/Toolbar.test.tsx index 0540929..e06cc60 100644 --- a/frontend/src/components/panels/__tests__/Toolbar.test.tsx +++ b/frontend/src/components/panels/__tests__/Toolbar.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { Toolbar } from '../Toolbar' import { useCanvasStore } from '@/stores/canvasStore' @@ -56,4 +56,33 @@ describe('Toolbar', () => { fireEvent.click(screen.getByText('Save')) expect(defaultProps.onSave).toHaveBeenCalledWith() }) + + it('shows the View (live view) link in full mode', () => { + render() + expect(screen.getByText('View')).toBeInTheDocument() + }) +}) + +// ── Standalone mode ──────────────────────────────────────────────────────────── +// VITE_STANDALONE is read at module load, so re-import Toolbar after stubbing it. +describe('Toolbar (standalone)', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('hides the View link (live view is pointless without a backend)', async () => { + vi.stubEnv('VITE_STANDALONE', 'true') + vi.resetModules() + const { useCanvasStore: cs } = await import('@/stores/canvasStore') + vi.mocked(cs).mockReturnValue({ + hasUnsavedChanges: false, past: [], future: [], + } as ReturnType) + const { Toolbar: TB } = await import('../Toolbar') + + render() + expect(screen.queryByText('View')).not.toBeInTheDocument() + // Other actions remain. + expect(screen.getByText('Save')).toBeInTheDocument() + expect(screen.getByText('MD')).toBeInTheDocument() + }) }) From 1f2d298419ffd182ddff37b0e15a14f6386317be Mon Sep 17 00:00:00 2001 From: Pouzor Date: Tue, 30 Jun 2026 00:58:42 +0200 Subject: [PATCH 5/5] fix: type standalone canvas theme_id as ThemeId The Docker CI build (tsc -b, stricter than the local tsc --noEmit) rejected passing the storage theme_id (string) to setTheme/setCustomStyle which expect ThemeId. Type StandaloneCanvas.theme_id as ThemeId so the standalone load path matches the theme store API. ha-relevant: no --- frontend/src/utils/standaloneStorage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/utils/standaloneStorage.ts b/frontend/src/utils/standaloneStorage.ts index c8245d2..f5f2506 100644 --- a/frontend/src/utils/standaloneStorage.ts +++ b/frontend/src/utils/standaloneStorage.ts @@ -12,6 +12,7 @@ */ import type { Node, Edge } from '@xyflow/react' import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef } from '@/types' +import type { ThemeId } from '@/utils/themes' import { generateUUID } from '@/utils/uuid' const DESIGNS_KEY = 'homelable_designs' @@ -21,7 +22,7 @@ const canvasKey = (designId: string) => `${LEGACY_CANVAS_KEY}:${designId}` export interface StandaloneCanvas { nodes: Node[] edges: Edge[] - theme_id?: string + theme_id?: ThemeId custom_style?: CustomStyleDef | null }