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