From 8821b05b7b3155f98da4cf8d5636a9e72a13a191 Mon Sep 17 00:00:00 2001 From: Pranjal Joshi Date: Mon, 15 Jun 2026 16:37:41 +0530 Subject: [PATCH 1/5] feat: floor plan map, LQI edge coloring, zigbee path highlighting, eslint/test fixes, vite audit fix --- frontend/package-lock.json | 383 +++++++++--------- frontend/src/App.tsx | 50 ++- .../src/components/canvas/CanvasContainer.tsx | 2 + .../src/components/canvas/FloorMapLayer.tsx | 145 +++++++ .../src/components/canvas/edges/index.tsx | 39 +- .../src/components/modals/FloorMapModal.tsx | 138 +++++++ .../src/components/panels/DetailPanel.tsx | 25 +- frontend/src/components/panels/Sidebar.tsx | 8 +- .../panels/__tests__/DetailPanel.test.tsx | 32 ++ .../panels/__tests__/GroupPanels.test.tsx | 2 + frontend/src/stores/canvasStore.ts | 20 +- frontend/src/types/index.ts | 11 + frontend/src/utils/standaloneStorage.ts | 3 +- frontend/src/utils/zigbeePathfinding.ts | 89 ++++ 14 files changed, 745 insertions(+), 202 deletions(-) create mode 100644 frontend/src/components/canvas/FloorMapLayer.tsx create mode 100644 frontend/src/components/modals/FloorMapModal.tsx create mode 100644 frontend/src/utils/zigbeePathfinding.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f6cb45d..26cd436 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -963,6 +963,40 @@ "@noble/ciphers": "^1.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1916,6 +1950,25 @@ "node": ">=18" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -2429,9 +2482,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", - "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", + "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", "cpu": [ "arm" ], @@ -2443,9 +2496,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", - "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", + "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", "cpu": [ "arm64" ], @@ -2457,9 +2510,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", + "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", "cpu": [ "arm64" ], @@ -2471,9 +2524,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", + "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", "cpu": [ "x64" ], @@ -2485,9 +2538,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", - "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", + "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", "cpu": [ "arm64" ], @@ -2499,9 +2552,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", - "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", + "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", "cpu": [ "x64" ], @@ -2513,16 +2566,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", - "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", + "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2530,16 +2580,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", - "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", + "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", "cpu": [ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2547,16 +2594,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", + "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2564,16 +2608,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", - "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", + "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2581,16 +2622,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", - "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", + "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", "cpu": [ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2598,16 +2636,13 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", - "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", + "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", "cpu": [ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2615,16 +2650,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", - "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", + "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2632,16 +2664,13 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", - "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", + "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", "cpu": [ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2649,16 +2678,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", - "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", + "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2666,16 +2692,13 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", - "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", + "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2683,16 +2706,13 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", - "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", + "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2700,16 +2720,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", + "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2717,16 +2734,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", - "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", + "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2734,9 +2748,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", - "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", + "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", "cpu": [ "x64" ], @@ -2748,9 +2762,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", - "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", + "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", "cpu": [ "arm64" ], @@ -2762,9 +2776,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", + "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", "cpu": [ "arm64" ], @@ -2776,9 +2790,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", - "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", + "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", "cpu": [ "ia32" ], @@ -2790,9 +2804,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", - "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", + "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", "cpu": [ "x64" ], @@ -2804,9 +2818,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", + "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", "cpu": [ "x64" ], @@ -2858,6 +2872,13 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tailwindcss/node/node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@tailwindcss/oxide": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", @@ -2975,9 +2996,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2995,9 +3013,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3015,9 +3030,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3035,9 +3047,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3126,6 +3135,13 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3263,6 +3279,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3391,9 +3418,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -6945,9 +6972,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6969,9 +6993,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6993,9 +7014,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7017,9 +7035,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7455,9 +7470,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "funding": [ { "type": "github", @@ -7888,9 +7903,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "funding": [ { "type": "opencollective", @@ -7907,7 +7922,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8243,13 +8258,13 @@ } }, "node_modules/rollup": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", - "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "version": "4.62.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", + "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -8259,31 +8274,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.1", - "@rollup/rollup-android-arm64": "4.60.1", - "@rollup/rollup-darwin-arm64": "4.60.1", - "@rollup/rollup-darwin-x64": "4.60.1", - "@rollup/rollup-freebsd-arm64": "4.60.1", - "@rollup/rollup-freebsd-x64": "4.60.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", - "@rollup/rollup-linux-arm-musleabihf": "4.60.1", - "@rollup/rollup-linux-arm64-gnu": "4.60.1", - "@rollup/rollup-linux-arm64-musl": "4.60.1", - "@rollup/rollup-linux-loong64-gnu": "4.60.1", - "@rollup/rollup-linux-loong64-musl": "4.60.1", - "@rollup/rollup-linux-ppc64-gnu": "4.60.1", - "@rollup/rollup-linux-ppc64-musl": "4.60.1", - "@rollup/rollup-linux-riscv64-gnu": "4.60.1", - "@rollup/rollup-linux-riscv64-musl": "4.60.1", - "@rollup/rollup-linux-s390x-gnu": "4.60.1", - "@rollup/rollup-linux-x64-gnu": "4.60.1", - "@rollup/rollup-linux-x64-musl": "4.60.1", - "@rollup/rollup-openbsd-x64": "4.60.1", - "@rollup/rollup-openharmony-arm64": "4.60.1", - "@rollup/rollup-win32-arm64-msvc": "4.60.1", - "@rollup/rollup-win32-ia32-msvc": "4.60.1", - "@rollup/rollup-win32-x64-gnu": "4.60.1", - "@rollup/rollup-win32-x64-msvc": "4.60.1", + "@rollup/rollup-android-arm-eabi": "4.62.0", + "@rollup/rollup-android-arm64": "4.62.0", + "@rollup/rollup-darwin-arm64": "4.62.0", + "@rollup/rollup-darwin-x64": "4.62.0", + "@rollup/rollup-freebsd-arm64": "4.62.0", + "@rollup/rollup-freebsd-x64": "4.62.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", + "@rollup/rollup-linux-arm-musleabihf": "4.62.0", + "@rollup/rollup-linux-arm64-gnu": "4.62.0", + "@rollup/rollup-linux-arm64-musl": "4.62.0", + "@rollup/rollup-linux-loong64-gnu": "4.62.0", + "@rollup/rollup-linux-loong64-musl": "4.62.0", + "@rollup/rollup-linux-ppc64-gnu": "4.62.0", + "@rollup/rollup-linux-ppc64-musl": "4.62.0", + "@rollup/rollup-linux-riscv64-gnu": "4.62.0", + "@rollup/rollup-linux-riscv64-musl": "4.62.0", + "@rollup/rollup-linux-s390x-gnu": "4.62.0", + "@rollup/rollup-linux-x64-gnu": "4.62.0", + "@rollup/rollup-linux-x64-musl": "4.62.0", + "@rollup/rollup-openbsd-x64": "4.62.0", + "@rollup/rollup-openharmony-arm64": "4.62.0", + "@rollup/rollup-win32-arm64-msvc": "4.62.0", + "@rollup/rollup-win32-ia32-msvc": "4.62.0", + "@rollup/rollup-win32-x64-gnu": "4.62.0", + "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" } }, @@ -8856,9 +8871,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, @@ -8900,14 +8915,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0f833c2..39dc6bb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ 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' @@ -41,14 +42,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() @@ -82,6 +83,7 @@ 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 @@ -91,14 +93,16 @@ 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 }) + standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle, floorMap }) markSaved() toast.success('Canvas saved') return true } 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 +110,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,6 +131,8 @@ 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 + if (savedFloorMap) setFloorMap(savedFloorMap) loadCanvas(rfNodes, rfEdges) } else { loadCanvas(demoNodes, demoEdges) @@ -134,7 +140,7 @@ export default function App() { } catch { 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 +149,12 @@ 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) loadCanvas(saved.nodes, saved.edges) } else { loadCanvas(demoNodes, demoEdges) } - }, [loadCanvas, setTheme, setCustomStyle]) + }, [loadCanvas, setTheme, setCustomStyle, setFloorMap]) const loadDesignsAndCanvas = useCallback(async () => { if (STANDALONE) { @@ -518,6 +525,25 @@ 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 } @@ -697,6 +723,7 @@ export default function App() { onScan={() => setScanConfigOpen(true)} onZigbeeImport={() => setZigbeeImportOpen(true)} onZwaveImport={() => setZwaveImportOpen(true)} + onFloorMap={() => setFloorMapModalOpen(true)} onSave={handleSave} onOpenSettings={() => setSettingsOpen(true)} onOpenHistory={() => setScanHistoryOpen(true)} @@ -833,6 +860,15 @@ export default function App() { /> )} + setFloorMapModalOpen(false)} + onSubmit={handleFloorMapSubmit} + onRemove={handleFloorMapRemove} + initial={floorMap} + /> + setAddGroupRectOpen(false)} diff --git a/frontend/src/components/canvas/CanvasContainer.tsx b/frontend/src/components/canvas/CanvasContainer.tsx index 19082c0..3c28112 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..31aad13 --- /dev/null +++ b/frontend/src/components/canvas/FloorMapLayer.tsx @@ -0,0 +1,145 @@ +import { useCallback, useRef } from 'react' +import { useCanvasStore } from '@/stores/canvasStore' + +interface FloorMapLayerProps { + screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number } +} + +interface ResizeState { + startMouseX: number + startMouseY: number + startX: number + startY: number + startW: number + startH: number + edges: Set<'n' | 's' | 'e' | 'w'> +} + +export function FloorMapLayer({ screenToFlowPosition }: FloorMapLayerProps) { + const floorMap = useCanvasStore((s) => s.floorMap) + const updateFloorMap = useCanvasStore((s) => s.updateFloorMap) + + const resizeRef = useRef(null) + + const onDragStart = useCallback((e: React.MouseEvent) => { + if (!floorMap) return + e.stopPropagation() + 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, locked } = floorMap + + const hs: React.CSSProperties = { + position: 'absolute', + width: 10, + height: 10, + background: '#00d4ff', + border: '2px solid #0d1117', + borderRadius: 2, + 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']))} /> + + )} +
+ ) +} diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index bae77b8..9205d2e 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -9,7 +9,7 @@ import { type EdgeProps, type Edge, } from '@xyflow/react' -import type { EdgeData, EdgeType, Waypoint } from '@/types' +import type { EdgeData, EdgeType, NodeData, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' @@ -22,6 +22,13 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } +function getLqiColor(lqi: number): string { + if (lqi >= 200) return '#00cc00' + if (lqi >= 150) return '#88cc00' + if (lqi >= 100) return '#ffaa00' + return '#ff3300' +} + // ── Waypoint drag handle ───────────────────────────────────────────────────── interface WaypointHandleProps { @@ -289,7 +296,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] const sourceType = useStore((s) => s.nodeLookup.get(source)?.type) - const targetType = useStore((s) => s.nodeLookup.get(target)?.type) + const targetNode = useStore((s) => s.nodeLookup.get(target)) + const targetType = targetNode?.type const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0 @@ -315,6 +323,8 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors + const highlightedPath = useCanvasStore((s) => s.highlightedPath) + const isHighlighted = highlightedPath.includes(id) const BASE_STYLES: Record = { ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 }, @@ -328,15 +338,36 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle } const customColor = data?.custom_color as string | undefined - const strokeColor: string = selected + + // Derive LQI color from the target node's properties for iot edges + const lqiColor = (edgeType === 'iot' && targetNode?.data?.properties) + ? (() => { + const props = (targetNode.data as NodeData).properties ?? [] + const lqiProp = props.find( + (p: { key: string; value: string }) => p.key === 'LQI' + ) + if (lqiProp) { + const lqi = parseInt(lqiProp.value, 10) + if (!isNaN(lqi)) return getLqiColor(lqi) + } + return null + })() + : null + + const pathHighlightColor = '#00d4ff' + const strokeColor: string = isHighlighted + ? pathHighlightColor + : selected ? theme.colors.edgeSelectedColor - : customColor + : lqiColor + ?? customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) const style: React.CSSProperties = { ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(customColor ? { stroke: customColor } : {}), + ...(isHighlighted ? { stroke: pathHighlightColor, strokeWidth: 3, filter: `drop-shadow(0 0 6px ${pathHighlightColor}aa)` } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } diff --git a/frontend/src/components/modals/FloorMapModal.tsx b/frontend/src/components/modals/FloorMapModal.tsx new file mode 100644 index 0000000..0bf45ca --- /dev/null +++ b/frontend/src/components/modals/FloorMapModal.tsx @@ -0,0 +1,138 @@ +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/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index b75ff31..3864120 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -1,4 +1,4 @@ -import { createElement, useRef, useState } from 'react' +import { createElement, useEffect, useRef, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -9,6 +9,8 @@ import { getServiceUrl } from '@/utils/serviceUrl' import { splitIps } from '@/utils/maskIp' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { formatTimestamp } from '@/utils/timeFormat' +import { findZigbeePath } from '@/utils/zigbeePathfinding' +import { isZigbeeType } from '@/utils/zigbeeProperties' import type { Node } from '@xyflow/react' interface DetailPanelProps { @@ -22,7 +24,7 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } export function DetailPanel({ onEdit }: DetailPanelProps) { - const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore() + const { nodes, edges, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize, setHighlightedPath } = useCanvasStore() const serviceStatuses = useCanvasStore((s) => s.serviceStatuses) const [addingForNode, setAddingForNode] = useState(null) @@ -41,6 +43,25 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) + // Zigbee path highlighting: when a zigbee node is selected, compute + // the optimal route to the coordinator and highlight those edges. + useEffect(() => { + const targetNode = nodes.find((n) => n.id === selectedNodeId) + if (!targetNode || !isZigbeeType(targetNode.data.type)) { + setHighlightedPath([]) + return + } + + const coordinator = nodes.find((n) => n.data.type === 'zigbee_coordinator') + if (!coordinator) { + setHighlightedPath([]) + return + } + + const path = findZigbeePath(targetNode.id, coordinator.id, nodes, edges) + setHighlightedPath(path) + }, [selectedNodeId, nodes, edges, setHighlightedPath]) + if (multiSelected.length > 1) { return ( 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, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) { +export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbeeImport, onZwaveImport, onFloorMap, onSave, onOpenSettings, onOpenHistory, onOpenPending }: SidebarProps) { const [collapsed, setCollapsed] = useState(false) const logout = useAuthStore((s) => s.logout) const { designs, activeDesignId, setActiveDesign, addDesign, updateDesign, removeDesign } = useDesignStore() @@ -76,7 +77,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } }, [designs.length, removeDesign]) - const { nodes, hasUnsavedChanges } = useCanvasStore() + const { nodes, hasUnsavedChanges, floorMap } = useCanvasStore() const networkNodes = nodes.filter((n) => n.data.type !== 'groupRect' && n.data.type !== 'text') const onlineCount = networkNodes.filter((n) => n.data.status === 'online').length @@ -228,6 +229,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee {!STANDALONE && } {!STANDALONE && } {!STANDALONE && } + = {}, serviceStatuses: Record { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) }) @@ -130,6 +134,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -157,6 +163,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -178,6 +186,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -199,6 +209,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -253,6 +265,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() fireEvent.click(screen.getByLabelText('Close panel')) @@ -277,6 +291,8 @@ describe('DetailPanel', () => { deleteNode, updateNode: vi.fn(), snapshotHistory, + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(true) render() @@ -295,6 +311,8 @@ describe('DetailPanel', () => { deleteNode, updateNode: vi.fn(), snapshotHistory, + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(false) render() @@ -326,6 +344,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() // Two "Add" header buttons: first = properties, second = services @@ -351,6 +371,8 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() const addHeaders = screen.getAllByText('Add') @@ -374,6 +396,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Remove service')) @@ -389,6 +413,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) expect(() => render()).not.toThrow() }) @@ -420,6 +446,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -445,6 +473,8 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), + setHighlightedPath: vi.fn(), + edges: [], } as unknown as ReturnType) render() @@ -608,6 +638,7 @@ describe('DetailPanel', () => { function setupSized(node: Partial>, setNodeSize = vi.fn()) { const state = { nodes: [{ ...makeNode({}), ...node }], + edges: [], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), @@ -617,6 +648,7 @@ describe('DetailPanel', () => { createGroup: vi.fn(), ungroup: vi.fn(), setNodeSize, + setHighlightedPath: vi.fn(), serviceStatuses: {}, } vi.mocked(canvasStore.useCanvasStore).mockImplementation( diff --git a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx index ca73963..c3748c4 100644 --- a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx +++ b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx @@ -34,6 +34,7 @@ function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) { const mockStore = { nodes: [], + edges: [], selectedNodeId: null, selectedNodeIds: [], setSelectedNode: vi.fn(), @@ -43,6 +44,7 @@ const mockStore = { createGroup: vi.fn(), ungroup: vi.fn(), removeFromGroup: vi.fn(), + setHighlightedPath: vi.fn(), } function setupStore(overrides = {}) { diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index b1ba24e..a7fe8f8 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -9,7 +9,7 @@ import { applyEdgeChanges, addEdge, } from '@xyflow/react' -import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus } from '@/types' +import type { NodeData, EdgeData, NodeType, EdgeType, NodeTypeStyle, EdgeTypeStyle, CustomStyleDef, ServiceStatus, FloorMapConfig } from '@/types' import { generateUUID } from '@/utils/uuid' import { normalizeHandle, removedBottomHandleIds } from '@/utils/handleUtils' import { applyOpacity } from '@/utils/colorUtils' @@ -35,6 +35,12 @@ interface CanvasState { // Live per-service status overlay (not persisted), keyed via serviceStatusKey. serviceStatuses: Record + floorMap: FloorMapConfig | null + setFloorMap: (config: FloorMapConfig | null) => void + updateFloorMap: (patch: Partial) => void + highlightedPath: string[] + setHighlightedPath: (edgeIds: string[]) => void + // History past: HistoryEntry[] future: HistoryEntry[] @@ -98,6 +104,8 @@ export const useCanvasStore = create((set) => ({ hideIp: readHideIp(), scanEventTs: 0, serviceStatuses: {}, + floorMap: null, + highlightedPath: [], fitViewPending: false, past: [], @@ -747,6 +755,16 @@ export const useCanvasStore = create((set) => ({ set({ hideIp: value }) }, + setFloorMap: (config) => set({ floorMap: config, hasUnsavedChanges: true }), + + updateFloorMap: (patch) => + set((state) => ({ + floorMap: state.floorMap ? { ...state.floorMap, ...patch } : null, + hasUnsavedChanges: true, + })), + + setHighlightedPath: (edgeIds) => set({ highlightedPath: edgeIds }), + loadCanvas: (nodes, edges) => { // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 7f438fb..94469fd 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -252,3 +252,14 @@ export interface CustomStyleDef { nodes: Partial> edges: Partial> } + +export interface FloorMapConfig { + imageData: string + posX: number + posY: number + width: number + height: number + opacity: number + locked: boolean + enabled: boolean +} diff --git a/frontend/src/utils/standaloneStorage.ts b/frontend/src/utils/standaloneStorage.ts index f5f2506..c07e945 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 } from '@/types' +import type { Design, DesignType, NodeData, EdgeData, CustomStyleDef, FloorMapConfig } from '@/types' import type { ThemeId } from '@/utils/themes' import { generateUUID } from '@/utils/uuid' @@ -24,6 +24,7 @@ export interface StandaloneCanvas { edges: Edge[] theme_id?: ThemeId custom_style?: CustomStyleDef | null + floorMap?: FloorMapConfig } function readJSON(key: string): T | null { diff --git a/frontend/src/utils/zigbeePathfinding.ts b/frontend/src/utils/zigbeePathfinding.ts new file mode 100644 index 0000000..c55a55e --- /dev/null +++ b/frontend/src/utils/zigbeePathfinding.ts @@ -0,0 +1,89 @@ +import type { Node, Edge } from '@xyflow/react' +import type { NodeData, EdgeData } from '@/types' + +function getNodeLqi(node: Node): number { + const lqiProp = node.data.properties?.find((p) => p.key === 'LQI') + if (!lqiProp) return 255 + const lqi = parseInt(lqiProp.value, 10) + return isNaN(lqi) ? 255 : lqi +} + +/** + * Run Dijkstra on the Zigbee `iot` edge subgraph from `startNodeId` to + * `targetNodeId`. Returns the ordered list of edge IDs forming the optimal + * path, or an empty array if no path exists. + * + * Edge cost = 255 - LQI, where LQI is read from the edge's target node + * properties (higher LQI = lower cost). For reverse traversal, the same + * cost is used. + */ +export function findZigbeePath( + startNodeId: string, + targetNodeId: string, + nodes: Node[], + edges: Edge[], +): string[] { + if (startNodeId === targetNodeId) return [] + + const iotEdges = edges.filter((e) => (e.data?.type ?? 'ethernet') === 'iot') + if (iotEdges.length === 0) return [] + + const nodeMap = new Map(nodes.map((n) => [n.id, n])) + const adjacency = new Map() + + for (const e of iotEdges) { + const targetLqi = nodeMap.get(e.target) ? getNodeLqi(nodeMap.get(e.target)!) : 255 + const sourceLqi = nodeMap.get(e.source) ? getNodeLqi(nodeMap.get(e.source)!) : 255 + const cost = 255 - Math.min(targetLqi, sourceLqi) + + if (!adjacency.has(e.source)) adjacency.set(e.source, []) + if (!adjacency.has(e.target)) adjacency.set(e.target, []) + + adjacency.get(e.source)!.push({ neighbor: e.target, edgeId: e.id, cost }) + adjacency.get(e.target)!.push({ neighbor: e.source, edgeId: e.id, cost }) + } + + if (!adjacency.has(startNodeId) || !adjacency.has(targetNodeId)) return [] + + const dist = new Map() + const prev = new Map() + const unvisited = new Set(adjacency.keys()) + + for (const nodeId of unvisited) dist.set(nodeId, Infinity) + dist.set(startNodeId, 0) + + while (unvisited.size > 0) { + let current: string | null = null + let minDist = Infinity + for (const nodeId of unvisited) { + const d = dist.get(nodeId)! + if (d < minDist) { minDist = d; current = nodeId } + } + if (current === null || minDist === Infinity) break + if (current === targetNodeId) break + + unvisited.delete(current) + + const neighbors = adjacency.get(current) ?? [] + for (const { neighbor, edgeId, cost } of neighbors) { + if (!unvisited.has(neighbor)) continue + const alt = dist.get(current)! + cost + if (alt < (dist.get(neighbor) ?? Infinity)) { + dist.set(neighbor, alt) + prev.set(neighbor, { node: current, edgeId }) + } + } + } + + // Reconstruct path: walk from targetNodeId back to startNodeId + const pathEdges: string[] = [] + let current = targetNodeId + while (current !== startNodeId) { + const p = prev.get(current) + if (!p) return [] + pathEdges.unshift(p.edgeId) + current = p.node + } + + return pathEdges +} From 046c99e219d0c29696b56b6d1704c4c50f6a1460 Mon Sep 17 00:00:00 2001 From: Pranjal Joshi Date: Thu, 2 Jul 2026 10:33:57 +0530 Subject: [PATCH 2/5] fix: null -> undefined for StandaloneCanvas.floorMap --- frontend/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 39dc6bb..a635774 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -93,7 +93,7 @@ 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 }) + standaloneStorage.saveCanvas(saveDesignId, { nodes, edges, theme_id: activeTheme, custom_style: customStyle, floorMap: floorMap ?? undefined }) markSaved() toast.success('Canvas saved') return true From 1ed013bde2031baf6fb0bb692a9ab138074a7a6e Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 2 Jul 2026 16:36:25 +0200 Subject: [PATCH 3/5] 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 { From 61600909193ad5c4670ecbe465a8ddf3b94a0533 Mon Sep 17 00:00:00 2001 From: Pouzor Date: Thu, 2 Jul 2026 17:16:51 +0200 Subject: [PATCH 4/5] revert: drop zigbee LQI edge coloring and path highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split out of this PR — will land separately with reworked zigbee edge capture/storage. Keeps only the floor plan + generic media upload work. - Remove getLqiColor + LQI-derived iot edge stroke (edges/index.tsx) - Remove zigbee path highlight effect (DetailPanel.tsx) - Remove highlightedPath/setHighlightedPath store state (canvasStore.ts) - Delete utils/zigbeePathfinding.ts - Revert associated test scaffolding and package-lock churn --- frontend/package-lock.json | 383 +++++++++--------- .../src/components/canvas/edges/index.tsx | 39 +- .../src/components/panels/DetailPanel.tsx | 25 +- .../panels/__tests__/DetailPanel.test.tsx | 32 -- .../panels/__tests__/GroupPanels.test.tsx | 2 - frontend/src/stores/canvasStore.ts | 5 - frontend/src/utils/zigbeePathfinding.ts | 89 ---- 7 files changed, 190 insertions(+), 385 deletions(-) delete mode 100644 frontend/src/utils/zigbeePathfinding.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26cd436..f6cb45d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -963,40 +963,6 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1950,25 +1916,6 @@ "node": ">=18" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -2482,9 +2429,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz", - "integrity": "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", "cpu": [ "arm" ], @@ -2496,9 +2443,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz", - "integrity": "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", "cpu": [ "arm64" ], @@ -2510,9 +2457,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz", - "integrity": "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", "cpu": [ "arm64" ], @@ -2524,9 +2471,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz", - "integrity": "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", "cpu": [ "x64" ], @@ -2538,9 +2485,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz", - "integrity": "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", "cpu": [ "arm64" ], @@ -2552,9 +2499,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz", - "integrity": "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", "cpu": [ "x64" ], @@ -2566,13 +2513,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz", - "integrity": "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2580,13 +2530,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz", - "integrity": "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2594,13 +2547,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz", - "integrity": "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2608,13 +2564,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz", - "integrity": "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2622,13 +2581,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz", - "integrity": "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2636,13 +2598,16 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz", - "integrity": "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2650,13 +2615,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz", - "integrity": "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2664,13 +2632,16 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz", - "integrity": "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2678,13 +2649,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz", - "integrity": "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2692,13 +2666,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz", - "integrity": "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2706,13 +2683,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz", - "integrity": "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2720,13 +2700,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz", - "integrity": "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2734,13 +2717,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz", - "integrity": "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2748,9 +2734,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz", - "integrity": "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", "cpu": [ "x64" ], @@ -2762,9 +2748,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz", - "integrity": "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", "cpu": [ "arm64" ], @@ -2776,9 +2762,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz", - "integrity": "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", "cpu": [ "arm64" ], @@ -2790,9 +2776,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz", - "integrity": "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", "cpu": [ "ia32" ], @@ -2804,9 +2790,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz", - "integrity": "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", "cpu": [ "x64" ], @@ -2818,9 +2804,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz", - "integrity": "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", "cpu": [ "x64" ], @@ -2872,13 +2858,6 @@ "tailwindcss": "4.2.2" } }, - "node_modules/@tailwindcss/node/node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@tailwindcss/oxide": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", @@ -2996,6 +2975,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3013,6 +2995,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3030,6 +3015,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3047,6 +3035,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3135,13 +3126,6 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@tailwindcss/vite/node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3279,17 +3263,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -3418,9 +3391,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -6972,6 +6945,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -6993,6 +6969,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7014,6 +6993,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7035,6 +7017,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -7470,9 +7455,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -7903,9 +7888,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -7922,7 +7907,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -8258,13 +8243,13 @@ } }, "node_modules/rollup": { - "version": "4.62.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.0.tgz", - "integrity": "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==", + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -8274,31 +8259,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.0", - "@rollup/rollup-android-arm64": "4.62.0", - "@rollup/rollup-darwin-arm64": "4.62.0", - "@rollup/rollup-darwin-x64": "4.62.0", - "@rollup/rollup-freebsd-arm64": "4.62.0", - "@rollup/rollup-freebsd-x64": "4.62.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", - "@rollup/rollup-linux-arm-musleabihf": "4.62.0", - "@rollup/rollup-linux-arm64-gnu": "4.62.0", - "@rollup/rollup-linux-arm64-musl": "4.62.0", - "@rollup/rollup-linux-loong64-gnu": "4.62.0", - "@rollup/rollup-linux-loong64-musl": "4.62.0", - "@rollup/rollup-linux-ppc64-gnu": "4.62.0", - "@rollup/rollup-linux-ppc64-musl": "4.62.0", - "@rollup/rollup-linux-riscv64-gnu": "4.62.0", - "@rollup/rollup-linux-riscv64-musl": "4.62.0", - "@rollup/rollup-linux-s390x-gnu": "4.62.0", - "@rollup/rollup-linux-x64-gnu": "4.62.0", - "@rollup/rollup-linux-x64-musl": "4.62.0", - "@rollup/rollup-openbsd-x64": "4.62.0", - "@rollup/rollup-openharmony-arm64": "4.62.0", - "@rollup/rollup-win32-arm64-msvc": "4.62.0", - "@rollup/rollup-win32-ia32-msvc": "4.62.0", - "@rollup/rollup-win32-x64-gnu": "4.62.0", - "@rollup/rollup-win32-x64-msvc": "4.62.0", + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" } }, @@ -8871,9 +8856,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", - "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", "dev": true, "license": "MIT" }, @@ -8915,14 +8900,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" diff --git a/frontend/src/components/canvas/edges/index.tsx b/frontend/src/components/canvas/edges/index.tsx index 9205d2e..bae77b8 100644 --- a/frontend/src/components/canvas/edges/index.tsx +++ b/frontend/src/components/canvas/edges/index.tsx @@ -9,7 +9,7 @@ import { type EdgeProps, type Edge, } from '@xyflow/react' -import type { EdgeData, EdgeType, NodeData, Waypoint } from '@/types' +import type { EdgeData, EdgeType, Waypoint } from '@/types' import { useThemeStore } from '@/stores/themeStore' import { useCanvasStore } from '@/stores/canvasStore' import { THEMES } from '@/utils/themes' @@ -22,13 +22,6 @@ function getVlanColor(vlanId?: number): string { return VLAN_COLORS[vlanId % VLAN_COLORS.length] } -function getLqiColor(lqi: number): string { - if (lqi >= 200) return '#00cc00' - if (lqi >= 150) return '#88cc00' - if (lqi >= 100) return '#ffaa00' - return '#ff3300' -} - // ── Waypoint drag handle ───────────────────────────────────────────────────── interface WaypointHandleProps { @@ -296,8 +289,7 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle const activeTheme = useThemeStore((s) => s.activeTheme) const theme = THEMES[activeTheme] const sourceType = useStore((s) => s.nodeLookup.get(source)?.type) - const targetNode = useStore((s) => s.nodeLookup.get(target)) - const targetType = targetNode?.type + const targetType = useStore((s) => s.nodeLookup.get(target)?.type) const isBidirectional = sourceType === 'proxmox' && targetType === 'proxmox' const waypoints: Waypoint[] = Array.isArray(data?.waypoints) && data.waypoints.length > 0 @@ -323,8 +315,6 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle const edgeType: EdgeType = data?.type ?? 'ethernet' const edgeColors = theme.colors.edgeColors - const highlightedPath = useCanvasStore((s) => s.highlightedPath) - const isHighlighted = highlightedPath.includes(id) const BASE_STYLES: Record = { ethernet: { stroke: edgeColors.ethernet, strokeWidth: 2 }, @@ -338,36 +328,15 @@ export function HomelableEdge({ id, source, target, sourceHandleId, targetHandle } const customColor = data?.custom_color as string | undefined - - // Derive LQI color from the target node's properties for iot edges - const lqiColor = (edgeType === 'iot' && targetNode?.data?.properties) - ? (() => { - const props = (targetNode.data as NodeData).properties ?? [] - const lqiProp = props.find( - (p: { key: string; value: string }) => p.key === 'LQI' - ) - if (lqiProp) { - const lqi = parseInt(lqiProp.value, 10) - if (!isNaN(lqi)) return getLqiColor(lqi) - } - return null - })() - : null - - const pathHighlightColor = '#00d4ff' - const strokeColor: string = isHighlighted - ? pathHighlightColor - : selected + const strokeColor: string = selected ? theme.colors.edgeSelectedColor - : lqiColor - ?? customColor + : customColor ?? (edgeType === 'vlan' ? getVlanColor(data?.vlan_id as number | undefined) : (BASE_STYLES[edgeType].stroke as string ?? edgeColors.ethernet)) const style: React.CSSProperties = { ...BASE_STYLES[edgeType], ...(edgeType === 'vlan' ? { stroke: getVlanColor(data?.vlan_id as number | undefined) } : {}), ...(customColor ? { stroke: customColor } : {}), - ...(isHighlighted ? { stroke: pathHighlightColor, strokeWidth: 3, filter: `drop-shadow(0 0 6px ${pathHighlightColor}aa)` } : {}), ...(selected ? { stroke: theme.colors.edgeSelectedColor, filter: `drop-shadow(0 0 4px ${theme.colors.edgeSelectedColor}88)` } : {}), } diff --git a/frontend/src/components/panels/DetailPanel.tsx b/frontend/src/components/panels/DetailPanel.tsx index 3864120..b75ff31 100644 --- a/frontend/src/components/panels/DetailPanel.tsx +++ b/frontend/src/components/panels/DetailPanel.tsx @@ -1,4 +1,4 @@ -import { createElement, useEffect, useRef, useState } from 'react' +import { createElement, useRef, useState } from 'react' import { X, Edit, Trash2, ExternalLink, Plus, Pencil, Layers, Ungroup, Eye, EyeOff } from 'lucide-react' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' @@ -9,8 +9,6 @@ import { getServiceUrl } from '@/utils/serviceUrl' import { splitIps } from '@/utils/maskIp' import { PROPERTY_ICONS, PROPERTY_ICON_NAMES, resolvePropertyIcon } from '@/utils/propertyIcons' import { formatTimestamp } from '@/utils/timeFormat' -import { findZigbeePath } from '@/utils/zigbeePathfinding' -import { isZigbeeType } from '@/utils/zigbeeProperties' import type { Node } from '@xyflow/react' interface DetailPanelProps { @@ -24,7 +22,7 @@ type PropForm = { key: string; value: string; icon: string | null; visible: bool const EMPTY_PROP: PropForm = { key: '', value: '', icon: null, visible: true } export function DetailPanel({ onEdit }: DetailPanelProps) { - const { nodes, edges, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize, setHighlightedPath } = useCanvasStore() + const { nodes, selectedNodeId, selectedNodeIds, setSelectedNode, deleteNode, updateNode, snapshotHistory, createGroup, ungroup, removeFromGroup, setNodeSize } = useCanvasStore() const serviceStatuses = useCanvasStore((s) => s.serviceStatuses) const [addingForNode, setAddingForNode] = useState(null) @@ -43,25 +41,6 @@ export function DetailPanel({ onEdit }: DetailPanelProps) { // Multi-select panel const multiSelected = (selectedNodeIds ?? []).filter((id) => nodes.some((n) => n.id === id)) - // Zigbee path highlighting: when a zigbee node is selected, compute - // the optimal route to the coordinator and highlight those edges. - useEffect(() => { - const targetNode = nodes.find((n) => n.id === selectedNodeId) - if (!targetNode || !isZigbeeType(targetNode.data.type)) { - setHighlightedPath([]) - return - } - - const coordinator = nodes.find((n) => n.data.type === 'zigbee_coordinator') - if (!coordinator) { - setHighlightedPath([]) - return - } - - const path = findZigbeePath(targetNode.id, coordinator.id, nodes, edges) - setHighlightedPath(path) - }, [selectedNodeId, nodes, edges, setHighlightedPath]) - if (multiSelected.length > 1) { return ( = {}, serviceStatuses: Record { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) }) @@ -134,8 +130,6 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() @@ -163,8 +157,6 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() @@ -186,8 +178,6 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() @@ -209,8 +199,6 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() @@ -265,8 +253,6 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() fireEvent.click(screen.getByLabelText('Close panel')) @@ -291,8 +277,6 @@ describe('DetailPanel', () => { deleteNode, updateNode: vi.fn(), snapshotHistory, - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(true) render() @@ -311,8 +295,6 @@ describe('DetailPanel', () => { deleteNode, updateNode: vi.fn(), snapshotHistory, - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) vi.spyOn(window, 'confirm').mockReturnValue(false) render() @@ -344,8 +326,6 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() // Two "Add" header buttons: first = properties, second = services @@ -371,8 +351,6 @@ describe('DetailPanel', () => { snapshotHistory: vi.fn(), createGroup: vi.fn(), ungroup: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() const addHeaders = screen.getAllByText('Add') @@ -396,8 +374,6 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() fireEvent.click(screen.getByTitle('Remove service')) @@ -413,8 +389,6 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode: vi.fn(), snapshotHistory: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) expect(() => render()).not.toThrow() }) @@ -446,8 +420,6 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() @@ -473,8 +445,6 @@ describe('DetailPanel', () => { deleteNode: vi.fn(), updateNode, snapshotHistory: vi.fn(), - setHighlightedPath: vi.fn(), - edges: [], } as unknown as ReturnType) render() @@ -638,7 +608,6 @@ describe('DetailPanel', () => { function setupSized(node: Partial>, setNodeSize = vi.fn()) { const state = { nodes: [{ ...makeNode({}), ...node }], - edges: [], selectedNodeId: 'n1', selectedNodeIds: [], setSelectedNode: vi.fn(), @@ -648,7 +617,6 @@ describe('DetailPanel', () => { createGroup: vi.fn(), ungroup: vi.fn(), setNodeSize, - setHighlightedPath: vi.fn(), serviceStatuses: {}, } vi.mocked(canvasStore.useCanvasStore).mockImplementation( diff --git a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx index c3748c4..ca73963 100644 --- a/frontend/src/components/panels/__tests__/GroupPanels.test.tsx +++ b/frontend/src/components/panels/__tests__/GroupPanels.test.tsx @@ -34,7 +34,6 @@ function makeGroupNode(id = 'g1', label = 'My Group', showBorder = true) { const mockStore = { nodes: [], - edges: [], selectedNodeId: null, selectedNodeIds: [], setSelectedNode: vi.fn(), @@ -44,7 +43,6 @@ const mockStore = { createGroup: vi.fn(), ungroup: vi.fn(), removeFromGroup: vi.fn(), - setHighlightedPath: vi.fn(), } function setupStore(overrides = {}) { diff --git a/frontend/src/stores/canvasStore.ts b/frontend/src/stores/canvasStore.ts index a7fe8f8..2e310f6 100644 --- a/frontend/src/stores/canvasStore.ts +++ b/frontend/src/stores/canvasStore.ts @@ -38,8 +38,6 @@ interface CanvasState { floorMap: FloorMapConfig | null setFloorMap: (config: FloorMapConfig | null) => void updateFloorMap: (patch: Partial) => void - highlightedPath: string[] - setHighlightedPath: (edgeIds: string[]) => void // History past: HistoryEntry[] @@ -105,7 +103,6 @@ export const useCanvasStore = create((set) => ({ scanEventTs: 0, serviceStatuses: {}, floorMap: null, - highlightedPath: [], fitViewPending: false, past: [], @@ -763,8 +760,6 @@ export const useCanvasStore = create((set) => ({ hasUnsavedChanges: true, })), - setHighlightedPath: (edgeIds) => set({ highlightedPath: edgeIds }), - loadCanvas: (nodes, edges) => { // React Flow requires parents before children in the array const parents = nodes.filter((n) => !n.parentId) diff --git a/frontend/src/utils/zigbeePathfinding.ts b/frontend/src/utils/zigbeePathfinding.ts deleted file mode 100644 index c55a55e..0000000 --- a/frontend/src/utils/zigbeePathfinding.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Node, Edge } from '@xyflow/react' -import type { NodeData, EdgeData } from '@/types' - -function getNodeLqi(node: Node): number { - const lqiProp = node.data.properties?.find((p) => p.key === 'LQI') - if (!lqiProp) return 255 - const lqi = parseInt(lqiProp.value, 10) - return isNaN(lqi) ? 255 : lqi -} - -/** - * Run Dijkstra on the Zigbee `iot` edge subgraph from `startNodeId` to - * `targetNodeId`. Returns the ordered list of edge IDs forming the optimal - * path, or an empty array if no path exists. - * - * Edge cost = 255 - LQI, where LQI is read from the edge's target node - * properties (higher LQI = lower cost). For reverse traversal, the same - * cost is used. - */ -export function findZigbeePath( - startNodeId: string, - targetNodeId: string, - nodes: Node[], - edges: Edge[], -): string[] { - if (startNodeId === targetNodeId) return [] - - const iotEdges = edges.filter((e) => (e.data?.type ?? 'ethernet') === 'iot') - if (iotEdges.length === 0) return [] - - const nodeMap = new Map(nodes.map((n) => [n.id, n])) - const adjacency = new Map() - - for (const e of iotEdges) { - const targetLqi = nodeMap.get(e.target) ? getNodeLqi(nodeMap.get(e.target)!) : 255 - const sourceLqi = nodeMap.get(e.source) ? getNodeLqi(nodeMap.get(e.source)!) : 255 - const cost = 255 - Math.min(targetLqi, sourceLqi) - - if (!adjacency.has(e.source)) adjacency.set(e.source, []) - if (!adjacency.has(e.target)) adjacency.set(e.target, []) - - adjacency.get(e.source)!.push({ neighbor: e.target, edgeId: e.id, cost }) - adjacency.get(e.target)!.push({ neighbor: e.source, edgeId: e.id, cost }) - } - - if (!adjacency.has(startNodeId) || !adjacency.has(targetNodeId)) return [] - - const dist = new Map() - const prev = new Map() - const unvisited = new Set(adjacency.keys()) - - for (const nodeId of unvisited) dist.set(nodeId, Infinity) - dist.set(startNodeId, 0) - - while (unvisited.size > 0) { - let current: string | null = null - let minDist = Infinity - for (const nodeId of unvisited) { - const d = dist.get(nodeId)! - if (d < minDist) { minDist = d; current = nodeId } - } - if (current === null || minDist === Infinity) break - if (current === targetNodeId) break - - unvisited.delete(current) - - const neighbors = adjacency.get(current) ?? [] - for (const { neighbor, edgeId, cost } of neighbors) { - if (!unvisited.has(neighbor)) continue - const alt = dist.get(current)! + cost - if (alt < (dist.get(neighbor) ?? Infinity)) { - dist.set(neighbor, alt) - prev.set(neighbor, { node: current, edgeId }) - } - } - } - - // Reconstruct path: walk from targetNodeId back to startNodeId - const pathEdges: string[] = [] - let current = targetNodeId - while (current !== startNodeId) { - const p = prev.get(current) - if (!p) return [] - pathEdges.unshift(p.edgeId) - current = p.node - } - - return pathEdges -} From bdde03cdfa4320ae7ce18a13733468b091306c0b Mon Sep 17 00:00:00 2001 From: Pouzor Date: Fri, 3 Jul 2026 00:03:16 +0200 Subject: [PATCH 5/5] feat: floor plan selection handles, dbl-click edit, bottom z-index - Resize handles only render when the plan is selected (click to select, click outside to deselect); previously always shown when unlocked. - Double-clicking an unlocked plan opens the canvas edit modal (via a floorMapEditNonce signal the Sidebar watches). - Floor plan always sits at the bottom of the canvas (z-index -1), behind nodes and edges, locked or not. - Remount the edit modal on every open (key bump) so it re-seeds from the current floor plan; fixes Save clobbering a canvas-side resize/move with stale modal dimensions. - Drop the no-op history snapshot on floor-plan edits (floorMap isn't part of undo history). --- .../e65c837dca6746fd97b54dade0c9a880.jpg | Bin 0 -> 31431 bytes .../src/components/canvas/FloorMapLayer.tsx | 37 ++++++-- .../canvas/__tests__/FloorMapLayer.test.tsx | 83 ++++++++++++++++++ .../modals/__tests__/DesignModal.test.tsx | 37 ++++++++ frontend/src/components/panels/Sidebar.tsx | 35 ++++++-- .../src/stores/__tests__/canvasStore.test.ts | 7 ++ frontend/src/stores/canvasStore.ts | 7 ++ 7 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg create mode 100644 frontend/src/components/canvas/__tests__/FloorMapLayer.test.tsx diff --git a/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg b/backend/data/uploads/e65c837dca6746fd97b54dade0c9a880.jpg new file mode 100644 index 0000000000000000000000000000000000000000..792150e4b407c14f1560bb1aca8bf119cd213da3 GIT binary patch literal 31431 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 z zSNE$Z9|;KmB(?sI`H_3#B4MX|YRg57xLR9ulTYqC6nb6Y_oc%%#|s~bEzc93Yb%!) zoWk0^eUt6XY%4pf_B{cG>1S9@&94Y)IiY2}Dp@ppn|Pbx1ZhF7w=2C4v&~y@xA3GS zue${Qj>cC_kJ!@e0#6q`i&}5aKgVl+M9|A~$+O%i_KWdYS9_mXw#HeE@5E}0sZ))TtDJ$9pijo@TS(6IH9j|)tE27D|8Y5Ct)HjC8I9D@I%+G<$zNWQ&w2_#*;rf z15S%oPRW?Tk8s@IwqN92>Scmaq$85zPPSfst_SBu=a_BrDT^Y<}ux0Bp zMc2l(-PdnrD*LQaV>|F*;^#9QEJa)Xss+aGtLaf-c+NV{;M}C;<&Oe4e---My*tlp zW2*gwq;Bo_3yb;oDfP;<^OZ_4Ep%P=ZjsZ%2Gd8$4-F(2&)Ax=HsbuWWR-=|xz84P z3A>sUH*r7Am60*J)Tr(KWU^4k+MQm?8&6J62(=RM;<;X^qqlB;0b|I+9_NxY4qis4 zwPMWWI@9)ug{)hVaI>}P!^$-69R>&Yc1vvxSQx-(Gx17f9txR#7cmpR}gx}L;|0Z@$@%z@aV9v2wc2U2NC#Hw>-Q4Z) z=k&41ha4Tv^~41BX7{anTozJs+IkB&PqH~AP6{@tY`Q9PMN?B?E8 z$r(J+Zw}8)+`H%@m*&k^tj-B9e6r7oddDquG2y$$vc}vpwI^-gp-`)`6Q#i)jJ1!2 z^f&5s*cT-_mg%^$TIq$9?vV7=vRuPCr#m(^2h*uDP$n1}=l8F@Z7DmS`!9|9EDw`KcOPcsoyGTGOZ6 zU!_ckvwxV`I!_5(;XD7M&~{~mO&bin7#qUBGAgb+C}_Qt$H7=NF@FZLTKDsh!Yb=# zPTZ@PC%|;B+UD4WQ|A;c)}H#Dal~krg_cc>$?_TO3*X2;S(5nZRa?Rn%jh=IqGFbq zc_B&nn>K`B6WQ9WA8tA^=AGG{pqu|znVwO*E_mhl@?V!kp8RZ!pR#hp%2g{pb6+gK za#-oBQC7`aLw~+{gGC|>qUxCbEfG%8_LW<(+PvYioS-$wgw!47A}!YIjrJJ446|GC z@W#_Wi>5ta)iYa|=|6+VTf=V8OU~;by1h~;O61?nvd~WINN7M>joU)z+1igfE^NEO z&%7hkc1O^+_S7Q+>y)zhDbLR`%3JZrL2>D<%GY;XGc$w!&Z~8s!PBRA_jljQI}-|4 zOc7bNxX;$;HG||jHLA(d#9~X>v*U0Yj3b|M;7~| z{@;RsZn?csK6F!O(jCd$zb<|hNSSv-?~gG1=HQG|e_nh2XK>*V*mClF(HDh}rC<9T z#C`3yit0p^I6gKsdVRAd?qM$Ly6golyU#Ve5@DQmu~fI(>rZFhm7AfF$8SX)xUxZb zhTEz}-?=}#HoVZ&Y(5lzW`*$<#=ZX;0vH(>7kz3j)?E87∋cTJrPXv%7jf?FuoN z9{yy0Wai67pM?ZJve`t$2xgc&-$`Fs%RI5g@|b2rWrD<{r-wOr-11i7ik&Xi*06h< zna^zQgB|%XLJv)23>h2`z5FP7=EU=M@n=j+8QnY9E?K@a=*wK6S$}_+IvnoYq`EZL z(Cx}vW$m|qbI$x{_?WU}k;Pi(e;*AFv~GO*Gl8pan!AMliOEI(1WXs7IIf!b&F!j! zOrS`}z2isBUWqP{@jfKJSJxs@ts_TvSBtRAwEhA%CxfNEOA>>B^?zqpRrn}a-@R_G zUGj(AOY8MJi(^^8T3_E3Hg9F%1NAFS=NO6}#!NQl3d+8yCDV02XorK&af8PJnH;h2 z*gk773Aw4iK(Nz6m#2D~r+7zw;Udl@y8~AH9gzMg9&Xlsl1po1=;}@`=7}fn@D}be z?npX!wcTLTsoXh63}!$4m{U{t*vh&lO|8A-pp$fhLG6Q}{^tc7PG5d2{%zLzdrH$k zI8U|tFfnWWBi?y;H_R~5FP*oqb-zk{)-1l7GxHWJv`d`3#k})G63?M8XT0_ohulv+ z@aDI|)R|LXpV_ymxJ#!Ra+I}x$`3esaMo?n zM$u*lLmpGpFCCt|!J4Nw)|^t*`p+Pi=5|Z$K~s;+8tLU{R`1#&eWAuldH#)eJU7e> zI%b`mk&xHZ65976`QYrkuQQ({HOzc6bAj6)RduVdKifH3nx{`#vDGK&X3vh7q0e_D zO)}9iZ#H{=CCAhA#4!VQhKKL3=u{jRT>ozSrR1aQjJdeiebwREk~ja3mz1VXb(gkv z1Mj0BQ=7PF?nzy(d;FmLkwqfQjTiUWJ^kOg_XlcY-l@LSg9v zYbBP6t0YT>s+Jzu`=22&_{k;iU2|r6Eo|C%aVk}!hRO7wC(mJyiHwgo-dYvwwy60jVD3m-M=%zEUd9J z+L!L1R9j_lJozN@pq!%}84ucm9(s z4%Xi~LtVS?+)GYBEXc^ke)6BA(V~5F%XkcTw(nDyk>8fy@SXL-`NExpTzZq@7CjCzN8n zJzQ=Wlrh)8WM1l=xV}_Zaf4`I>LPxnm-Q>EJ9_SmlrofV({5+fPg#0Qjqk9C>-5EE zU*($Sg_nF>YaV|^;ez4eA5)gCuKz3=C;j!`sc)74BJER*M8COkxy|u9ktV@!)!(+j z&0>1qsb0PnJ@48B4Pyi@$FDe`Vkh|NwzKHsuPX~(wmA5DU%PaxCuuoLpFrl19V@n- zt>6AGwmHLYqey^sRuYGI?FE006o*gcTsNWxo*%iRd`Eo6y{5t&r>`54zE5Mv%?^Cx}c_4;-kl1Q=S*hbH8)6 z+#;dr=!Qcv%ClQu23%ge!@zg*+zm|us~)sTGS&w+ubQ`0ao%r^ZCCzWk==hFGNj>) z@T3x_OWcJsKU=O_xT|5Q!-vZDDW@+lw;UczsO9aL#Frst*DNq9og#Ff*UxUBjl2vtyEy$_EO2wsn^-`t4mSVvMkN7 zyc=6I?xY>~cjAk2sm7X|9L_rCH-C<7mwY6pEZSe_xa0SvmVz~Bq>d}KaB04taU}N9 z)5$MNkE}~LU$MC}O8UX-y2&w1D}VWV%kA-A__*13#=|RTrfk^p?Lp?O>5XZtPW@Bo zobl69=4Q;h2CdMD3~9N(2szE8b)1g(d!s|Yx@7B} zxUj&YX$70wjGuiU6PaYrc(ciINY9%Yzy1-o`;$i2-Mjdzn{Hi6@y=R(aQ zPMr%|x18tWM`1VqYqS42A5Wj`>-Sav5_3r~%dY;Z$_I>n-r3*((AHKiFlFMx2A3}@ zt{$){X6_7VF85u?6|cDC#o|8Ajtu?gWhOiGd0)vX$Hni|zWTJ&PU*zP$)B+T$lg`<{9(6K8CU8uu4%m27DZiKwyv@=F?5Og9Q&OO+U$pve|GW- z=@tEF_^s>2YxYV->cG8;{~llZ&2-f#Al3WM;*O|g%Dbsnu{M!@vv^Y9oZ^1FH4=NDRSM!ZH!-n z-℘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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/canvas/FloorMapLayer.tsx b/frontend/src/components/canvas/FloorMapLayer.tsx index 2e925e0..26daf2a 100644 --- a/frontend/src/components/canvas/FloorMapLayer.tsx +++ b/frontend/src/components/canvas/FloorMapLayer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { ViewportPortal, useReactFlow, useStore } from '@xyflow/react' import { useCanvasStore } from '@/stores/canvasStore' @@ -17,20 +17,40 @@ interface ResizeState { * 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. + * 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 @@ -96,7 +116,7 @@ export function FloorMapLayer() { if (!floorMap || !floorMap.enabled) return null - const { imageData, posX, posY, width, height, opacity, locked } = floorMap + 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. @@ -115,6 +135,7 @@ export function FloorMapLayer() { return (
{ e.stopPropagation(); requestFloorMapEdit() }} > - {!locked && ( + {!locked && selected && ( <>
onResizeStart(e, new Set(['n','w']))} />
onResizeStart(e, new Set(['n']))} /> 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/__tests__/DesignModal.test.tsx b/frontend/src/components/modals/__tests__/DesignModal.test.tsx index 27c0976..688f092 100644 --- a/frontend/src/components/modals/__tests__/DesignModal.test.tsx +++ b/frontend/src/components/modals/__tests__/DesignModal.test.tsx @@ -157,6 +157,43 @@ describe('DesignModal', () => { 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, diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx index da5765b..758ec67 100644 --- a/frontend/src/components/panels/Sidebar.tsx +++ b/frontend/src/components/panels/Sidebar.tsx @@ -1,4 +1,4 @@ -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' @@ -39,7 +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) - const { nodes, hasUnsavedChanges, floorMap, setFloorMap, snapshotHistory } = useCanvasStore() + // 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 @@ -57,16 +67,16 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee } // 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. + // store and persisted on the next explicit canvas Save. Not pushed to + // undo history (floorMap isn't part of HistoryEntry). 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, setFloorMap, snapshotHistory]) + }, [designModal, addDesign, updateDesign, setFloorMap]) const handleDesignDelete = useCallback(async (d: Design) => { if (designs.length <= 1) { toast.error('Cannot delete the only canvas'); return } @@ -96,6 +106,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee 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 const offlineCount = networkNodes.filter((n) => n.data.status === 'offline').length @@ -160,7 +179,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee