Merge pull request #244 from Pouzor/feat/standalone-multi-canvas
feat: multi-canvas (designs) support in frontend-only standalone mode
This commit is contained in:
+49
-26
@@ -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) {
|
||||||
@@ -478,7 +498,10 @@ export default function App() {
|
|||||||
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
// Otherwise fetch the configured live view key and build /view?key=...&design=<id>.
|
||||||
const handleViewOnly = useCallback(async () => {
|
const handleViewOnly = useCallback(async () => {
|
||||||
if (STANDALONE) {
|
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
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -913,9 +936,9 @@ export default function App() {
|
|||||||
onCancel={() => setPendingContainerAdd(null)}
|
onCancel={() => setPendingContainerAdd(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!STANDALONE && (
|
{/* Mounted in standalone too: status-check settings are hidden inside,
|
||||||
|
but canvas prefs (snap, hide-IP) still apply. */}
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
)}
|
|
||||||
|
|
||||||
<PendingDevicesModal
|
<PendingDevicesModal
|
||||||
open={pendingModalOpen}
|
open={pendingModalOpen}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ import { edgeTypes } from '@/components/canvas/edges/edgeTypes'
|
|||||||
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
import { deserializeApiNode, deserializeApiEdge, type ApiNode, type ApiEdge } from '@/utils/canvasSerializer'
|
||||||
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
import { computeCollapseInfo, rewireEdgesForCollapse } from '@/utils/collapseFilter'
|
||||||
import { liveviewApi } from '@/api/client'
|
import { liveviewApi } from '@/api/client'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
import type { NodeData, CustomStyleDef } from '@/types'
|
import type { NodeData, CustomStyleDef } from '@/types'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
const STORAGE_KEY = 'homelable_canvas'
|
|
||||||
|
|
||||||
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
type ViewState = 'loading' | 'disabled' | 'invalid-key' | 'no-key' | 'network-error' | 'ready'
|
||||||
|
|
||||||
@@ -55,14 +55,16 @@ function LiveViewCanvas() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (STANDALONE) {
|
if (STANDALONE) {
|
||||||
try {
|
// ?design=<id> selects which canvas to render; fall back to the first
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
// 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) {
|
||||||
const { nodes: savedNodes, edges: savedEdges } = JSON.parse(saved)
|
if (saved.theme_id) setTheme(saved.theme_id)
|
||||||
loadCanvas(savedNodes, savedEdges)
|
if (saved.custom_style) setCustomStyle(saved.custom_style)
|
||||||
}
|
loadCanvas(saved.nodes, saved.edges)
|
||||||
} catch {
|
|
||||||
// empty canvas on parse error — show empty canvas
|
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useThemeStore } from '@/stores/themeStore'
|
import { useThemeStore } from '@/stores/themeStore'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
|
|
||||||
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
// ── Mock heavy dependencies ────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -213,9 +214,13 @@ describe('LiveView (non-standalone)', () => {
|
|||||||
|
|
||||||
// ── Standalone mode ────────────────────────────────────────────────────────
|
// ── 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<string, unknown> = {}
|
||||||
const XYFLOW_MOCK = {
|
const XYFLOW_MOCK = {
|
||||||
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
ReactFlowProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
ReactFlow: () => <div data-testid="react-flow" />,
|
ReactFlow: (props: Record<string, unknown>) => { standaloneRfProps = props; return <div data-testid="react-flow" /> },
|
||||||
Background: () => null,
|
Background: () => null,
|
||||||
Controls: () => null,
|
Controls: () => null,
|
||||||
BackgroundVariant: { Dots: 'dots' },
|
BackgroundVariant: { Dots: 'dots' },
|
||||||
@@ -235,16 +240,45 @@ describe('LiveView (standalone — localStorage)', () => {
|
|||||||
vi.unstubAllEnvs()
|
vi.unstubAllEnvs()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('loads canvas from localStorage without calling the API', async () => {
|
it('loads the active design canvas from localStorage without calling the API', async () => {
|
||||||
const stored = {
|
const design = standaloneStorage.createDesign('Main')
|
||||||
|
standaloneStorage.saveCanvas(design.id, {
|
||||||
nodes: [{
|
nodes: [{
|
||||||
id: 'ls-node', type: 'router',
|
id: 'ls-node', type: 'router',
|
||||||
position: { x: 10, y: 20 },
|
position: { x: 10, y: 20 },
|
||||||
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
|
data: { label: 'Router', type: 'router', status: 'unknown', services: [] },
|
||||||
}],
|
}],
|
||||||
edges: [],
|
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(<LiveViewStandalone />)
|
||||||
|
|
||||||
|
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.stubEnv('VITE_STANDALONE', 'true')
|
||||||
vi.resetModules()
|
vi.resetModules()
|
||||||
@@ -260,6 +294,7 @@ describe('LiveView (standalone — localStorage)', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('react-flow')).toBeDefined()
|
expect(screen.getByTestId('react-flow')).toBeDefined()
|
||||||
})
|
})
|
||||||
|
expect((standaloneRfProps.nodes as { id: string }[]).map((n) => n.id)).toContain('fb-node')
|
||||||
expect(mockLoad).not.toHaveBeenCalled()
|
expect(mockLoad).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
if (STANDALONE) {
|
||||||
|
standaloneStorage.deleteDesign(d.id)
|
||||||
|
} else {
|
||||||
await designsApi.delete(d.id)
|
await designsApi.delete(d.id)
|
||||||
|
}
|
||||||
removeDesign(d.id)
|
removeDesign(d.id)
|
||||||
toast.success('Canvas deleted')
|
toast.success('Canvas deleted')
|
||||||
} catch {
|
} catch {
|
||||||
@@ -193,8 +202,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
|||||||
|
|
||||||
{!collapsed && <div className="flex-1" />}
|
{!collapsed && <div className="flex-1" />}
|
||||||
|
|
||||||
{/* Stats footer */}
|
{/* Stats footer — hidden in standalone (no scan / live status to count) */}
|
||||||
{!collapsed && (
|
{!collapsed && !STANDALONE && (
|
||||||
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
<div className="px-3 py-2 border-t border-border text-xs text-muted-foreground space-y-0.5">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Logo } from '@/components/ui/Logo'
|
import { Logo } from '@/components/ui/Logo'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
|
|
||||||
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onAutoLayout: () => void
|
onAutoLayout: () => void
|
||||||
@@ -82,9 +84,13 @@ export function Toolbar({ onSave, onAutoLayout, onExport, onChangeStyle, onUndo,
|
|||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onExportMd} title="Copy inventory as Markdown table">
|
||||||
<Table2 size={14} /> MD
|
<Table2 size={14} /> MD
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Live view reads backend/localStorage canvas; pointless in standalone
|
||||||
|
where the editor already shows the only (localStorage) copy. */}
|
||||||
|
{!STANDALONE && (
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onViewOnly} title="Open read-only live view of this canvas">
|
||||||
<Eye size={14} /> View
|
<Eye size={14} /> View
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
<Button size="sm" variant="ghost" className="gap-1.5 text-muted-foreground hover:text-foreground cursor-pointer hover:bg-[#21262d]" onClick={onShortcuts} title="Keyboard shortcuts (?)">
|
||||||
<HelpCircle size={14} />
|
<HelpCircle size={14} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -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 { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { Sidebar } from '../Sidebar'
|
import { Sidebar } from '../Sidebar'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { useAuthStore } from '@/stores/authStore'
|
import { useAuthStore } from '@/stores/authStore'
|
||||||
import type { Node } from '@xyflow/react'
|
import type { Node } from '@xyflow/react'
|
||||||
import type { NodeData } from '@/types'
|
import type { NodeData, Design } from '@/types'
|
||||||
|
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||||
|
|
||||||
// ── Mocks ────────────────────────────────────────────────────────────────────
|
// ── Mocks ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -277,3 +278,103 @@ describe('Sidebar', () => {
|
|||||||
expect(mockLogout).toHaveBeenCalledOnce()
|
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<NodeData>[] = [], 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<typeof useCanvasStore>)
|
||||||
|
vi.mocked(as).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
|
||||||
|
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
|
||||||
|
)
|
||||||
|
ds.setState({ designs, activeDesignId: designs[0]?.id ?? null, loaded: true })
|
||||||
|
const { Sidebar: SB } = await import('../Sidebar')
|
||||||
|
render(<SB {...defaultProps} />)
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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 { render, screen, fireEvent } from '@testing-library/react'
|
||||||
import { Toolbar } from '../Toolbar'
|
import { Toolbar } from '../Toolbar'
|
||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
@@ -56,4 +56,33 @@ describe('Toolbar', () => {
|
|||||||
fireEvent.click(screen.getByText('Save'))
|
fireEvent.click(screen.getByText('Save'))
|
||||||
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
expect(defaultProps.onSave).toHaveBeenCalledWith()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows the View (live view) link in full mode', () => {
|
||||||
|
render(<Toolbar {...defaultProps} />)
|
||||||
|
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<typeof useCanvasStore>)
|
||||||
|
const { Toolbar: TB } = await import('../Toolbar')
|
||||||
|
|
||||||
|
render(<TB {...defaultProps} />)
|
||||||
|
expect(screen.queryByText('View')).not.toBeInTheDocument()
|
||||||
|
// Other actions remain.
|
||||||
|
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('MD')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 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 type { ThemeId } from '@/utils/themes'
|
||||||
|
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?: ThemeId
|
||||||
|
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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user