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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 RIhZNXzf$iuLg(Mg{{Y#wkQD#` 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