Merge pull request #207 from pranjal-joshi/pranjal/floorplan

feat: floor plan map, LQI edge coloring, zigbee path highlighting
This commit is contained in:
Pouzor - Rémy Jardient
2026-07-03 00:48:39 +02:00
committed by GitHub
18 changed files with 905 additions and 19 deletions
+3
View File
@@ -1,6 +1,9 @@
# Backend - server-side only (NEVER commit .env) # Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db SQLITE_PATH=./data/homelab.db
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
# which sits on the same persistent volume as the DB.
# UPLOAD_DIR=./data/uploads
# Set this to the URL(s) you use to access Homelable in your browser. # Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
+89
View File
@@ -0,0 +1,89 @@
"""Generic media upload/serve endpoint.
Images are stored on disk (see `Settings.media_dir`) with server-generated
UUID filenames — never in the DB, and the client filename is never trusted.
Upload/delete require auth; GET is public so plain <img> tags and the read-only
live view can load images (filenames are unguessable).
Currently used by the floor-plan feature; kept deliberately generic so future
raw-image uploads reuse the same endpoint.
"""
import re
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from app.api.deps import get_current_user
from app.core.config import settings
router = APIRouter()
# content-type → extension. Also the allowlist of accepted uploads.
ALLOWED_TYPES: dict[str, str] = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}
# Magic-byte signatures for defense-in-depth (don't trust content-type alone).
_MAGIC: dict[str, tuple[bytes, ...]] = {
".png": (b"\x89PNG\r\n\x1a\n",),
".jpg": (b"\xff\xd8\xff",),
".webp": (b"RIFF",), # RIFF....WEBP; RIFF prefix is enough to reject non-images
}
MAX_BYTES = 10 * 1024 * 1024 # 10 MB
# Only ever serve/delete files we created: 32 hex chars + known extension.
_NAME_RE = re.compile(r"^[0-9a-f]{32}\.(png|jpg|webp)$")
@router.post("/upload")
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
ext = ALLOWED_TYPES.get(file.content_type or "")
if ext is None:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Unsupported media type — PNG, JPEG, or WebP only",
)
# Read one byte past the cap so we can detect oversize without loading more.
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail="File too large (max 10 MB)",
)
if not data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file")
if not any(data.startswith(sig) for sig in _MAGIC[ext]):
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="File content does not match its type",
)
media_dir = settings.media_dir()
media_dir.mkdir(parents=True, exist_ok=True)
name = f"{uuid.uuid4().hex}{ext}"
(media_dir / name).write_bytes(data)
return {"filename": name, "url": f"/api/v1/media/{name}"}
@router.get("/{filename}")
async def get_media(filename: str) -> FileResponse:
if not _NAME_RE.match(filename):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
path = settings.media_dir() / filename
if not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return FileResponse(path)
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
if not _NAME_RE.match(filename):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
path = settings.media_dir() / filename
if path.is_file():
path.unlink()
+11
View File
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
secret_key: str # Required — set SECRET_KEY in .env secret_key: str # Required — set SECRET_KEY in .env
sqlite_path: str = "./data/homelab.db" sqlite_path: str = "./data/homelab.db"
# Uploaded media (floor plans, and future raw image uploads) live on disk,
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
upload_dir: str = ""
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"] cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
# JWT # JWT
@@ -79,6 +83,13 @@ class Settings(BaseSettings):
def _override_path(self) -> Path: def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json" return Path(self.sqlite_path).parent / "scan_config.json"
def media_dir(self) -> Path:
"""On-disk folder for uploaded media. Sits on the same persistent
volume as the SQLite DB unless UPLOAD_DIR is set."""
if self.upload_dir:
return Path(self.upload_dir)
return Path(self.sqlite_path).parent / "uploads"
def load_overrides(self) -> None: def load_overrides(self) -> None:
"""Load runtime scan config overrides written by the API.""" """Load runtime scan config overrides written by the API."""
try: try:
+2 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee, zwave from app.api.routes import auth, canvas, designs, edges, liveview, media, nodes, scan, stats, status, zigbee, zwave
from app.api.routes import settings as settings_routes from app.api.routes import settings as settings_routes
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler from app.core.scheduler import start_scheduler, stop_scheduler
@@ -59,6 +59,7 @@ app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"]
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"]) app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"]) app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"]) app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
app.include_router(media.router, prefix="/api/v1/media", tags=["media"])
@app.get("/api/v1/health") @app.get("/api/v1/health")
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+94
View File
@@ -0,0 +1,94 @@
import re
import pytest
from app.api.routes import media
from app.core.config import settings
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 32
@pytest.fixture
def media_dir(tmp_path, monkeypatch):
"""Point uploads at a temp folder for the duration of a test."""
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
return tmp_path
async def _upload(client, headers, name="plan.png", data=PNG_BYTES, content_type="image/png"):
return await client.post(
"/api/v1/media/upload",
files={"file": (name, data, content_type)},
headers=headers,
)
@pytest.mark.asyncio
async def test_upload_requires_auth(client, media_dir):
res = await _upload(client, headers={})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_upload_stores_file_and_returns_url(client, auth_headers, media_dir):
headers = await auth_headers()
res = await _upload(client, headers)
assert res.status_code == 200
body = res.json()
assert re.fullmatch(r"/api/v1/media/[0-9a-f]{32}\.png", body["url"])
# File written to disk with the server-generated name (client name ignored).
assert (media_dir / body["filename"]).read_bytes() == PNG_BYTES
assert body["filename"] != "plan.png"
@pytest.mark.asyncio
async def test_upload_rejects_unsupported_type(client, auth_headers, media_dir):
headers = await auth_headers()
res = await _upload(client, headers, name="a.txt", data=b"hello", content_type="text/plain")
assert res.status_code == 415
@pytest.mark.asyncio
async def test_upload_rejects_content_type_magic_mismatch(client, auth_headers, media_dir):
headers = await auth_headers()
# Claims PNG but bytes are not a PNG.
res = await _upload(client, headers, data=b"not-a-real-png", content_type="image/png")
assert res.status_code == 415
@pytest.mark.asyncio
async def test_upload_rejects_oversize(client, auth_headers, media_dir, monkeypatch):
monkeypatch.setattr(media, "MAX_BYTES", 8)
headers = await auth_headers()
res = await _upload(client, headers, data=PNG_BYTES) # > 8 bytes
assert res.status_code == 413
@pytest.mark.asyncio
async def test_get_serves_uploaded_file(client, auth_headers, media_dir):
headers = await auth_headers()
up = await _upload(client, headers)
res = await client.get(up.json()["url"]) # public, no auth
assert res.status_code == 200
assert res.content == PNG_BYTES
@pytest.mark.asyncio
async def test_get_rejects_bad_filename(client, media_dir):
res = await client.get("/api/v1/media/..%2f..%2fetc%2fpasswd")
assert res.status_code == 404
@pytest.mark.asyncio
async def test_delete_requires_auth_and_removes_file(client, auth_headers, media_dir):
headers = await auth_headers()
up = await _upload(client, headers)
filename = up.json()["filename"]
assert (await client.delete(f"/api/v1/media/{filename}")).status_code == 401
assert (media_dir / filename).exists()
res = await client.delete(f"/api/v1/media/{filename}", headers=headers)
assert res.status_code == 204
assert not (media_dir / filename).exists()
assert (await client.get(up.json()["url"])).status_code == 404
+18 -6
View File
@@ -41,14 +41,14 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage' 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, FloorMapConfig } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' 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'
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, floorMap, setFloorMap } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null) const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore()
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
@@ -91,6 +91,7 @@ export default function App() {
const saveDesignId = designIdOverride ?? activeDesignId const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) { if (STANDALONE) {
if (!saveDesignId) return false if (!saveDesignId) return false
// Floor plans are backend-only (upload/serve), so standalone never persists one.
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle }) standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
@@ -98,7 +99,9 @@ export default function App() {
} }
const nodesToSave = nodes.map(serializeNode) const nodesToSave = nodes.map(serializeNode)
const edgesToSave = edges.map(serializeEdge) const edgesToSave = edges.map(serializeEdge)
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId }) const viewport: Record<string, unknown> = { theme_id: activeTheme }
if (floorMap) viewport.floor_map = floorMap
await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return true return true
@@ -106,7 +109,7 @@ export default function App() {
toast.error('Save failed') toast.error('Save failed')
return false return false
} }
}, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId]) }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap])
// Keep a ref so the keydown handler always calls the latest version // Keep a ref so the keydown handler always calls the latest version
const handleSaveRef = useRef(handleSave) const handleSaveRef = useRef(handleSave)
@@ -127,14 +130,20 @@ export default function App() {
const savedTheme = res.data.viewport?.theme_id const savedTheme = res.data.viewport?.theme_id
if (savedTheme) setTheme(savedTheme) if (savedTheme) setTheme(savedTheme)
if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef)
const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
// 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) loadCanvas(rfNodes, rfEdges)
} else { } else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
} catch { } catch {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
}, [loadCanvas, setTheme, setCustomStyle]) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from // Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
// localStorage, falling back to the demo canvas when it has never been saved. // localStorage, falling back to the demo canvas when it has never been saved.
@@ -143,11 +152,14 @@ export default function App() {
if (saved && saved.nodes.length > 0) { if (saved && saved.nodes.length > 0) {
if (saved.theme_id) setTheme(saved.theme_id) if (saved.theme_id) setTheme(saved.theme_id)
if (saved.custom_style) setCustomStyle(saved.custom_style) if (saved.custom_style) setCustomStyle(saved.custom_style)
// Floor plans are backend-only; keep the store clear in standalone mode.
setFloorMap(null)
loadCanvas(saved.nodes, saved.edges) loadCanvas(saved.nodes, saved.edges)
} else { } else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
}, [loadCanvas, setTheme, setCustomStyle]) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
const loadDesignsAndCanvas = useCallback(async () => { const loadDesignsAndCanvas = useCallback(async () => {
if (STANDALONE) { if (STANDALONE) {
+11
View File
@@ -41,6 +41,17 @@ export const canvasApi = {
}) => api.post('/canvas/save', payload), }) => 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 = { export const nodesApi = {
create: (data: object) => api.post('/nodes', data), create: (data: object) => api.post('/nodes', data),
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data), update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
@@ -22,6 +22,7 @@ import { nodeTypes } from './nodes/nodeTypes'
import { edgeTypes } from './edges/edgeTypes' import { edgeTypes } from './edges/edgeTypes'
import { SearchBar } from './SearchBar' import { SearchBar } from './SearchBar'
import { AlignmentGuides } from './AlignmentGuides' import { AlignmentGuides } from './AlignmentGuides'
import { FloorMapLayer } from './FloorMapLayer'
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides' import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
import { setViewportCenterProjector } from '@/utils/viewportCenter' import { setViewportCenterProjector } from '@/utils/viewportCenter'
import type { NodeData, EdgeData } from '@/types' import type { NodeData, EdgeData } from '@/types'
@@ -199,6 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1} size={1}
color={theme.colors.canvasDotColor} color={theme.colors.canvasDotColor}
/> />
<FloorMapLayer />
<SearchBar onOpenPending={onOpenPending} /> <SearchBar onOpenPending={onOpenPending} />
<AlignmentGuides guides={guides} /> <AlignmentGuides guides={guides} />
<Controls> <Controls>
@@ -0,0 +1,181 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore'
interface ResizeState {
startMouseX: number
startMouseY: number
startX: number
startY: number
startW: number
startH: number
edges: Set<'n' | 's' | 'e' | 'w'>
}
/**
* 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.
*
* It always sits at the bottom of the canvas (behind nodes and edges). When
* unlocked it can still be grabbed/resized in areas not covered by a node;
* resize handles appear only while it is selected. Double-clicking an unlocked
* plan opens its edit modal.
*/
export function FloorMapLayer() {
const floorMap = useCanvasStore((s) => s.floorMap)
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
const requestFloorMapEdit = useCanvasStore((s) => s.requestFloorMapEdit)
const { screenToFlowPosition } = useReactFlow()
const zoom = useStore((s) => s.transform[2])
const resizeRef = useRef<ResizeState | null>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const [selected, setSelected] = useState(false)
const locked = floorMap?.locked ?? false
// While selected (and unlocked), deselect on any click outside the plan.
// A locked plan can't be selected, and handles/edit are gated on !locked, so
// a residual selection is harmless.
useEffect(() => {
if (locked || !selected) return
const onDocDown = (ev: MouseEvent) => {
if (!wrapperRef.current?.contains(ev.target as Node)) setSelected(false)
}
document.addEventListener('mousedown', onDocDown)
return () => document.removeEventListener('mousedown', onDocDown)
}, [selected, locked])
const onDragStart = useCallback((e: React.MouseEvent) => {
if (!floorMap) return
e.stopPropagation()
setSelected(true)
const startX = e.clientX
const startY = e.clientY
const origPosX = floorMap.posX
const origPosY = floorMap.posY
const onMove = (ev: MouseEvent) => {
const start = screenToFlowPosition({ x: startX, y: startY })
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
updateFloorMap({ posX: origPosX + (cur.x - start.x), posY: origPosY + (cur.y - start.y) })
}
const onUp = () => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [floorMap, updateFloorMap, screenToFlowPosition])
const onResizeStart = useCallback((e: React.MouseEvent, edges: Set<'n' | 's' | 'e' | 'w'>) => {
if (!floorMap) return
e.stopPropagation()
resizeRef.current = {
startMouseX: e.clientX,
startMouseY: e.clientY,
startX: floorMap.posX,
startY: floorMap.posY,
startW: floorMap.width,
startH: floorMap.height,
edges,
}
const onMove = (ev: MouseEvent) => {
const rs = resizeRef.current
if (!rs) return
const start = screenToFlowPosition({ x: rs.startMouseX, y: rs.startMouseY })
const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY })
const dx = cur.x - start.x
const dy = cur.y - start.y
let x = rs.startX, y = rs.startY, w = rs.startW, h = rs.startH
if (rs.edges.has('w')) { x += dx; w -= dx }
if (rs.edges.has('e')) w += dx
if (rs.edges.has('n')) { y += dy; h -= dy }
if (rs.edges.has('s')) h += dy
const MIN = 80
if (w < MIN) {
if (rs.edges.has('w')) x = rs.startX + rs.startW - MIN
w = MIN
}
if (h < MIN) {
if (rs.edges.has('n')) y = rs.startY + rs.startH - MIN
h = MIN
}
updateFloorMap({ posX: x, posY: y, width: w, height: h })
}
const onUp = () => {
resizeRef.current = null
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}, [floorMap, updateFloorMap, screenToFlowPosition])
if (!floorMap || !floorMap.enabled) return null
const { imageData, posX, posY, width, height, opacity } = 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: hsz,
height: hsz,
background: '#00d4ff',
border: `${2 / zoom}px solid #0d1117`,
borderRadius: 2 / zoom,
zIndex: 10,
}
return (
<ViewportPortal>
<div
ref={wrapperRef}
style={{
position: 'absolute',
left: posX,
top: posY,
width,
height,
opacity,
// Always at the bottom of the canvas, behind nodes and edges.
zIndex: -1,
pointerEvents: locked ? 'none' : 'auto',
cursor: locked ? 'default' : 'move',
}}
onMouseDown={locked ? undefined : onDragStart}
onDoubleClick={locked ? undefined : (e) => { e.stopPropagation(); requestFloorMapEdit() }}
>
<img
src={imageData}
alt="Floor plan"
draggable={false}
style={{
width: '100%',
height: '100%',
objectFit: 'fill',
pointerEvents: 'none',
userSelect: 'none',
}}
/>
{!locked && selected && (
<>
<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>
)
}
@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { FloorMapLayer } from '../FloorMapLayer'
import { useCanvasStore } from '@/stores/canvasStore'
import type { FloorMapConfig } from '@/types'
// Stub React Flow: render the portal inline, and give the layer a 1x zoom and
// an identity screen→flow projection so it can mount without a provider.
vi.mock('@xyflow/react', () => ({
ViewportPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useReactFlow: () => ({ screenToFlowPosition: (p: { x: number; y: number }) => p }),
useStore: (sel: (s: { transform: number[] }) => unknown) => sel({ transform: [0, 0, 1] }),
}))
const BASE: FloorMapConfig = {
imageData: '/api/v1/media/abc.png',
posX: 0, posY: 0, width: 800, height: 600,
opacity: 0.8, locked: false, enabled: true,
}
function setFloorMap(patch: Partial<FloorMapConfig> = {}) {
useCanvasStore.setState({ floorMap: { ...BASE, ...patch }, floorMapEditNonce: 0 })
}
function wrapper() {
return screen.getByAltText('Floor plan').parentElement as HTMLElement
}
function handleCount(root: HTMLElement) {
return Array.from(root.querySelectorAll('div')).filter((d) =>
(d.getAttribute('style') ?? '').includes('resize'),
).length
}
describe('FloorMapLayer', () => {
beforeEach(() => useCanvasStore.setState({ floorMap: null, floorMapEditNonce: 0 }))
it('renders nothing when there is no plan or it is disabled', () => {
const { container, rerender } = render(<FloorMapLayer />)
expect(container.querySelector('img')).toBeNull()
setFloorMap({ enabled: false })
rerender(<FloorMapLayer />)
expect(container.querySelector('img')).toBeNull()
})
it('hides resize handles until the plan is selected, then shows them (unlocked)', () => {
setFloorMap()
render(<FloorMapLayer />)
expect(handleCount(wrapper())).toBe(0)
fireEvent.mouseDown(wrapper())
expect(handleCount(wrapper())).toBe(8)
})
it('never shows handles and is non-interactive when locked', () => {
setFloorMap({ locked: true })
render(<FloorMapLayer />)
const w = wrapper()
fireEvent.mouseDown(w)
expect(handleCount(w)).toBe(0)
expect(w.style.pointerEvents).toBe('none')
})
it('double-click on an unlocked plan requests the edit modal', () => {
setFloorMap()
render(<FloorMapLayer />)
fireEvent.doubleClick(wrapper())
expect(useCanvasStore.getState().floorMapEditNonce).toBe(1)
})
it('locked plan ignores double-click', () => {
setFloorMap({ locked: true })
render(<FloorMapLayer />)
fireEvent.doubleClick(wrapper())
expect(useCanvasStore.getState().floorMapEditNonce).toBe(0)
})
it('sits at the bottom of the canvas (negative z-index)', () => {
setFloorMap()
render(<FloorMapLayer />)
expect(wrapper().style.zIndex).toBe('-1')
})
})
+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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { DESIGN_ICONS, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import type { FloorMapConfig } from '@/types'
export interface DesignFormData { export interface DesignFormData {
name: string name: string
icon: 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 { interface DesignModalProps {
@@ -17,21 +24,91 @@ interface DesignModalProps {
initial?: DesignFormData initial?: DesignFormData
title?: string title?: string
submitLabel?: 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 [name, setName] = useState(initial?.name ?? '')
const [icon, setIcon] = useState(initial?.icon ?? DEFAULT_DESIGN_ICON) 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 handleSubmit = () => {
const trimmed = name.trim() const trimmed = name.trim()
if (!trimmed) return 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 ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <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> <DialogHeader>
<DialogTitle>{title}</DialogTitle> <DialogTitle>{title}</DialogTitle>
</DialogHeader> </DialogHeader>
@@ -75,6 +152,69 @@ export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Can
})} })}
</div> </div>
</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> </div>
<DialogFooter> <DialogFooter>
@@ -64,4 +64,145 @@ describe('DesignModal', () => {
expect(onClose).toHaveBeenCalled() expect(onClose).toHaveBeenCalled()
expect(onSubmit).not.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()
})
// Regression: reopening the edit modal after a canvas-side resize must not
// save stale dimensions. Sidebar bumps the modal `key` on every open so it
// remounts and re-seeds from the current floor plan.
it('re-seeds width/height when remounted with a new key (reopen after resize)', () => {
const onSubmit = vi.fn()
const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON }
const { rerender } = render(
<DesignModal key="k1" open onClose={vi.fn()} onSubmit={onSubmit}
showFloorMap initialFloorMap={fm} initial={initial} submitLabel="Save" />,
)
// Canvas-side resize happened; reopen with a fresh key + larger dims.
const resized = { ...fm, width: 1200, height: 900 }
rerender(
<DesignModal key="k2" open onClose={vi.fn()} onSubmit={onSubmit}
showFloorMap initialFloorMap={resized} initial={initial} submitLabel="Save" />,
)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 1200, height: 900 })
})
it('keeps stale dimensions when reopened without remount (why the key bump matters)', () => {
const onSubmit = vi.fn()
const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON }
const { rerender } = render(
<DesignModal key="same" open onClose={vi.fn()} onSubmit={onSubmit}
showFloorMap initialFloorMap={fm} initial={initial} submitLabel="Save" />,
)
const resized = { ...fm, width: 1200, height: 900 }
rerender(
<DesignModal key="same" open onClose={vi.fn()} onSubmit={onSubmit}
showFloorMap initialFloorMap={resized} initial={initial} submitLabel="Save" />,
)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
// Same key → no remount → local state still the original 800×600.
expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 800, height: 600 })
})
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 })
})
})
}) })
+47 -7
View File
@@ -1,11 +1,11 @@
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } 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 { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' 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, mediaApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage' 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'
@@ -39,6 +39,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null) const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null)
// Bumped on every open so the modal remounts and re-seeds its local state from
// the current floor plan — otherwise a reopen keeps stale width/height/lock
// and Save would clobber canvas-side resize/move.
const [openSeq, setOpenSeq] = useState(0)
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
setOpenSeq((s) => s + 1)
setDesignModal(m)
}, [])
const handleDesignSubmit = useCallback(async (data: DesignFormData) => { const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
if (!designModal) return if (!designModal) return
@@ -54,11 +65,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
: (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data : (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data
if (updated) updateDesign(updated.id, { name: updated.name, icon: updated.icon }) 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. Not pushed to
// undo history (floorMap isn't part of HistoryEntry).
if (data.floorMap !== undefined) {
setFloorMap(data.floorMap)
}
setDesignModal(null) setDesignModal(null)
} catch { } catch {
toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas') toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas')
} }
}, [designModal, addDesign, updateDesign]) }, [designModal, addDesign, updateDesign, setFloorMap])
const handleDesignDelete = useCallback(async (d: Design) => { const handleDesignDelete = useCallback(async (d: Design) => {
if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return }
@@ -76,7 +94,26 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
} }
}, [designs.length, removeDesign]) }, [designs.length, removeDesign])
const { nodes, hasUnsavedChanges } = 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
// Double-click on the floor plan (canvas) asks to edit the active canvas.
useEffect(() => {
if (floorMapEditNonce === 0) return
const active = designs.find((d) => d.id === activeDesignId)
if (active) openDesignModal({ mode: 'edit', design: active })
// Only react to the nonce bump, not to design/active changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [floorMapEditNonce])
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text')
const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
@@ -142,7 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
<button <button
aria-label={`Edit ${d.name}`} aria-label={`Edit ${d.name}`}
title="Edit canvas" title="Edit canvas"
onClick={() => { setDesignModal({ mode: 'edit', design: d }); setDesignSwitcherOpen(false) }} onClick={() => { openDesignModal({ mode: 'edit', design: d }); setDesignSwitcherOpen(false) }}
className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity" className="shrink-0 p-1.5 text-muted-foreground hover:text-foreground cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity"
> >
<Pencil size={12} /> <Pencil size={12} />
@@ -161,7 +198,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
})} })}
<div className="border-t border-border" /> <div className="border-t border-border" />
<button <button
onClick={() => { setDesignModal({ mode: 'create' }); setDesignSwitcherOpen(false) }} onClick={() => { openDesignModal({ mode: 'create' }); setDesignSwitcherOpen(false) }}
className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer" className="flex items-center gap-2 w-full px-3 py-2 text-xs text-[#00d4ff] hover:bg-[#00d4ff]/10 transition-colors cursor-pointer"
> >
<PlusCircle size={14} /> <PlusCircle size={14} />
@@ -255,7 +292,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{!collapsed && <VersionBadge />} {!collapsed && <VersionBadge />}
<DesignModal <DesignModal
key={designModal?.mode === 'edit' ? designModal.design?.id : 'create'} key={`${designModal?.mode === 'edit' ? designModal.design?.id : 'create'}-${openSeq}`}
open={!!designModal} open={!!designModal}
onClose={() => setDesignModal(null)} onClose={() => setDesignModal(null)}
onSubmit={handleDesignSubmit} onSubmit={handleDesignSubmit}
@@ -264,6 +301,9 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
: undefined} : undefined}
title={designModal?.mode === 'edit' ? 'Edit Canvas' : 'New Canvas'} title={designModal?.mode === 'edit' ? 'Edit Canvas' : 'New Canvas'}
submitLabel={designModal?.mode === 'edit' ? 'Save' : 'Create'} submitLabel={designModal?.mode === 'edit' ? 'Save' : 'Create'}
showFloorMap={!STANDALONE && isActiveEdit}
initialFloorMap={!STANDALONE && isActiveEdit ? floorMap : null}
onUploadImage={handleUploadImage}
/> />
</aside> </aside>
) )
@@ -1305,4 +1305,44 @@ describe('canvasStore — custom style apply', () => {
expect(n.width).toBe(300) expect(n.width).toBe(300)
expect(n.height).toBe(100) 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()
})
it('requestFloorMapEdit bumps the nonce each call', () => {
const start = useCanvasStore.getState().floorMapEditNonce
useCanvasStore.getState().requestFloorMapEdit()
useCanvasStore.getState().requestFloorMapEdit()
expect(useCanvasStore.getState().floorMapEditNonce).toBe(start + 2)
})
})
}) })
+21 -1
View File
@@ -9,7 +9,7 @@ import {
applyEdgeChanges, applyEdgeChanges,
addEdge, addEdge,
} from '@xyflow/react' } from '@xyflow/react'
import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types' import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
import { applyOpacity } from '@/utils/colorUtils' import { applyOpacity } from '@/utils/colorUtils'
@@ -35,6 +35,14 @@ interface CanvasState {
// Live per-service status overlay (not persisted), keyed via serviceStatusKey. // Live per-service status overlay (not persisted), keyed via serviceStatusKey.
serviceStatuses: Record<string, ServiceStatus> serviceStatuses: Record<string, ServiceStatus>
floorMap: FloorMapConfig | null
setFloorMap: (config: FloorMapConfig | null) => void
updateFloorMap: (patch: Partial<FloorMapConfig>) => void
// Bumped when the user double-clicks the floor plan on the canvas, asking the
// Sidebar to open the active canvas's edit modal (floor plan section).
floorMapEditNonce: number
requestFloorMapEdit: () => void
// History // History
past: HistoryEntry[] past: HistoryEntry[]
future: HistoryEntry[] future: HistoryEntry[]
@@ -98,6 +106,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
hideIp: readHideIp(), hideIp: readHideIp(),
scanEventTs: 0, scanEventTs: 0,
serviceStatuses: {}, serviceStatuses: {},
floorMap: null,
floorMapEditNonce: 0,
fitViewPending: false, fitViewPending: false,
past: [], past: [],
@@ -747,6 +757,16 @@ export const useCanvasStore = create<CanvasState>((set) => ({
set({ hideIp: value }) set({ hideIp: value })
}, },
setFloorMap: (config) => set({ floorMap: config, hasUnsavedChanges: true }),
updateFloorMap: (patch) =>
set((state) => ({
floorMap: state.floorMap ? { ...state.floorMap, ...patch } : null,
hasUnsavedChanges: true,
})),
requestFloorMapEdit: () => set((s) => ({ floorMapEditNonce: s.floorMapEditNonce + 1 })),
loadCanvas: (nodes, edges) => { loadCanvas: (nodes, edges) => {
// React Flow requires parents before children in the array // React Flow requires parents before children in the array
const parents = nodes.filter((n) => !n.parentId) const parents = nodes.filter((n) => !n.parentId)
+16
View File
@@ -252,3 +252,19 @@ export interface CustomStyleDef {
nodes: Partial<Record<NodeType, NodeTypeStyle>> nodes: Partial<Record<NodeType, NodeTypeStyle>>
edges: Partial<Record<EdgeType, EdgeTypeStyle>> edges: Partial<Record<EdgeType, EdgeTypeStyle>>
} }
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
width: number
height: number
opacity: number
locked: boolean
enabled: boolean
}
+2
View File
@@ -24,6 +24,8 @@ export interface StandaloneCanvas {
edges: Edge<EdgeData>[] edges: Edge<EdgeData>[]
theme_id?: ThemeId theme_id?: ThemeId
custom_style?: CustomStyleDef | null custom_style?: CustomStyleDef | null
// 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 { function readJSON<T>(key: string): T | null {