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

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

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-02 16:36:25 +02:00
parent 046c99e219
commit 1ed013bde2
16 changed files with 597 additions and 233 deletions
+10 -34
View File
@@ -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)}