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)
|
# 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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
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
@@ -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 |
@@ -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 * 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) {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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 })
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user