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

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

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-02 16:36:25 +02:00
parent 046c99e219
commit 1ed013bde2
16 changed files with 597 additions and 233 deletions
+3
View File
@@ -1,6 +1,9 @@
# Backend - server-side only (NEVER commit .env) # Backend - server-side only (NEVER commit .env)
SECRET_KEY=change_me_in_production SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db SQLITE_PATH=./data/homelab.db
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
# which sits on the same persistent volume as the DB.
# UPLOAD_DIR=./data/uploads
# Set this to the URL(s) you use to access Homelable in your browser. # Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"] CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
+89
View File
@@ -0,0 +1,89 @@
"""Generic media upload/serve endpoint.
Images are stored on disk (see `Settings.media_dir`) with server-generated
UUID filenames — never in the DB, and the client filename is never trusted.
Upload/delete require auth; GET is public so plain <img> tags and the read-only
live view can load images (filenames are unguessable).
Currently used by the floor-plan feature; kept deliberately generic so future
raw-image uploads reuse the same endpoint.
"""
import re
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from app.api.deps import get_current_user
from app.core.config import settings
router = APIRouter()
# content-type → extension. Also the allowlist of accepted uploads.
ALLOWED_TYPES: dict[str, str] = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/webp": ".webp",
}
# Magic-byte signatures for defense-in-depth (don't trust content-type alone).
_MAGIC: dict[str, tuple[bytes, ...]] = {
".png": (b"\x89PNG\r\n\x1a\n",),
".jpg": (b"\xff\xd8\xff",),
".webp": (b"RIFF",), # RIFF....WEBP; RIFF prefix is enough to reject non-images
}
MAX_BYTES = 10 * 1024 * 1024 # 10 MB
# Only ever serve/delete files we created: 32 hex chars + known extension.
_NAME_RE = re.compile(r"^[0-9a-f]{32}\.(png|jpg|webp)$")
@router.post("/upload")
async def upload_media(file: UploadFile, _user: str = Depends(get_current_user)) -> dict[str, str]:
ext = ALLOWED_TYPES.get(file.content_type or "")
if ext is None:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="Unsupported media type — PNG, JPEG, or WebP only",
)
# Read one byte past the cap so we can detect oversize without loading more.
data = await file.read(MAX_BYTES + 1)
if len(data) > MAX_BYTES:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail="File too large (max 10 MB)",
)
if not data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Empty file")
if not any(data.startswith(sig) for sig in _MAGIC[ext]):
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="File content does not match its type",
)
media_dir = settings.media_dir()
media_dir.mkdir(parents=True, exist_ok=True)
name = f"{uuid.uuid4().hex}{ext}"
(media_dir / name).write_bytes(data)
return {"filename": name, "url": f"/api/v1/media/{name}"}
@router.get("/{filename}")
async def get_media(filename: str) -> FileResponse:
if not _NAME_RE.match(filename):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
path = settings.media_dir() / filename
if not path.is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return FileResponse(path)
@router.delete("/{filename}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_media(filename: str, _user: str = Depends(get_current_user)) -> None:
if not _NAME_RE.match(filename):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
path = settings.media_dir() / filename
if path.is_file():
path.unlink()
+11
View File
@@ -24,6 +24,10 @@ class Settings(BaseSettings):
secret_key: str # Required — set SECRET_KEY in .env secret_key: str # Required — set SECRET_KEY in .env
sqlite_path: str = "./data/homelab.db" sqlite_path: str = "./data/homelab.db"
# Uploaded media (floor plans, and future raw image uploads) live on disk,
# not in the DB. Defaults to a `uploads/` folder next to the SQLite DB so it
# sits on the same persistent Docker volume. Override with UPLOAD_DIR.
upload_dir: str = ""
cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"] cors_origins: list[str] = ["http://localhost:5173", "http://localhost:3000"]
# JWT # JWT
@@ -79,6 +83,13 @@ class Settings(BaseSettings):
def _override_path(self) -> Path: def _override_path(self) -> Path:
return Path(self.sqlite_path).parent / "scan_config.json" return Path(self.sqlite_path).parent / "scan_config.json"
def media_dir(self) -> Path:
"""On-disk folder for uploaded media. Sits on the same persistent
volume as the SQLite DB unless UPLOAD_DIR is set."""
if self.upload_dir:
return Path(self.upload_dir)
return Path(self.sqlite_path).parent / "uploads"
def load_overrides(self) -> None: def load_overrides(self) -> None:
"""Load runtime scan config overrides written by the API.""" """Load runtime scan config overrides written by the API."""
try: try:
+2 -1
View File
@@ -7,7 +7,7 @@ from typing import Any
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, canvas, designs, edges, liveview, nodes, scan, stats, status, zigbee, zwave from app.api.routes import auth, canvas, designs, edges, liveview, media, nodes, scan, stats, status, zigbee, zwave
from app.api.routes import settings as settings_routes from app.api.routes import settings as settings_routes
from app.core.config import settings from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler from app.core.scheduler import start_scheduler, stop_scheduler
@@ -59,6 +59,7 @@ app.include_router(liveview.router, prefix="/api/v1/liveview", tags=["liveview"]
app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"]) app.include_router(zigbee.router, prefix="/api/v1/zigbee", tags=["zigbee"])
app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"]) app.include_router(zwave.router, prefix="/api/v1/zwave", tags=["zwave"])
app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"]) app.include_router(stats.router, prefix="/api/v1/stats", tags=["stats"])
app.include_router(media.router, prefix="/api/v1/media", tags=["media"])
@app.get("/api/v1/health") @app.get("/api/v1/health")
+94
View File
@@ -0,0 +1,94 @@
import re
import pytest
from app.api.routes import media
from app.core.config import settings
PNG_BYTES = b"\x89PNG\r\n\x1a\n" + b"0" * 32
@pytest.fixture
def media_dir(tmp_path, monkeypatch):
"""Point uploads at a temp folder for the duration of a test."""
monkeypatch.setattr(settings, "upload_dir", str(tmp_path))
return tmp_path
async def _upload(client, headers, name="plan.png", data=PNG_BYTES, content_type="image/png"):
return await client.post(
"/api/v1/media/upload",
files={"file": (name, data, content_type)},
headers=headers,
)
@pytest.mark.asyncio
async def test_upload_requires_auth(client, media_dir):
res = await _upload(client, headers={})
assert res.status_code == 401
@pytest.mark.asyncio
async def test_upload_stores_file_and_returns_url(client, auth_headers, media_dir):
headers = await auth_headers()
res = await _upload(client, headers)
assert res.status_code == 200
body = res.json()
assert re.fullmatch(r"/api/v1/media/[0-9a-f]{32}\.png", body["url"])
# File written to disk with the server-generated name (client name ignored).
assert (media_dir / body["filename"]).read_bytes() == PNG_BYTES
assert body["filename"] != "plan.png"
@pytest.mark.asyncio
async def test_upload_rejects_unsupported_type(client, auth_headers, media_dir):
headers = await auth_headers()
res = await _upload(client, headers, name="a.txt", data=b"hello", content_type="text/plain")
assert res.status_code == 415
@pytest.mark.asyncio
async def test_upload_rejects_content_type_magic_mismatch(client, auth_headers, media_dir):
headers = await auth_headers()
# Claims PNG but bytes are not a PNG.
res = await _upload(client, headers, data=b"not-a-real-png", content_type="image/png")
assert res.status_code == 415
@pytest.mark.asyncio
async def test_upload_rejects_oversize(client, auth_headers, media_dir, monkeypatch):
monkeypatch.setattr(media, "MAX_BYTES", 8)
headers = await auth_headers()
res = await _upload(client, headers, data=PNG_BYTES) # > 8 bytes
assert res.status_code == 413
@pytest.mark.asyncio
async def test_get_serves_uploaded_file(client, auth_headers, media_dir):
headers = await auth_headers()
up = await _upload(client, headers)
res = await client.get(up.json()["url"]) # public, no auth
assert res.status_code == 200
assert res.content == PNG_BYTES
@pytest.mark.asyncio
async def test_get_rejects_bad_filename(client, media_dir):
res = await client.get("/api/v1/media/..%2f..%2fetc%2fpasswd")
assert res.status_code == 404
@pytest.mark.asyncio
async def test_delete_requires_auth_and_removes_file(client, auth_headers, media_dir):
headers = await auth_headers()
up = await _upload(client, headers)
filename = up.json()["filename"]
assert (await client.delete(f"/api/v1/media/{filename}")).status_code == 401
assert (media_dir / filename).exists()
res = await client.delete(f"/api/v1/media/{filename}", headers=headers)
assert res.status_code == 204
assert not (media_dir / filename).exists()
assert (await client.get(up.json()["url"])).status_code == 404
+10 -34
View File
@@ -26,7 +26,6 @@ import { SettingsModal } from '@/components/modals/SettingsModal'
import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal' import { ZigbeeImportModal } from '@/components/zigbee/ZigbeeImportModal'
import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal' import { ZwaveImportModal } from '@/components/zwave/ZwaveImportModal'
import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal' import { GroupRectModal, type GroupRectFormData } from '@/components/modals/GroupRectModal'
import { FloorMapModal } from '@/components/modals/FloorMapModal'
import { TextModal, type TextFormData } from '@/components/modals/TextModal' import { TextModal, type TextFormData } from '@/components/modals/TextModal'
import { ThemeModal } from '@/components/modals/ThemeModal' import { ThemeModal } from '@/components/modals/ThemeModal'
import { SearchModal } from '@/components/modals/SearchModal' import { SearchModal } from '@/components/modals/SearchModal'
@@ -83,7 +82,6 @@ export default function App() {
const [exportModalOpen, setExportModalOpen] = useState(false) const [exportModalOpen, setExportModalOpen] = useState(false)
const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false) const [zigbeeImportOpen, setZigbeeImportOpen] = useState(false)
const [zwaveImportOpen, setZwaveImportOpen] = useState(false) const [zwaveImportOpen, setZwaveImportOpen] = useState(false)
const [floorMapModalOpen, setFloorMapModalOpen] = useState(false)
// Declare handleSave before the Ctrl+S effect so it is in scope. // Declare handleSave before the Ctrl+S effect so it is in scope.
// Returns true on success, false on failure — the design-switch effect relies // Returns true on success, false on failure — the design-switch effect relies
@@ -93,7 +91,8 @@ export default function App() {
const saveDesignId = designIdOverride ?? activeDesignId const saveDesignId = designIdOverride ?? activeDesignId
if (STANDALONE) { if (STANDALONE) {
if (!saveDesignId) return false if (!saveDesignId) return false
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle, floorMap: floorMap ?? undefined }) // Floor plans are backend-only (upload/serve), so standalone never persists one.
standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle })
markSaved() markSaved()
toast.success('Canvas saved') toast.success('Canvas saved')
return true return true
@@ -132,12 +131,16 @@ export default function App() {
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 const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined
if (savedFloorMap) setFloorMap(savedFloorMap) // Clear when the target design has no floor plan, so it doesn't bleed
// across canvases when switching designs.
setFloorMap(savedFloorMap ?? null)
loadCanvas(rfNodes, rfEdges) 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, setFloorMap]) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
@@ -149,9 +152,11 @@ 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)
if (saved.floorMap) setFloorMap(saved.floorMap) // Floor plans are backend-only; keep the store clear in standalone mode.
setFloorMap(null)
loadCanvas(saved.nodes, saved.edges) loadCanvas(saved.nodes, saved.edges)
} else { } else {
setFloorMap(null)
loadCanvas(demoNodes, demoEdges) loadCanvas(demoNodes, demoEdges)
} }
}, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) }, [loadCanvas, setTheme, setCustomStyle, setFloorMap])
@@ -525,25 +530,6 @@ export default function App() {
} }
}, [activeDesignId]) }, [activeDesignId])
const handleFloorMapSubmit = useCallback((config: FloorMapConfig) => {
snapshotHistory()
if (floorMap) {
// Preserve position from existing config when editing
setFloorMap({ ...floorMap, ...config })
} else {
setFloorMap(config)
}
setFloorMapModalOpen(false)
toast.success(floorMap ? 'Floor plan updated' : 'Floor plan imported')
}, [floorMap, setFloorMap, snapshotHistory])
const handleFloorMapRemove = useCallback(() => {
snapshotHistory()
setFloorMap(null)
setFloorMapModalOpen(false)
toast.success('Floor plan removed')
}, [setFloorMap, snapshotHistory])
const handleExport = useCallback(() => { const handleExport = useCallback(() => {
const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow') const el = canvasRef.current?.querySelector<HTMLElement>('.react-flow')
if (!el) { toast.error('Canvas not ready'); return } if (!el) { toast.error('Canvas not ready'); return }
@@ -723,7 +709,6 @@ export default function App() {
onScan={() => setScanConfigOpen(true)} onScan={() => setScanConfigOpen(true)}
onZigbeeImport={() => setZigbeeImportOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)}
onZwaveImport={() => setZwaveImportOpen(true)} onZwaveImport={() => setZwaveImportOpen(true)}
onFloorMap={() => setFloorMapModalOpen(true)}
onSave={handleSave} onSave={handleSave}
onOpenSettings={() => setSettingsOpen(true)} onOpenSettings={() => setSettingsOpen(true)}
onOpenHistory={() => setScanHistoryOpen(true)} onOpenHistory={() => setScanHistoryOpen(true)}
@@ -860,15 +845,6 @@ export default function App() {
/> />
)} )}
<FloorMapModal
key={floorMapModalOpen ? 'floor-map-open' : 'floor-map-closed'}
open={floorMapModalOpen}
onClose={() => setFloorMapModalOpen(false)}
onSubmit={handleFloorMapSubmit}
onRemove={handleFloorMapRemove}
initial={floorMap}
/>
<GroupRectModal <GroupRectModal
open={addGroupRectOpen} open={addGroupRectOpen}
onClose={() => setAddGroupRectOpen(false)} onClose={() => setAddGroupRectOpen(false)}
+11
View File
@@ -41,6 +41,17 @@ export const canvasApi = {
}) => api.post('/canvas/save', payload), }) => api.post('/canvas/save', payload),
} }
export const mediaApi = {
/** Upload an image, returns its server URL (e.g. /api/v1/media/<uuid>.png). */
upload: async (file: File): Promise<{ url: string; filename: string }> => {
const form = new FormData()
form.append('file', file)
const res = await api.post<{ url: string; filename: string }>('/media/upload', form)
return res.data
},
delete: (filename: string) => api.delete(`/media/${filename}`),
}
export const nodesApi = { export const nodesApi = {
create: (data: object) => api.post('/nodes', data), create: (data: object) => api.post('/nodes', data),
update: (id: string, data: object) => api.patch(`/nodes/${id}`, data), update: (id: string, data: object) => api.patch(`/nodes/${id}`, data),
@@ -200,7 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
size={1} size={1}
color={theme.colors.canvasDotColor} color={theme.colors.canvasDotColor}
/> />
<FloorMapLayer screenToFlowPosition={screenToFlowPosition} /> <FloorMapLayer />
<SearchBar onOpenPending={onOpenPending} /> <SearchBar onOpenPending={onOpenPending} />
<AlignmentGuides guides={guides} /> <AlignmentGuides guides={guides} />
<Controls> <Controls>
@@ -1,10 +1,7 @@
import { useCallback, useRef } from 'react' import { useCallback, useRef } from 'react'
import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
interface FloorMapLayerProps {
screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number }
}
interface ResizeState { interface ResizeState {
startMouseX: number startMouseX: number
startMouseY: number startMouseY: number
@@ -15,9 +12,19 @@ interface ResizeState {
edges: Set<'n' | 's' | 'e' | 'w'> edges: Set<'n' | 's' | 'e' | 'w'>
} }
export function FloorMapLayer({ screenToFlowPosition }: FloorMapLayerProps) { /**
* Floor plan background rendered INSIDE the React Flow viewport (via
* ViewportPortal) so it pans and zooms together with the nodes. Position and
* size are stored in flow coordinates.
*
* When unlocked it sits above the nodes so it can be grabbed/resized; when
* locked it drops behind everything to act as a static background.
*/
export function FloorMapLayer() {
const floorMap = useCanvasStore((s) => s.floorMap) const floorMap = useCanvasStore((s) => s.floorMap)
const updateFloorMap = useCanvasStore((s) => s.updateFloorMap) const updateFloorMap = useCanvasStore((s) => s.updateFloorMap)
const { screenToFlowPosition } = useReactFlow()
const zoom = useStore((s) => s.transform[2])
const resizeRef = useRef<ResizeState | null>(null) const resizeRef = useRef<ResizeState | null>(null)
@@ -91,55 +98,63 @@ export function FloorMapLayer({ screenToFlowPosition }: FloorMapLayerProps) {
const { imageData, posX, posY, width, height, opacity, locked } = floorMap const { imageData, posX, posY, width, height, opacity, locked } = floorMap
// Handles live in flow space, so counter-scale by zoom to keep a ~constant
// on-screen size regardless of the current zoom level.
const hsz = 10 / zoom
const half = hsz / 2
const hs: React.CSSProperties = { const hs: React.CSSProperties = {
position: 'absolute', position: 'absolute',
width: 10, width: hsz,
height: 10, height: hsz,
background: '#00d4ff', background: '#00d4ff',
border: '2px solid #0d1117', border: `${2 / zoom}px solid #0d1117`,
borderRadius: 2, borderRadius: 2 / zoom,
zIndex: 10, zIndex: 10,
} }
return ( return (
<div <ViewportPortal>
style={{ <div
position: 'absolute',
left: posX,
top: posY,
width,
height,
opacity,
zIndex: -1,
pointerEvents: locked ? 'none' : 'auto',
cursor: locked ? 'default' : 'move',
}}
onMouseDown={locked ? undefined : onDragStart}
>
<img
src={imageData}
alt="Floor plan"
draggable={false}
style={{ style={{
width: '100%', position: 'absolute',
height: '100%', left: posX,
objectFit: 'fill', top: posY,
pointerEvents: 'none', width,
userSelect: 'none', height,
opacity,
// Unlocked → above nodes so it can be grabbed. Locked → behind
// everything as a static background.
zIndex: locked ? -1 : 4,
pointerEvents: locked ? 'none' : 'auto',
cursor: locked ? 'default' : 'move',
}} }}
/> onMouseDown={locked ? undefined : onDragStart}
{!locked && ( >
<> <img
<div style={{ ...hs, cursor: 'nw-resize', top: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} /> src={imageData}
<div style={{ ...hs, cursor: 'n-resize', top: -5, left: '50%', marginLeft: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} /> alt="Floor plan"
<div style={{ ...hs, cursor: 'ne-resize', top: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['n','e']))} /> draggable={false}
<div style={{ ...hs, cursor: 'e-resize', top: '50%', marginTop: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['e']))} /> style={{
<div style={{ ...hs, cursor: 'se-resize', bottom: -5, right: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s','e']))} /> width: '100%',
<div style={{ ...hs, cursor: 's-resize', bottom: -5, left: '50%', marginLeft: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s']))} /> height: '100%',
<div style={{ ...hs, cursor: 'sw-resize', bottom: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['s','w']))} /> objectFit: 'fill',
<div style={{ ...hs, cursor: 'w-resize', top: '50%', marginTop: -5, left: -5 }} onMouseDown={(e) => onResizeStart(e, new Set(['w']))} /> pointerEvents: 'none',
</> userSelect: 'none',
)} }}
</div> />
{!locked && (
<>
<div style={{ ...hs, cursor: 'nw-resize', top: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','w']))} />
<div style={{ ...hs, cursor: 'n-resize', top: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n']))} />
<div style={{ ...hs, cursor: 'ne-resize', top: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['n','e']))} />
<div style={{ ...hs, cursor: 'e-resize', top: '50%', marginTop: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['e']))} />
<div style={{ ...hs, cursor: 'se-resize', bottom: -half, right: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','e']))} />
<div style={{ ...hs, cursor: 's-resize', bottom: -half, left: '50%', marginLeft: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s']))} />
<div style={{ ...hs, cursor: 'sw-resize', bottom: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['s','w']))} />
<div style={{ ...hs, cursor: 'w-resize', top: '50%', marginTop: -half, left: -half }} onMouseDown={(e) => onResizeStart(e, new Set(['w']))} />
</>
)}
</div>
</ViewportPortal>
) )
} }
+144 -4
View File
@@ -1,13 +1,20 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog' import { 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>
@@ -1,138 +0,0 @@
import { useState, useRef } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import type { FloorMapConfig } from '@/types'
interface FloorMapModalProps {
open: boolean
onClose: () => void
onSubmit: (config: FloorMapConfig) => void
onRemove: () => void
initial: FloorMapConfig | null
}
export function FloorMapModal({ open, onClose, onSubmit, onRemove, initial }: FloorMapModalProps) {
const [imageData, setImageData] = useState(initial?.imageData ?? '')
const [width, setWidth] = useState(initial?.width ?? 800)
const [height, setHeight] = useState(initial?.height ?? 600)
const [opacity, setOpacity] = useState(initial?.opacity ?? 0.8)
const [locked, setLocked] = useState(initial?.locked ?? false)
const [enabled, setEnabled] = useState(initial?.enabled ?? true)
const fileRef = useRef<HTMLInputElement>(null)
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
const dataUrl = ev.target?.result as string
setImageData(dataUrl)
const img = new Image()
img.onload = () => {
setWidth(img.naturalWidth)
setHeight(img.naturalHeight)
}
img.src = dataUrl
}
reader.readAsDataURL(file)
}
const handleSubmit = () => {
if (!imageData) return
onSubmit({
imageData,
posX: initial?.posX ?? 0,
posY: initial?.posY ?? 0,
width,
height,
opacity,
locked,
enabled,
})
}
const hasImage = !!imageData
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose() }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{initial ? 'Edit Floor Plan' : 'Import Floor Plan'}</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
{!hasImage ? (
<div
className="flex flex-col items-center justify-center gap-2 border-2 border-dashed border-[#30363d] rounded-lg p-8 cursor-pointer hover:border-[#00d4ff]/50 transition-colors"
onClick={() => fileRef.current?.click()}
>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="hidden" onChange={handleFile} />
<span className="text-muted-foreground text-sm">Click to select a floor plan image</span>
<span className="text-muted-foreground/50 text-xs">PNG, JPEG or WebP</span>
</div>
) : (
<>
<div className="relative rounded-lg overflow-hidden border border-[#30363d]" style={{ maxHeight: 200 }}>
<img src={imageData} alt="Floor plan preview" className="w-full h-full object-contain" style={{ opacity }} />
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" className="cursor-pointer" onClick={() => fileRef.current?.click()}>
Replace Image
</Button>
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image.webp" className="hidden" onChange={handleFile} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Width (px)</Label>
<Input type="number" value={width} onChange={(e) => setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Height (px)</Label>
<Input type="number" value={height} onChange={(e) => setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" />
</div>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">Opacity: {Math.round(opacity * 100)}%</Label>
<input
type="range" min="0.05" max="1" step="0.05" value={opacity}
onChange={(e) => setOpacity(Number(e.target.value))}
className="w-full accent-[#00d4ff]"
/>
</div>
<div className="flex items-center gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
<span className="text-xs text-muted-foreground">Lock position & size</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="accent-[#00d4ff] w-3.5 h-3.5" />
<span className="text-xs text-muted-foreground">Show on canvas</span>
</label>
</div>
</>
)}
</div>
<DialogFooter className="flex items-center justify-between sm:justify-between">
{hasImage && initial && (
<Button size="sm" variant="destructive" className="cursor-pointer" onClick={onRemove}>
Remove
</Button>
)}
<div className="flex gap-2 ml-auto">
<Button size="sm" variant="ghost" className="cursor-pointer" onClick={onClose}>Cancel</Button>
<Button size="sm" className="bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 cursor-pointer" disabled={!hasImage} onClick={handleSubmit}>
{initial ? 'Apply' : 'Import'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -64,4 +64,108 @@ describe('DesignModal', () => {
expect(onClose).toHaveBeenCalled() expect(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()
})
it('submits floorMap: null when shown but no image was chosen', () => {
const { onSubmit } = renderModal({
showFloorMap: true,
initialFloorMap: null,
submitLabel: 'Save',
})
fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Empty' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(onSubmit).toHaveBeenCalledWith({ name: 'Empty', icon: DEFAULT_DESIGN_ICON, floorMap: null })
})
})
}) })
+26 -7
View File
@@ -1,11 +1,11 @@
import { useState, useCallback } from 'react' import { useState, useCallback } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2, Image } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react'
import { Logo } from '@/components/ui/Logo' import { 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'
@@ -27,19 +27,19 @@ interface SidebarProps {
onScan: () => void onScan: () => void
onZigbeeImport: () => void onZigbeeImport: () => void
onZwaveImport: () => void onZwaveImport: () => void
onFloorMap: () => void
onSave: () => void onSave: () => void
onOpenSettings: () => void onOpenSettings: () => void
onOpenHistory: () => void onOpenHistory: () => void
onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void onOpenPending: (deviceId?: string, status?: 'pending' | 'hidden') => void
} }
export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onFloorMap, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) {
const [collapsed, setCollapsed] = useState(false) const [collapsed, setCollapsed] = useState(false)
const logout = useAuthStore((s) => s.logout) const logout = useAuthStore((s) => s.logout)
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)
const { nodes, hasUnsavedChanges, floorMap, setFloorMap, snapshotHistory } = useCanvasStore()
const handleDesignSubmit = useCallback(async (data: DesignFormData) => { const handleDesignSubmit = useCallback(async (data: DesignFormData) => {
if (!designModal) return if (!designModal) return
@@ -55,11 +55,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
: (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data : (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.
if (data.floorMap !== undefined) {
snapshotHistory()
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, snapshotHistory])
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 }
@@ -77,7 +84,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
} }
}, [designs.length, removeDesign]) }, [designs.length, removeDesign])
const { nodes, hasUnsavedChanges, floorMap } = useCanvasStore() const handleUploadImage = useCallback(async (file: File): Promise<string> => {
try {
const { url } = await mediaApi.upload(file)
return url
} catch {
toast.error('Image upload failed')
throw new Error('upload failed')
}
}, [])
const isActiveEdit = designModal?.mode === 'edit' && designModal.design?.id === activeDesignId
const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const 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
@@ -229,7 +246,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
{!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />} {!STANDALONE && <SidebarItem icon={ScanLine} label="Scan Network" collapsed={collapsed} onClick={handleScan} />}
{!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />} {!STANDALONE && <SidebarItem icon={Network} label="Zigbee Import" collapsed={collapsed} onClick={onZigbeeImport} />}
{!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />} {!STANDALONE && <SidebarItem icon={RadioTower} label="Z-Wave Import" collapsed={collapsed} onClick={onZwaveImport} />}
<SidebarItem icon={Image} label={floorMap ? 'Edit Floor Plan' : 'Add Floor Plan'} collapsed={collapsed} onClick={onFloorMap} />
<SidebarItem <SidebarItem
icon={Save} icon={Save}
label="Save Canvas" label="Save Canvas"
@@ -266,6 +282,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,37 @@ 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()
})
})
}) })
+5
View File
@@ -254,6 +254,11 @@ export interface CustomStyleDef {
} }
export interface FloorMapConfig { 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 imageData: string
posX: number posX: number
posY: number posY: number
+3 -2
View File
@@ -11,7 +11,7 @@
* default design on first run so existing users keep their canvas. * default design on first run so existing users keep their canvas.
*/ */
import type { Node, Edge } from '@xyflow/react' import type { Node, Edge } from '@xyflow/react'
import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types' import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef } from '@/types'
import type { ThemeId } from '@/utils/themes' import type { ThemeId } from '@/utils/themes'
import { generateUUID } from '@/utils/uuid' import { generateUUID } from '@/utils/uuid'
@@ -24,7 +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
floorMap?: FloorMapConfig // NOTE: no floor plan here — floor plans need a backend to upload/serve the
// image, so they are disabled in standalone mode (see homelable/CLAUDE.md ADR).
} }
function readJSON<T>(key: string): T | null { function readJSON<T>(key: string): T | null {