Merge pull request #207 from pranjal-joshi/pranjal/floorplan
feat: floor plan map, LQI edge coloring, zigbee path highlighting
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
# Backend - server-side only (NEVER commit .env)
|
||||
SECRET_KEY=change_me_in_production
|
||||
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.
|
||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
|
||||
|
||||
secret_key: str # Required — set SECRET_KEY in .env
|
||||
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"]
|
||||
|
||||
# JWT
|
||||
@@ -79,6 +83,13 @@ class Settings(BaseSettings):
|
||||
def _override_path(self) -> Path:
|
||||
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:
|
||||
"""Load runtime scan config overrides written by the API."""
|
||||
try:
|
||||
|
||||
+2
-1
@@ -7,7 +7,7 @@ from typing import Any
|
||||
from fastapi import FastAPI
|
||||
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.core.config import settings
|
||||
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(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
|
||||
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")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -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
@@ -41,14 +41,14 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client'
|
||||
import * as standaloneStorage from '@/utils/standaloneStorage'
|
||||
import { demoNodes, demoEdges } from '@/utils/demoData'
|
||||
import { useStatusPolling } from '@/hooks/useStatusPolling'
|
||||
import type { NodeData, EdgeData, CustomStyleDef } from '@/types'
|
||||
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types'
|
||||
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
|
||||
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
|
||||
|
||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||
|
||||
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 { isAuthenticated } = useAuthStore()
|
||||
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
|
||||
@@ -91,6 +91,7 @@ export default function App() {
|
||||
const saveDesignId = designIdOverride ?? activeDesignId
|
||||
if (STANDALONE) {
|
||||
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 })
|
||||
markSaved()
|
||||
toast.success('Canvas saved')
|
||||
@@ -98,7 +99,9 @@ export default function App() {
|
||||
}
|
||||
const nodesToSave = nodes.map(serializeNode)
|
||||
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()
|
||||
toast.success('Canvas saved')
|
||||
return true
|
||||
@@ -106,7 +109,7 @@ export default function App() {
|
||||
toast.error('Save failed')
|
||||
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
|
||||
const handleSaveRef = useRef(handleSave)
|
||||
@@ -127,14 +130,20 @@ export default function App() {
|
||||
const savedTheme = res.data.viewport?.theme_id
|
||||
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
|
||||
// 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])
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
|
||||
// Standalone counterpart of loadCanvasFromApi — reads a design's canvas from
|
||||
// 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.theme_id) setTheme(saved.theme_id)
|
||||
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)
|
||||
} else {
|
||||
setFloorMap(null)
|
||||
loadCanvas(demoNodes, demoEdges)
|
||||
}
|
||||
}, [loadCanvas, setTheme, setCustomStyle])
|
||||
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
|
||||
|
||||
const loadDesignsAndCanvas = useCallback(async () => {
|
||||
if (STANDALONE) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -22,6 +22,7 @@ import { nodeTypes } from './nodes/nodeTypes'
|
||||
import { edgeTypes } from './edges/edgeTypes'
|
||||
import { SearchBar } from './SearchBar'
|
||||
import { AlignmentGuides } from './AlignmentGuides'
|
||||
import { FloorMapLayer } from './FloorMapLayer'
|
||||
import { useAlignmentGuides } from '@/hooks/useAlignmentGuides'
|
||||
import { setViewportCenterProjector } from '@/utils/viewportCenter'
|
||||
import type { NodeData, EdgeData } from '@/types'
|
||||
@@ -199,6 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
||||
size={1}
|
||||
color={theme.colors.canvasDotColor}
|
||||
/>
|
||||
<FloorMapLayer />
|
||||
<SearchBar onOpenPending={onOpenPending} />
|
||||
<AlignmentGuides guides={guides} />
|
||||
<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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -64,4 +64,145 @@ 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()
|
||||
})
|
||||
|
||||
// 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 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 { 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'
|
||||
@@ -39,6 +39,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore()
|
||||
const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false)
|
||||
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) => {
|
||||
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
|
||||
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)
|
||||
} catch {
|
||||
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) => {
|
||||
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])
|
||||
|
||||
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 onlineCount = networkNodes.filter((n) => n.data.status === 'online').length
|
||||
@@ -142,7 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
<button
|
||||
aria-label={`Edit ${d.name}`}
|
||||
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"
|
||||
>
|
||||
<Pencil size={12} />
|
||||
@@ -161,7 +198,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
})}
|
||||
<div className="border-t border-border" />
|
||||
<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"
|
||||
>
|
||||
<PlusCircle size={14} />
|
||||
@@ -255,7 +292,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
|
||||
{!collapsed && <VersionBadge />}
|
||||
|
||||
<DesignModal
|
||||
key={designModal?.mode === 'edit' ? designModal.design?.id : 'create'}
|
||||
key={`${designModal?.mode === 'edit' ? designModal.design?.id : 'create'}-${openSeq}`}
|
||||
open={!!designModal}
|
||||
onClose={() => setDesignModal(null)}
|
||||
onSubmit={handleDesignSubmit}
|
||||
@@ -264,6 +301,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,44 @@ 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()
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
applyEdgeChanges,
|
||||
addEdge,
|
||||
} 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 { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils'
|
||||
import { applyOpacity } from '@/utils/colorUtils'
|
||||
@@ -35,6 +35,14 @@ interface CanvasState {
|
||||
// Live per-service status overlay (not persisted), keyed via serviceStatusKey.
|
||||
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
|
||||
past: HistoryEntry[]
|
||||
future: HistoryEntry[]
|
||||
@@ -98,6 +106,8 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
hideIp: readHideIp(),
|
||||
scanEventTs: 0,
|
||||
serviceStatuses: {},
|
||||
floorMap: null,
|
||||
floorMapEditNonce: 0,
|
||||
fitViewPending: false,
|
||||
|
||||
past: [],
|
||||
@@ -747,6 +757,16 @@ export const useCanvasStore = create<CanvasState>((set) => ({
|
||||
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) => {
|
||||
// React Flow requires parents before children in the array
|
||||
const parents = nodes.filter((n) => !n.parentId)
|
||||
|
||||
@@ -252,3 +252,19 @@ export interface CustomStyleDef {
|
||||
nodes: Partial<Record<NodeType, NodeTypeStyle>>
|
||||
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
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface StandaloneCanvas {
|
||||
edges: Edge<EdgeData>[]
|
||||
theme_id?: ThemeId
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user