feat: floor plan viewport rendering, per-canvas config, server media upload

- Render floor plan inside React Flow ViewportPortal so it pans/zooms with
  nodes (was screen-fixed, desynced on pan/zoom); zoom-stable resize handles.
- Move floor plan config from the left panel into the canvas (design) edit
  modal; attach per-design and fix cross-design bleed on load.
- Store images via a new generic backend media endpoint (POST/GET/DELETE
  /api/v1/media) on disk under <data_dir>/uploads, not base64 in the canvas.
- Disable floor plans in standalone mode (no backend to upload/serve); drop
  base64 localStorage persistence. See ADR-001 in CLAUDE.md.
- Tests: backend media route, DesignModal floor plan + upload, store floorMap.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-02 16:36:25 +02:00
parent 046c99e219
commit 1ed013bde2
16 changed files with 597 additions and 233 deletions
+10 -34
View File
@@ -26,7 +26,6 @@ import { SettingsModal } from '@/components/modals/SettingsModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { FloorMapModal } from '@/components/modals/FloorMapModal'
import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal'
@@ -83,7 +82,6 @@ export default function App() {
const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
const [floorMapModalOpen, setFloorMapModalOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope.
// Returns true on success, false on failure — the design-switch effect relies
@@ -93,7 +91,8 @@ export default function App() {
const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) {
if (!saveDesignId) return false
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle, floorMap: floorMap ?? undefined })
// Floor plans are backend-only (upload/serve), so standalone never persists one.
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
markSaved()
toast.success('Canvas saved')
return true
@@ -132,12 +131,16 @@ export default function App() {
if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
if (savedFloorMap) setFloorMap(savedFloorMap)
// Clear when the target design has no floor plan, so it doesn't bleed
// across canvases when switching designs.
setFloorMap(savedFloorMap ?? null)
loadCanvas(rfNodes, rfEdges)
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
} catch {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
@@ -149,9 +152,11 @@ export default function App() {
if (saved && saved.nodes.length > 0) {
if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style)
if (saved.floorMap) setFloorMap(saved.floorMap)
// Floor plans are backend-only; keep the store clear in standalone mode.
setFloorMap(null)
loadCanvas(saved.nodes, saved.edges)
} else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges)
}
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
@@ -525,25 +530,6 @@ export default function App() {
}
}, [activeDesignId])
const handleFloorMapSubmit = useCallback((config: FloorMapConfig) => {
snapshotHistory()
if (floorMap) {
// Preserve position from existing config when editing
setFloorMap({ ...floorMap, ...config })
} else {
setFloorMap(config)
}
setFloorMapModalOpen(false)
toast.success(floorMap ? 'Floor plan updated' : 'Floor plan imported')
}, [floorMap, setFloorMap, snapshotHistory])
const handleFloorMapRemove = useCallback(() => {
snapshotHistory()
setFloorMap(null)
setFloorMapModalOpen(false)
toast.success('Floor plan removed')
}, [setFloorMap, snapshotHistory])
const handleExport = useCallback(() => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return }
@@ -723,7 +709,6 @@ export default function App() {
onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)}
onZwaveImport={() => setZwaveImportOpen(true)}
onFloorMap={() => setFloorMapModalOpen(true)}
onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)}
@@ -860,15 +845,6 @@ export default function App() {
/>
)}
<FloorMapModal
key={floorMapModalOpen ? 'floor-map-open' : 'floor-map-closed'}
open={floorMapModalOpen}
onClose={() => setFloorMapModalOpen(false)}
onSubmit={handleFloorMapSubmit}
onRemove={handleFloorMapRemove}
initial={floorMap}
/>
<GroupRectModal
open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)}
+11
View File
@@ -41,6 +41,17 @@ export const canvasApi = {
}) => api.post('/canvas/save', payload),
}
export const mediaApi = {
/** Upload an image, returns its server URL (e.g. /api/v1/media/<uuid>.png). */
upload: async (file: File): Promise<{ url: string; filename: string }> => {
const form = new FormData()
form.append('file', file)
const res = await api.post<{ url: string; filename: string }>('/media/upload', form)
return res.data
},
delete: (filename: string) => api.delete(`/media/${filename}`),
}
export const nodesApi = {
create: (data: object) => api.post('/nodes', data),
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
@@ -200,7 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1}
color={theme.colors.canvasDotColor}
/>
<FloorMapLayer screenToFlowPosition={screenToFlowPosition} />
<FloorMapLayer />
<SearchBar onOpenPending={onOpenPending} />
<AlignmentGuides guides={guides} />
<Controls>
@@ -1,10 +1,7 @@
import { useCallback, useRef } from 'react'
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore'
interface FloorMapLayerProps {
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
}
interface ResizeState {
startMouseX: number
startMouseY: number
@@ -15,9 +12,19 @@ interface ResizeState {
edges: Set<'n' | 's' | 'e' | 'w'>
}
export function FloorMapLayer({ screenToFlowPosition }: FloorMapLayerProps) {
/**
* Floor plan background rendered INSIDE the React Flow viewport (via
* ViewportPortal) so it pans and zooms together with the nodes. Position and
* size are stored in flow coordinates.
*
* When unlocked it sits above the nodes so it can be grabbed/resized; when
* locked it drops behind everything to act as a static background.
*/
export function FloorMapLayer() {
const floorMap = useCanvasStore((s) => s.floorMap)
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
const { screenToFlowPosition } = useReactFlow()
const zoom = useStore((s) => s.transform[2])
const resizeRef = useRef<ResizeState | null>(null)
@@ -91,55 +98,63 @@ export function FloorMapLayer({ screenToFlowPosition }: FloorMapLayerProps) {
const { imageData, posX, posY, width, height, opacity, locked } = floorMap
// Handles live in flow space, so counter-scale by zoom to keep a ~constant
// on-screen size regardless of the current zoom level.
const hsz = 10 / zoom
const half = hsz / 2
const hs: React.CSSProperties = {
position: 'absolute',
width: 10,
height: 10,
width: hsz,
height: hsz,
background: '#00d4ff',
border: '2px solid #0d1117',
borderRadius: 2,
border: `${2 / zoom}px solid #0d1117`,
borderRadius: 2 / zoom,
zIndex: 10,
}
return (
<div
style={{
position: 'absolute',
left: posX,
top: posY,
width,
height,
opacity,
zIndex: -1,
pointerEvents: locked ? 'none' : 'auto',
cursor: locked ? 'default' : 'move',
}}
onMouseDown={locked ? undefined : onDragStart}
>
<img
src={imageData}
alt="Floor plan"
draggable={false}
<ViewportPortal>
<div
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
pointerEvents: 'none',
userSelect: 'none',
position: 'absolute',
left: posX,
top: posY,
width,
height,
opacity,
// Unlocked → above nodes so it can be grabbed. Locked → behind
// everything as a static background.
zIndex: locked ? -1 : 4,
pointerEvents: locked ? 'none' : 'auto',
cursor: locked ? 'default' : 'move',
}}
/>
{!locked && (
<>
<div style={{ ...hs, cursor: 'nw-resize', top: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
<div style={{ ...hs, cursor: 'n-resize', top: -5, left: '50%', marginLeft: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
<div style={{ ...hs, cursor: 'ne-resize', top: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n','e']))} />
<div style={{ ...hs, cursor: 'e-resize', top: '50%', marginTop: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['e']))} />
<div style={{ ...hs, cursor: 'se-resize', bottom: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s','e']))} />
<div style={{ ...hs, cursor: 's-resize', bottom: -5, left: '50%', marginLeft: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s']))} />
<div style={{ ...hs, cursor: 'sw-resize', bottom: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s','w']))} />
<div style={{ ...hs, cursor: 'w-resize', top: '50%', marginTop: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['w']))} />
</>
)}
</div>
onMouseDown={locked ? undefined : onDragStart}
>
<img
src={imageData}
alt="Floor plan"
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
pointerEvents: 'none',
userSelect: 'none',
}}
/>
{!locked && (
<>
<div style={{ ...hs, cursor: 'nw-resize', top: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
<div style={{ ...hs, cursor: 'n-resize', top: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
<div style={{ ...hs, cursor: 'ne-resize', top: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','e']))} />
<div style={{ ...hs, cursor: 'e-resize', top: '50%', marginTop: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['e']))} />
<div style={{ ...hs, cursor: 'se-resize', bottom: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','e']))} />
<div style={{ ...hs, cursor: 's-resize', bottom: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s']))} />
<div style={{ ...hs, cursor: 'sw-resize', bottom: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','w']))} />
<div style={{ ...hs, cursor: 'w-resize', top: '50%', marginTop: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['w']))} />
</>
)}
</div>
</ViewportPortal>
)
}
+144 -4
View File
@@ -1,13 +1,20 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import type { FloorMapConfig } from '@/types'
export interface DesignFormData {
name: string
icon: string
/**
* Floor plan for THIS canvas. Only present when the floor-plan section is
* shown (edit mode on the active canvas). `null` means "remove the floor
* plan"; `undefined` means "leave it untouched".
*/
floorMap?: FloorMapConfig | null
}
interface DesignModalProps {
@@ -17,21 +24,91 @@ interface DesignModalProps {
initial?: DesignFormData
title?: string
submitLabel?: string
/** Show the floor-plan section (only meaningful when editing the active canvas). */
showFloorMap?: boolean
/** Current floor plan of the canvas being edited (position preserved on save). */
initialFloorMap?: FloorMapConfig | null
/**
* Upload a selected image and resolve to its server URL. Required whenever
* the floor-plan section is shown — images are stored server-side, never as
* base64. Rejects on failure (caller surfaces the error).
*/
onUploadImage?: (file: File) => Promise<string>
}
export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Canvas', submitLabel = 'Create' }: DesignModalProps) {
export function DesignModal({
open,
onClose,
onSubmit,
initial,
title = 'New Canvas',
submitLabel = 'Create',
showFloorMap = false,
initialFloorMap = null,
onUploadImage,
}: DesignModalProps) {
const [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON)
// Floor plan state (only used when showFloorMap)
const [imageData, setImageData] = useState(initialFloorMap?.imageData ?? '')
const [width, setWidth] = useState(initialFloorMap?.width ?? 800)
const [height, setHeight] = useState(initialFloorMap?.height ?? 600)
const [opacity, setOpacity] = useState(initialFloorMap?.opacity ?? 0.8)
const [locked, setLocked] = useState(initialFloorMap?.locked ?? false)
const [enabled, setEnabled] = useState(initialFloorMap?.enabled ?? true)
const [uploading, setUploading] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
const handleFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
e.target.value = '' // allow re-selecting the same file after a failure
if (!file || !onUploadImage) return
setUploading(true)
try {
const url = await onUploadImage(file)
setImageData(url)
// Read natural dimensions from the served image (non-blocking).
const img = new Image()
img.onload = () => {
setWidth(img.naturalWidth)
setHeight(img.naturalHeight)
}
img.src = url
} catch {
// Caller surfaces the error toast; leave existing state untouched.
} finally {
setUploading(false)
}
}
const handleSubmit = () => {
const trimmed = name.trim()
if (!trimmed) return
onSubmit({ name: trimmed, icon })
const data: DesignFormData = { name: trimmed, icon }
if (showFloorMap) {
data.floorMap = imageData
? {
imageData,
// Preserve position from the existing config; new plans start at 0,0.
posX: initialFloorMap?.posX ?? 0,
posY: initialFloorMap?.posY ?? 0,
width,
height,
opacity,
locked,
enabled,
}
: null
}
onSubmit(data)
}
const hasImage = !!imageData
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
@@ -75,6 +152,69 @@ export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Can
})}
</div>
</div>
{showFloorMap && (
<div className="space-y-2 pt-2 border-t border-border">
<Label>Floor Plan</Label>
{!hasImage ? (
<div
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[#30363d] rounded-lg p-6 cursor-pointer hover:border-[#00d4ff]/50 transition-colors"
onClick={() => fileRef.current?.click()}
>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
<span className="text-muted-foreground text-sm">{uploading ? 'Uploading…' : 'Click to select a floor plan image'}</span>
<span className="text-muted-foreground/50 text-xs">PNG, JPEG or WebP · max 10 MB</span>
</div>
) : (
<>
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 160 }}>
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" className="cursor-pointer" disabled={uploading} onClick={() => fileRef.current?.click()}>
{uploading ? 'Uploading…' : 'Replace Image'}
</Button>
<Button size="sm" variant="destructive" className="cursor-pointer" onClick={() => setImageData('')}>
Remove
</Button>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Width (px)</Label>
<Input type="number" value={width} onChange={(e) => setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Height (px)</Label>
<Input type="number" value={height} onChange={(e) => setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Opacity: {Math.round(opacity * 100)}%</Label>
<input
type="range" min="0.05" max="1" step="0.05" value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full accent-[#00d4ff]"
/>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
<span className="text-xs text-muted-foreground">Lock position & size</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
<span className="text-xs text-muted-foreground">Show on canvas</span>
</label>
</div>
</>
)}
</div>
)}
</div>
<DialogFooter>
@@ -1,138 +0,0 @@
import { useState, useRef } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { FloorMapConfig } from '@/types'
interface FloorMapModalProps {
open: boolean
onClose: () => void
onSubmit: (config: FloorMapConfig) => void
onRemove: () => void
initial: FloorMapConfig | null
}
export function FloorMapModal({ open, onClose, onSubmit, onRemove, initial }: FloorMapModalProps) {
const [imageData, setImageData] = useState(initial?.imageData ?? '')
const [width, setWidth] = useState(initial?.width ?? 800)
const [height, setHeight] = useState(initial?.height ?? 600)
const [opacity, setOpacity] = useState(initial?.opacity ?? 0.8)
const [locked, setLocked] = useState(initial?.locked ?? false)
const [enabled, setEnabled] = useState(initial?.enabled ?? true)
const fileRef = useRef<HTMLInputElement>(null)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
const dataUrl = ev.target?.result as string
setImageData(dataUrl)
const img = new Image()
img.onload = () => {
setWidth(img.naturalWidth)
setHeight(img.naturalHeight)
}
img.src = dataUrl
}
reader.readAsDataURL(file)
}
const handleSubmit = () => {
if (!imageData) return
onSubmit({
imageData,
posX: initial?.posX ?? 0,
posY: initial?.posY ?? 0,
width,
height,
opacity,
locked,
enabled,
})
}
const hasImage = !!imageData
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{initial ? 'Edit Floor Plan' : 'Import Floor Plan'}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{!hasImage ? (
<div
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[#30363d] rounded-lg p-8 cursor-pointer hover:border-[#00d4ff]/50 transition-colors"
onClick={() => fileRef.current?.click()}
>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
<span className="text-muted-foreground text-sm">Click to select a floor plan image</span>
<span className="text-muted-foreground/50 text-xs">PNG, JPEG or WebP</span>
</div>
) : (
<>
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 200 }}>
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" className="cursor-pointer" onClick={() => fileRef.current?.click()}>
Replace Image
</Button>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image.webp" className="hidden" onChange={handleFile} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Width (px)</Label>
<Input type="number" value={width} onChange={(e) => setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Height (px)</Label>
<Input type="number" value={height} onChange={(e) => setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Opacity: {Math.round(opacity * 100)}%</Label>
<input
type="range" min="0.05" max="1" step="0.05" value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full accent-[#00d4ff]"
/>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
<span className="text-xs text-muted-foreground">Lock position & size</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
<span className="text-xs text-muted-foreground">Show on canvas</span>
</label>
</div>
</>
)}
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
{hasImage && initial && (
<Button size="sm" variant="destructive" className="cursor-pointer" onClick={onRemove}>
Remove
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button size="sm" variant="ghost" className="cursor-pointer" onClick={onClose}>Cancel</Button>
<Button size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer" disabled={!hasImage} onClick={handleSubmit}>
{initial ? 'Apply' : 'Import'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -64,4 +64,108 @@ describe('DesignModal', () => {
expect(onClose).toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
describe('floor plan section', () => {
const fm = {
imageData: 'data:image/png;base64,abc',
posX: 40, posY: 60, width: 800, height: 600,
opacity: 0.8, locked: false, enabled: true,
}
it('is hidden by default and submit omits floorMap', () => {
const { onSubmit } = renderModal()
expect(screen.queryByText('Floor Plan')).toBeNull()
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'X' } })
fireEvent.click(screen.getByRole('button', { name: 'Create' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON })
expect('floorMap' in onSubmit.mock.calls[0][0]).toBe(false)
})
it('shows the section and preserves position while updating config', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: fm,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
})
expect(screen.getByText('Floor Plan')).toBeDefined()
expect(screen.getByAltText('Floor plan preview')).toBeDefined()
// Toggle "Show on canvas" off.
const enabledBox = screen.getByLabelText('Show on canvas') as HTMLInputElement
fireEvent.click(enabledBox)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({
name: 'Home',
icon: DEFAULT_DESIGN_ICON,
floorMap: { ...fm, enabled: false },
})
})
it('submits floorMap: null when the image is removed', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: fm,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
})
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({
name: 'Home',
icon: DEFAULT_DESIGN_ICON,
floorMap: null,
})
})
it('uploads a chosen file and stores the returned server URL', async () => {
const onUploadImage = vi.fn().mockResolvedValue('/api/v1/media/deadbeef.png')
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: null,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
onUploadImage,
})
const file = new File(['x'], 'plan.png', { type: 'image/png' })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [file] } })
await screen.findByAltText('Floor plan preview')
expect(onUploadImage).toHaveBeenCalledWith(file)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
const submitted = onSubmit.mock.calls[0][0]
expect(submitted.floorMap.imageData).toBe('/api/v1/media/deadbeef.png')
})
it('leaves state untouched when upload fails', async () => {
const onUploadImage = vi.fn().mockRejectedValue(new Error('boom'))
renderModal({
showFloorMap: true,
initialFloorMap: null,
initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON },
submitLabel: 'Save',
onUploadImage,
})
const file = new File(['x'], 'plan.png', { type: 'image/png' })
const input = document.querySelector('input[type="file"]') as HTMLInputElement
fireEvent.change(input, { target: { files: [file] } })
await vi.waitFor(() => expect(onUploadImage).toHaveBeenCalled())
expect(screen.queryByAltText('Floor plan preview')).toBeNull()
})
it('submits floorMap: null when shown but no image was chosen', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: null,
submitLabel: 'Save',
})
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Empty' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Empty', icon: DEFAULT_DESIGN_ICON, floorMap: null })
})
})
})
+26 -7
View File
@@ -1,11 +1,11 @@
import { useState, useCallback } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2, Image } from 'lucide-react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { designsApi } from '@/api/client'
import { designsApi, mediaApi } 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'
@@ -27,19 +27,19 @@ interface SidebarProps {
onScan: () => void
onZigbeeImport: () => void
onZwaveImport: () => void
onFloorMap: () => void
onSave: () => void
onOpenSettings: () => void
onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
}
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onFloorMap, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false)
const logout = useAuthStore((s) => s.logout)
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
const { nodes, hasUnsavedChanges, floorMap, setFloorMap, snapshotHistory } = useCanvasStore()
const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
if (!designModal) return
@@ -55,11 +55,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
: (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data
if (updated) updateDesign(updated.id, { name: updated.name, icon: updated.icon })
}
// Floor plan is canvas data attached to the active design. `undefined`
// means the section wasn't shown → leave it untouched. Applied to the
// store and persisted on the next explicit canvas Save.
if (data.floorMap !== undefined) {
snapshotHistory()
setFloorMap(data.floorMap)
}
setDesignModal(null)
} catch {
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
}
}, [designModal, addDesign, updateDesign])
}, [designModal, addDesign, updateDesign, setFloorMap, snapshotHistory])
const handleDesignDelete = useCallback(async (d: Design) => {
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
@@ -77,7 +84,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
}
}, [designs.length, removeDesign])
const { nodes, hasUnsavedChanges, floorMap } = useCanvasStore()
const handleUploadImage = useCallback(async (file: File): Promise<string> => {
try {
const { url } = await mediaApi.upload(file)
return url
} catch {
toast.error('Image upload failed')
throw new Error('upload failed')
}
}, [])
const isActiveEdit = designModal?.mode === 'edit' && designModal.design?.id === activeDesignId
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
@@ -229,7 +246,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
<SidebarItem icon={Image} label={floorMap ? 'Edit Floor Plan' : 'Add Floor Plan'} collapsed={collapsed} onClick={onFloorMap} />
<SidebarItem
icon={Save}
label="Save Canvas"
@@ -266,6 +282,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
: undefined}
title={designModal?.mode === 'edit' ? 'Edit Canvas' : 'New Canvas'}
submitLabel={designModal?.mode === 'edit' ? 'Save' : 'Create'}
showFloorMap={!STANDALONE && isActiveEdit}
initialFloorMap={!STANDALONE && isActiveEdit ? floorMap : null}
onUploadImage={handleUploadImage}
/>
</aside>
)
@@ -1305,4 +1305,37 @@ describe('canvasStore — custom style apply', () => {
expect(n.width).toBe(300)
expect(n.height).toBe(100)
})
describe('floorMap', () => {
const fm = {
imageData: 'data:image/png;base64,abc',
posX: 10, posY: 20, width: 800, height: 600,
opacity: 0.8, locked: false, enabled: true,
}
beforeEach(() => useCanvasStore.setState({ floorMap: null, hasUnsavedChanges: false }))
it('setFloorMap sets and marks unsaved', () => {
useCanvasStore.getState().setFloorMap(fm)
expect(useCanvasStore.getState().floorMap).toEqual(fm)
expect(useCanvasStore.getState().hasUnsavedChanges).toBe(true)
})
it('setFloorMap(null) clears the plan — prevents cross-design bleed on load', () => {
useCanvasStore.setState({ floorMap: fm })
useCanvasStore.getState().setFloorMap(null)
expect(useCanvasStore.getState().floorMap).toBeNull()
})
it('updateFloorMap patches an existing plan (position preserved)', () => {
useCanvasStore.setState({ floorMap: fm })
useCanvasStore.getState().updateFloorMap({ posX: 99, opacity: 0.5 })
expect(useCanvasStore.getState().floorMap).toEqual({ ...fm, posX: 99, opacity: 0.5 })
})
it('updateFloorMap is a no-op when no plan exists', () => {
useCanvasStore.getState().updateFloorMap({ posX: 5 })
expect(useCanvasStore.getState().floorMap).toBeNull()
})
})
})
+5
View File
@@ -254,6 +254,11 @@ export interface CustomStyleDef {
}
export interface FloorMapConfig {
/**
* Server URL of the uploaded image (e.g. /api/v1/media/<uuid>.png).
* Legacy canvases may still hold a base64 `data:` URL — both render in <img>.
* Floor plans require a backend; they are disabled in standalone mode.
*/
imageData: string
posX: number
posY: number
+3 -2
View File
@@ -11,7 +11,7 @@
* 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, FloorMapConfig } from '@/types'
import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef } from '@/types'
import type { ThemeId } from '@/utils/themes'
import { generateUUID } from '@/utils/uuid'
@@ -24,7 +24,8 @@ export interface StandaloneCanvas {
edges: Edge<EdgeData>[]
theme_id?: ThemeId
custom_style?: CustomStyleDef | null
floorMap?: FloorMapConfig
// NOTE: no floor plan here — floor plans need a backend to upload/serve the
// image, so they are disabled in standalone mode (see homelable/CLAUDE.md ADR).
}
function readJSON<T>(key: string): T | null {