From 1ed013bde2031baf6fb0bb692a9ab138074a7a6e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 2 Jul 2026 16:36:25 +0200 Subject: [PATCH] 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 /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 --- .env.example | 3 + backend/app/api/routes/media.py | 89 +++++++++++ backend/app/core/config.py | 11 ++ backend/app/main.py | 3 +- backend/tests/test_media.py | 94 +++++++++++ frontend/src/App.tsx | 44 ++---- frontend/src/api/client.ts | 11 ++ .../src/components/canvas/CanvasContainer.tsx | 2 +- .../src/components/canvas/FloorMapLayer.tsx | 107 +++++++------ .../src/components/modals/DesignModal.tsx | 148 +++++++++++++++++- .../src/components/modals/FloorMapModal.tsx | 138 ---------------- .../modals/__tests__/DesignModal.test.tsx | 104 ++++++++++++ frontend/src/components/panels/Sidebar.tsx | 33 +++- .../src/stores/__tests__/canvasStore.test.ts | 33 ++++ frontend/src/types/index.ts | 5 + frontend/src/utils/standaloneStorage.ts | 5 +- 16 files changed, 597 insertions(+), 233 deletions(-) create mode 100644 backend/app/api/routes/media.py create mode 100644 backend/tests/test_media.py delete mode 100644 frontend/src/components/modals/FloorMapModal.tsx diff --git a/.env.example b/.env.example index dce219f..9dbfdcf 100644 --- a/.env.example +++ b/.env.example @@ -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 /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"] diff --git a/backend/app/api/routes/media.py b/backend/app/api/routes/media.py new file mode 100644 index 0000000..324664d --- /dev/null +++ b/backend/app/api/routes/media.py @@ -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 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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index b9f4016..8dba247 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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: diff --git a/backend/app/main.py b/backend/app/main.py index 46172f9..4ca6f6d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/backend/tests/test_media.py b/backend/tests/test_media.py new file mode 100644 index 0000000..3741eb9 --- /dev/null +++ b/backend/tests/test_media.py @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a635774..857bb0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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('.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() { /> )} - setFloorMapModalOpen(false)} - onSubmit={handleFloorMapSubmit} - onRemove={handleFloorMapRemove} - initial={floorMap} - /> - setAddGroupRectOpen(false)} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a21755e..c063313 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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/.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), diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 3c28112..57741c7 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -200,7 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o size={1} color={theme.colors.canvasDotColor} /> - + diff --git a/frontend/src/components/canvas/FloorMapLayer.tsx b/frontend/src/components/canvas/FloorMapLayer.tsx index 31aad13..2e925e0 100644 --- a/frontend/src/components/canvas/FloorMapLayer.tsx +++ b/frontend/src/components/canvas/FloorMapLayer.tsx @@ -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(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 ( -
- Floor plan +
- {!locked && ( - <> -
onResizeStart(e, new Set(['n','w']))} /> -
onResizeStart(e, new Set(['n']))} /> -
onResizeStart(e, new Set(['n','e']))} /> -
onResizeStart(e, new Set(['e']))} /> -
onResizeStart(e, new Set(['s','e']))} /> -
onResizeStart(e, new Set(['s']))} /> -
onResizeStart(e, new Set(['s','w']))} /> -
onResizeStart(e, new Set(['w']))} /> - - )} -
+ onMouseDown={locked ? undefined : onDragStart} + > + Floor plan + {!locked && ( + <> +
onResizeStart(e, new Set(['n','w']))} /> +
onResizeStart(e, new Set(['n']))} /> +
onResizeStart(e, new Set(['n','e']))} /> +
onResizeStart(e, new Set(['e']))} /> +
onResizeStart(e, new Set(['s','e']))} /> +
onResizeStart(e, new Set(['s']))} /> +
onResizeStart(e, new Set(['s','w']))} /> +
onResizeStart(e, new Set(['w']))} /> + + )} +
+ ) } diff --git a/frontend/src/components/modals/DesignModal.tsx b/frontend/src/components/modals/DesignModal.tsx index d488470..3c290f3 100644 --- a/frontend/src/components/modals/DesignModal.tsx +++ b/frontend/src/components/modals/DesignModal.tsx @@ -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 } -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(null) + + const handleFile = async (e: React.ChangeEvent) => { + 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 ( !o && onClose()}> - + {title} @@ -75,6 +152,69 @@ export function DesignModal({ open, onClose, onSubmit, initial, title = 'New Can })}
+ + {showFloorMap && ( +
+ + {!hasImage ? ( +
fileRef.current?.click()} + > + + {uploading ? 'Uploading…' : 'Click to select a floor plan image'} + PNG, JPEG or WebP · max 10 MB +
+ ) : ( + <> +
+ Floor plan preview +
+ +
+ + + +
+ +
+
+ + setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" /> +
+
+ + setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" /> +
+
+ +
+ + setOpacity(Number(e.target.value))} + className="w-full accent-[#00d4ff]" + /> +
+ +
+ + +
+ + )} +
+ )}
diff --git a/frontend/src/components/modals/FloorMapModal.tsx b/frontend/src/components/modals/FloorMapModal.tsx deleted file mode 100644 index 0bf45ca..0000000 --- a/frontend/src/components/modals/FloorMapModal.tsx +++ /dev/null @@ -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(null) - - const handleFile = (e: React.ChangeEvent) => { - 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 ( - { if (!o) onClose() }}> - - - {initial ? 'Edit Floor Plan' : 'Import Floor Plan'} - - -
- {!hasImage ? ( -
fileRef.current?.click()} - > - - Click to select a floor plan image - PNG, JPEG or WebP -
- ) : ( - <> -
- Floor plan preview -
- -
- - -
- -
-
- - setWidth(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" /> -
-
- - setHeight(Math.max(80, Number(e.target.value)))} className="bg-[#21262d] border-[#30363d] text-xs h-8" /> -
-
- -
- - setOpacity(Number(e.target.value))} - className="w-full accent-[#00d4ff]" - /> -
- -
- - -
- - )} -
- - - {hasImage && initial && ( - - )} -
- - -
-
-
-
- ) -} diff --git a/frontend/src/components/modals/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx index 681c668..27c0976 100644 --- a/frontend/src/components/modals/__tests__/DesignModal.test.tsx +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -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 }) + }) + }) }) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 07c4807..da5765b 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -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 => { + 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 && } {!STANDALONE && } {!STANDALONE && } - ) diff --git a/frontend/src/stores/__tests__/canvasStore.test.ts b/frontend/src/stores/__tests__/canvasStore.test.ts index 9c69b99..1fa2e8a 100644 --- a/frontend/src/stores/__tests__/canvasStore.test.ts +++ b/frontend/src/stores/__tests__/canvasStore.test.ts @@ -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() + }) + }) }) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 94469fd..3520255 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -254,6 +254,11 @@ export interface CustomStyleDef { } export interface FloorMapConfig { + /** + * Server URL of the uploaded image (e.g. /api/v1/media/.png). + * Legacy canvases may still hold a base64 `data:` URL — both render in . + * Floor plans require a backend; they are disabled in standalone mode. + */ imageData: string posX: number posY: number diff --git a/frontend/src/utils/standaloneStorage.ts b/frontend/src/utils/standaloneStorage.ts index c07e945..d11619b 100644 --- a/frontend/src/utils/standaloneStorage.ts +++ b/frontend/src/utils/standaloneStorage.ts @@ -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[] 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(key: string): T | null {