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/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg b/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg new file mode 100644 index 0000000..792150e Binary files /dev/null and b/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg differ 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 0f833c2..857bb0f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -41,14 +41,14 @@ import { canvasApi, designsApi, liveviewApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { demoNodes, demoEdges } from '@/utils/demoData' import { useStatusPolling } from '@/hooks/useStatusPolling' -import type { NodeData, EdgeData, CustomStyleDef } from '@/types' +import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types' import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types' import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types' const STANDALONE = import.meta.env.VITE_STANDALONE === 'true' export default function App() { - const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer } = useCanvasStore() + const { loadCanvas, markSaved, markUnsaved, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore() const canvasRef = useRef(null) const { isAuthenticated } = useAuthStore() const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore() @@ -91,6 +91,7 @@ export default function App() { const saveDesignId = designIdOverride ?? activeDesignId if (STANDALONE) { if (!saveDesignId) return false + // Floor plans are backend-only (upload/serve), so standalone never persists one. standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle }) markSaved() toast.success('Canvas saved') @@ -98,7 +99,9 @@ export default function App() { } const nodesToSave = nodes.map(serializeNode) const edgesToSave = edges.map(serializeEdge) - await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport: { theme_id: activeTheme }, custom_style: customStyle, design_id: saveDesignId }) + const viewport: Record = { theme_id: activeTheme } + if (floorMap) viewport.floor_map = floorMap + await canvasApi.save({ nodes: nodesToSave, edges: edgesToSave, viewport, custom_style: customStyle, design_id: saveDesignId }) markSaved() toast.success('Canvas saved') return true @@ -106,7 +109,7 @@ export default function App() { toast.error('Save failed') return false } - }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId]) + }, [nodes, edges, markSaved, activeTheme, customStyle, activeDesignId, floorMap]) // Keep a ref so the keydown handler always calls the latest version const handleSaveRef = useRef(handleSave) @@ -127,14 +130,20 @@ export default function App() { const savedTheme = res.data.viewport?.theme_id if (savedTheme) setTheme(savedTheme) if (res.data.custom_style) setCustomStyle(res.data.custom_style as CustomStyleDef) + const savedFloorMap = res.data.viewport?.floor_map as FloorMapConfig | undefined + // Clear when the target design has no floor plan, so it doesn't bleed + // across canvases when switching designs. + setFloorMap(savedFloorMap ?? null) loadCanvas(rfNodes, rfEdges) } else { + setFloorMap(null) loadCanvas(demoNodes, demoEdges) } } catch { + setFloorMap(null) loadCanvas(demoNodes, demoEdges) } - }, [loadCanvas, setTheme, setCustomStyle]) + }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) // Standalone counterpart of loadCanvasFromApi — reads a design's canvas from // localStorage, falling back to the demo canvas when it has never been saved. @@ -143,11 +152,14 @@ export default function App() { if (saved && saved.nodes.length > 0) { if (saved.theme_id) setTheme(saved.theme_id) if (saved.custom_style) setCustomStyle(saved.custom_style) + // Floor plans are backend-only; keep the store clear in standalone mode. + setFloorMap(null) loadCanvas(saved.nodes, saved.edges) } else { + setFloorMap(null) loadCanvas(demoNodes, demoEdges) } - }, [loadCanvas, setTheme, setCustomStyle]) + }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) const loadDesignsAndCanvas = useCallback(async () => { if (STANDALONE) { 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 19082c0..57741c7 100644 --- a/frontend/src/components/canvas/CanvasContainer.tsx +++ b/frontend/src/components/canvas/CanvasContainer.tsx @@ -22,6 +22,7 @@ import { nodeTypes } from './nodes/nodeTypes' import { edgeTypes } from './edges/edgeTypes' import { SearchBar } from './SearchBar' import { AlignmentGuides } from './AlignmentGuides' +import { FloorMapLayer } from './FloorMapLayer' import { useAlignmentGuides } from '@/hooks/useAlignmentGuides' import { setViewportCenterProjector } from '@/utils/viewportCenter' import type { NodeData, EdgeData } from '@/types' @@ -199,6 +200,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o size={1} color={theme.colors.canvasDotColor} /> + diff --git a/frontend/src/components/canvas/FloorMapLayer.tsx b/frontend/src/components/canvas/FloorMapLayer.tsx new file mode 100644 index 0000000..26daf2a --- /dev/null +++ b/frontend/src/components/canvas/FloorMapLayer.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react' +import { useCanvasStore } from '@/stores/canvasStore' + +interface ResizeState { + startMouseX: number + startMouseY: number + startX: number + startY: number + startW: number + startH: number + edges: Set<'n' | 's' | 'e' | 'w'> +} + +/** + * Floor plan background rendered INSIDE the React Flow viewport (via + * ViewportPortal) so it pans and zooms together with the nodes. Position and + * size are stored in flow coordinates. + * + * It always sits at the bottom of the canvas (behind nodes and edges). When + * unlocked it can still be grabbed/resized in areas not covered by a node; + * resize handles appear only while it is selected. Double-clicking an unlocked + * plan opens its edit modal. + */ +export function FloorMapLayer() { + const floorMap = useCanvasStore((s) => s.floorMap) + const updateFloorMap = useCanvasStore((s) => s.updateFloorMap) + const requestFloorMapEdit = useCanvasStore((s) => s.requestFloorMapEdit) + const { screenToFlowPosition } = useReactFlow() + const zoom = useStore((s) => s.transform[2]) + + const resizeRef = useRef(null) + const wrapperRef = useRef(null) + const [selected, setSelected] = useState(false) + + const locked = floorMap?.locked ?? false + + // While selected (and unlocked), deselect on any click outside the plan. + // A locked plan can't be selected, and handles/edit are gated on !locked, so + // a residual selection is harmless. + useEffect(() => { + if (locked || !selected) return + const onDocDown = (ev: MouseEvent) => { + if (!wrapperRef.current?.contains(ev.target as Node)) setSelected(false) + } + document.addEventListener('mousedown', onDocDown) + return () => document.removeEventListener('mousedown', onDocDown) + }, [selected, locked]) + + const onDragStart = useCallback((e: React.MouseEvent) => { + if (!floorMap) return + e.stopPropagation() + setSelected(true) + const startX = e.clientX + const startY = e.clientY + const origPosX = floorMap.posX + const origPosY = floorMap.posY + + const onMove = (ev: MouseEvent) => { + const start = screenToFlowPosition({ x: startX, y: startY }) + const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY }) + updateFloorMap({ posX: origPosX + (cur.x - start.x), posY: origPosY + (cur.y - start.y) }) + } + const onUp = () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [floorMap, updateFloorMap, screenToFlowPosition]) + + const onResizeStart = useCallback((e: React.MouseEvent, edges: Set<'n' | 's' | 'e' | 'w'>) => { + if (!floorMap) return + e.stopPropagation() + resizeRef.current = { + startMouseX: e.clientX, + startMouseY: e.clientY, + startX: floorMap.posX, + startY: floorMap.posY, + startW: floorMap.width, + startH: floorMap.height, + edges, + } + + const onMove = (ev: MouseEvent) => { + const rs = resizeRef.current + if (!rs) return + const start = screenToFlowPosition({ x: rs.startMouseX, y: rs.startMouseY }) + const cur = screenToFlowPosition({ x: ev.clientX, y: ev.clientY }) + const dx = cur.x - start.x + const dy = cur.y - start.y + let x = rs.startX, y = rs.startY, w = rs.startW, h = rs.startH + if (rs.edges.has('w')) { x += dx; w -= dx } + if (rs.edges.has('e')) w += dx + if (rs.edges.has('n')) { y += dy; h -= dy } + if (rs.edges.has('s')) h += dy + const MIN = 80 + if (w < MIN) { + if (rs.edges.has('w')) x = rs.startX + rs.startW - MIN + w = MIN + } + if (h < MIN) { + if (rs.edges.has('n')) y = rs.startY + rs.startH - MIN + h = MIN + } + updateFloorMap({ posX: x, posY: y, width: w, height: h }) + } + const onUp = () => { + resizeRef.current = null + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }, [floorMap, updateFloorMap, screenToFlowPosition]) + + if (!floorMap || !floorMap.enabled) return null + + const { imageData, posX, posY, width, height, opacity } = floorMap + + // Handles live in flow space, so counter-scale by zoom to keep a ~constant + // on-screen size regardless of the current zoom level. + const hsz = 10 / zoom + const half = hsz / 2 + const hs: React.CSSProperties = { + position: 'absolute', + width: hsz, + height: hsz, + background: '#00d4ff', + border: `${2 / zoom}px solid #0d1117`, + borderRadius: 2 / zoom, + zIndex: 10, + } + + return ( + +
{ e.stopPropagation(); requestFloorMapEdit() }} + > + Floor plan + {!locked && selected && ( + <> +
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/canvas/__tests__/FloorMapLayer.test.tsx b/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx new file mode 100644 index 0000000..cce0973 --- /dev/null +++ b/frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { FloorMapLayer } from '../FloorMapLayer' +import { useCanvasStore } from '@/stores/canvasStore' +import type { FloorMapConfig } from '@/types' + +// Stub React Flow: render the portal inline, and give the layer a 1x zoom and +// an identity screen→flow projection so it can mount without a provider. +vi.mock('@xyflow/react', () => ({ + ViewportPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, + useReactFlow: () => ({ screenToFlowPosition: (p: { x: number; y: number }) => p }), + useStore: (sel: (s: { transform: number[] }) => unknown) => sel({ transform: [0, 0, 1] }), +})) + +const BASE: FloorMapConfig = { + imageData: '/api/v1/media/abc.png', + posX: 0, posY: 0, width: 800, height: 600, + opacity: 0.8, locked: false, enabled: true, +} + +function setFloorMap(patch: Partial = {}) { + useCanvasStore.setState({ floorMap: { ...BASE, ...patch }, floorMapEditNonce: 0 }) +} + +function wrapper() { + return screen.getByAltText('Floor plan').parentElement as HTMLElement +} + +function handleCount(root: HTMLElement) { + return Array.from(root.querySelectorAll('div')).filter((d) => + (d.getAttribute('style') ?? '').includes('resize'), + ).length +} + +describe('FloorMapLayer', () => { + beforeEach(() => useCanvasStore.setState({ floorMap: null, floorMapEditNonce: 0 })) + + it('renders nothing when there is no plan or it is disabled', () => { + const { container, rerender } = render() + expect(container.querySelector('img')).toBeNull() + setFloorMap({ enabled: false }) + rerender() + expect(container.querySelector('img')).toBeNull() + }) + + it('hides resize handles until the plan is selected, then shows them (unlocked)', () => { + setFloorMap() + render() + expect(handleCount(wrapper())).toBe(0) + + fireEvent.mouseDown(wrapper()) + expect(handleCount(wrapper())).toBe(8) + }) + + it('never shows handles and is non-interactive when locked', () => { + setFloorMap({ locked: true }) + render() + const w = wrapper() + fireEvent.mouseDown(w) + expect(handleCount(w)).toBe(0) + expect(w.style.pointerEvents).toBe('none') + }) + + it('double-click on an unlocked plan requests the edit modal', () => { + setFloorMap() + render() + fireEvent.doubleClick(wrapper()) + expect(useCanvasStore.getState().floorMapEditNonce).toBe(1) + }) + + it('locked plan ignores double-click', () => { + setFloorMap({ locked: true }) + render() + fireEvent.doubleClick(wrapper()) + expect(useCanvasStore.getState().floorMapEditNonce).toBe(0) + }) + + it('sits at the bottom of the canvas (negative z-index)', () => { + setFloorMap() + render() + expect(wrapper().style.zIndex).toBe('-1') + }) +}) 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/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx index 681c668..688f092 100644 --- a/frontend/src/components/modals/__tests__/DesignModal.test.tsx +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -64,4 +64,145 @@ describe('DesignModal', () => { expect(onClose).toHaveBeenCalled() expect(onSubmit).not.toHaveBeenCalled() }) + + describe('floor plan section', () => { + const fm = { + imageData: 'data:image/png;base64,abc', + posX: 40, posY: 60, width: 800, height: 600, + opacity: 0.8, locked: false, enabled: true, + } + + it('is hidden by default and submit omits floorMap', () => { + const { onSubmit } = renderModal() + expect(screen.queryByText('Floor Plan')).toBeNull() + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'X' } }) + fireEvent.click(screen.getByRole('button', { name: 'Create' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'X', icon: DEFAULT_DESIGN_ICON }) + expect('floorMap' in onSubmit.mock.calls[0][0]).toBe(false) + }) + + it('shows the section and preserves position while updating config', () => { + const { onSubmit } = renderModal({ + showFloorMap: true, + initialFloorMap: fm, + initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON }, + submitLabel: 'Save', + }) + expect(screen.getByText('Floor Plan')).toBeDefined() + expect(screen.getByAltText('Floor plan preview')).toBeDefined() + + // Toggle "Show on canvas" off. + const enabledBox = screen.getByLabelText('Show on canvas') as HTMLInputElement + fireEvent.click(enabledBox) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Home', + icon: DEFAULT_DESIGN_ICON, + floorMap: { ...fm, enabled: false }, + }) + }) + + it('submits floorMap: null when the image is removed', () => { + const { onSubmit } = renderModal({ + showFloorMap: true, + initialFloorMap: fm, + initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON }, + submitLabel: 'Save', + }) + fireEvent.click(screen.getByRole('button', { name: 'Remove' })) + expect(screen.queryByAltText('Floor plan preview')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSubmit).toHaveBeenCalledWith({ + name: 'Home', + icon: DEFAULT_DESIGN_ICON, + floorMap: null, + }) + }) + + it('uploads a chosen file and stores the returned server URL', async () => { + const onUploadImage = vi.fn().mockResolvedValue('/api/v1/media/deadbeef.png') + const { onSubmit } = renderModal({ + showFloorMap: true, + initialFloorMap: null, + initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON }, + submitLabel: 'Save', + onUploadImage, + }) + const file = new File(['x'], 'plan.png', { type: 'image/png' }) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [file] } }) + + await screen.findByAltText('Floor plan preview') + expect(onUploadImage).toHaveBeenCalledWith(file) + + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + const submitted = onSubmit.mock.calls[0][0] + expect(submitted.floorMap.imageData).toBe('/api/v1/media/deadbeef.png') + }) + + it('leaves state untouched when upload fails', async () => { + const onUploadImage = vi.fn().mockRejectedValue(new Error('boom')) + renderModal({ + showFloorMap: true, + initialFloorMap: null, + initial: { name: 'Home', icon: DEFAULT_DESIGN_ICON }, + submitLabel: 'Save', + onUploadImage, + }) + const file = new File(['x'], 'plan.png', { type: 'image/png' }) + const input = document.querySelector('input[type="file"]') as HTMLInputElement + fireEvent.change(input, { target: { files: [file] } }) + await vi.waitFor(() => expect(onUploadImage).toHaveBeenCalled()) + expect(screen.queryByAltText('Floor plan preview')).toBeNull() + }) + + // Regression: reopening the edit modal after a canvas-side resize must not + // save stale dimensions. Sidebar bumps the modal `key` on every open so it + // remounts and re-seeds from the current floor plan. + it('re-seeds width/height when remounted with a new key (reopen after resize)', () => { + const onSubmit = vi.fn() + const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON } + const { rerender } = render( + , + ) + // Canvas-side resize happened; reopen with a fresh key + larger dims. + const resized = { ...fm, width: 1200, height: 900 } + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 1200, height: 900 }) + }) + + it('keeps stale dimensions when reopened without remount (why the key bump matters)', () => { + const onSubmit = vi.fn() + const initial = { name: 'Home', icon: DEFAULT_DESIGN_ICON } + const { rerender } = render( + , + ) + const resized = { ...fm, width: 1200, height: 900 } + rerender( + , + ) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + // Same key → no remount → local state still the original 800×600. + expect(onSubmit.mock.calls[0][0].floorMap).toMatchObject({ width: 800, height: 600 }) + }) + + it('submits floorMap: null when shown but no image was chosen', () => { + const { onSubmit } = renderModal({ + showFloorMap: true, + initialFloorMap: null, + submitLabel: 'Save', + }) + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'Empty' } }) + fireEvent.click(screen.getByRole('button', { name: 'Save' })) + expect(onSubmit).toHaveBeenCalledWith({ name: 'Empty', icon: DEFAULT_DESIGN_ICON, floorMap: null }) + }) + }) }) diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index 4a3165a..758ec67 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 { useState, useCallback, useEffect } from 'react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Square, Settings, LogOut, Network, RadioTower, Type, PlusCircle, Pencil, Trash2 } from 'lucide-react' import { Logo } from '@/components/ui/Logo' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { useCanvasStore } from '@/stores/canvasStore' import { useDesignStore } from '@/stores/designStore' import { useAuthStore } from '@/stores/authStore' -import { designsApi } from '@/api/client' +import { designsApi, mediaApi } from '@/api/client' import * as standaloneStorage from '@/utils/standaloneStorage' import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons' import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal' @@ -39,6 +39,17 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() const [designSwitcherOpen, setDesignSwitcherOpen] = useState(false) const [designModal, setDesignModal] = useState<{ mode: 'create' | 'edit'; design?: Design } | null>(null) + // Bumped on every open so the modal remounts and re-seeds its local state from + // the current floor plan — otherwise a reopen keeps stale width/height/lock + // and Save would clobber canvas-side resize/move. + const [openSeq, setOpenSeq] = useState(0) + const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore() + const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce) + + const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => { + setOpenSeq((s) => s + 1) + setDesignModal(m) + }, []) const handleDesignSubmit = useCallback(async (data: DesignFormData) => { if (!designModal) return @@ -54,11 +65,18 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee : (await designsApi.update(designModal.design.id, { name: data.name, icon: data.icon })).data if (updated) updateDesign(updated.id, { name: updated.name, icon: updated.icon }) } + // Floor plan is canvas data attached to the active design. `undefined` + // means the section wasn't shown → leave it untouched. Applied to the + // store and persisted on the next explicit canvas Save. Not pushed to + // undo history (floorMap isn't part of HistoryEntry). + if (data.floorMap !== undefined) { + setFloorMap(data.floorMap) + } setDesignModal(null) } catch { toast.error(designModal.mode === 'create' ? 'Failed to create canvas' : 'Failed to update canvas') } - }, [designModal, addDesign, updateDesign]) + }, [designModal, addDesign, updateDesign, setFloorMap]) const handleDesignDelete = useCallback(async (d: Design) => { if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } @@ -76,7 +94,26 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } }, [designs.length, removeDesign]) - const { nodes, hasUnsavedChanges } = useCanvasStore() + const handleUploadImage = useCallback(async (file: File): Promise => { + try { + const { url } = await mediaApi.upload(file) + return url + } catch { + toast.error('Image upload failed') + throw new Error('upload failed') + } + }, []) + + const isActiveEdit = designModal?.mode === 'edit' && designModal.design?.id === activeDesignId + + // Double-click on the floor plan (canvas) asks to edit the active canvas. + useEffect(() => { + if (floorMapEditNonce === 0) return + const active = designs.find((d) => d.id === activeDesignId) + if (active) openDesignModal({ mode: 'edit', design: active }) + // Only react to the nonce bump, not to design/active changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [floorMapEditNonce]) const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length @@ -142,7 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee