From 6923c319a1d0e92182bb9b2594d739ac6f080102 Mon Sep 17 00:00:00 2001 From: lejay Date: Fri, 13 Feb 2026 00:21:53 +0100 Subject: [PATCH] v1.0 --- .gitignore | 22 + LICENSE | 21 + client/index.html | 15 + client/package-lock.json | 2541 +++++++++++++++++ client/package.json | 26 + client/public/materials/.gitkeep | 2 + client/public/rooms/.gitkeep | 2 + client/src/App.jsx | 27 + client/src/api/client.js | 157 + client/src/components/AIRenderModal.jsx | 162 ++ .../src/components/AIRenderModal.module.css | 199 ++ client/src/components/ColorPickerModal.jsx | 301 ++ .../components/ColorPickerModal.module.css | 491 ++++ client/src/components/CuttingListTable.jsx | 108 + .../components/CuttingListTable.module.css | 195 ++ client/src/components/DesignTip.jsx | 121 + client/src/components/DesignTip.module.css | 78 + client/src/components/Layout.jsx | 37 + client/src/components/Layout.module.css | 63 + client/src/components/MaterialDetailModal.jsx | 205 ++ .../components/MaterialDetailModal.module.css | 270 ++ client/src/components/MaterialModal.jsx | 201 ++ .../src/components/MaterialModal.module.css | 135 + client/src/components/Plans2DView.jsx | 184 ++ client/src/components/Plans2DView.module.css | 192 ++ client/src/components/ProtectedRoute.jsx | 13 + client/src/components/Room3D.jsx | 370 +++ client/src/components/RoomUpload.jsx | 234 ++ client/src/components/RoomUpload.module.css | 272 ++ client/src/components/WardrobeElement.jsx | 447 +++ client/src/components/WardrobePreview.jsx | 801 ++++++ .../src/components/WardrobePreview.module.css | 123 + client/src/context/AuthContext.jsx | 38 + client/src/index.css | 51 + client/src/main.jsx | 10 + client/src/pages/Admin.jsx | 240 ++ client/src/pages/Admin.module.css | 240 ++ client/src/pages/Configurator.jsx | 1170 ++++++++ client/src/pages/Configurator.module.css | 1050 +++++++ client/src/pages/Home.jsx | 61 + client/src/pages/Home.module.css | 82 + client/src/pages/Login.jsx | 45 + client/src/pages/Login.module.css | 61 + client/src/pages/Materials.jsx | 269 ++ client/src/pages/Materials.module.css | 267 ++ client/vite.config.js | 15 + docs/WINDOWS.md | 58 + package-lock.json | 6 + package.json | 15 + server/.env.example | 11 + server/db/index.js | 62 + server/db/init.js | 19 + server/db/migrate.js | 56 + server/db/migrations/001_formex_tables.sql | 25 + server/db/migrations/002_formex_enhanced.sql | 34 + server/db/migrations/003_room_configs.sql | 12 + server/db/migrations/004_modules.sql | 72 + server/db/migrations/005_material_color.sql | 5 + server/db/schema.sql | 202 ++ server/db/seed.js | 23 + server/db/seeds.sql | 75 + server/index.js | 163 ++ server/package-lock.json | 1623 +++++++++++ server/package.json | 24 + server/routes/ai.js | 73 + server/routes/blockTypes.js | 12 + server/routes/finishes.js | 67 + server/routes/layout.js | 82 + server/routes/modules.js | 179 ++ server/routes/orders.js | 27 + server/routes/plans.js | 96 + server/routes/price.js | 79 + server/routes/products.js | 12 + server/routes/room.js | 173 ++ server/routes/serviceOptions.js | 12 + server/routes/settings.js | 16 + server/routes/sync.js | 348 +++ server/services/cuttingEngine.js | 343 +++ server/services/designAdvisor.js | 213 ++ server/services/formexCrawler.js | 638 +++++ server/services/layoutOptimizer.js | 282 ++ server/services/planGenerator.js | 358 +++ server/services/priceEngine.js | 422 +++ server/services/roomAnalyzer.js | 245 ++ server/services/scheduler.js | 60 + 85 files changed, 17836 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 client/index.html create mode 100644 client/package-lock.json create mode 100644 client/package.json create mode 100644 client/public/materials/.gitkeep create mode 100644 client/public/rooms/.gitkeep create mode 100644 client/src/App.jsx create mode 100644 client/src/api/client.js create mode 100644 client/src/components/AIRenderModal.jsx create mode 100644 client/src/components/AIRenderModal.module.css create mode 100644 client/src/components/ColorPickerModal.jsx create mode 100644 client/src/components/ColorPickerModal.module.css create mode 100644 client/src/components/CuttingListTable.jsx create mode 100644 client/src/components/CuttingListTable.module.css create mode 100644 client/src/components/DesignTip.jsx create mode 100644 client/src/components/DesignTip.module.css create mode 100644 client/src/components/Layout.jsx create mode 100644 client/src/components/Layout.module.css create mode 100644 client/src/components/MaterialDetailModal.jsx create mode 100644 client/src/components/MaterialDetailModal.module.css create mode 100644 client/src/components/MaterialModal.jsx create mode 100644 client/src/components/MaterialModal.module.css create mode 100644 client/src/components/Plans2DView.jsx create mode 100644 client/src/components/Plans2DView.module.css create mode 100644 client/src/components/ProtectedRoute.jsx create mode 100644 client/src/components/Room3D.jsx create mode 100644 client/src/components/RoomUpload.jsx create mode 100644 client/src/components/RoomUpload.module.css create mode 100644 client/src/components/WardrobeElement.jsx create mode 100644 client/src/components/WardrobePreview.jsx create mode 100644 client/src/components/WardrobePreview.module.css create mode 100644 client/src/context/AuthContext.jsx create mode 100644 client/src/index.css create mode 100644 client/src/main.jsx create mode 100644 client/src/pages/Admin.jsx create mode 100644 client/src/pages/Admin.module.css create mode 100644 client/src/pages/Configurator.jsx create mode 100644 client/src/pages/Configurator.module.css create mode 100644 client/src/pages/Home.jsx create mode 100644 client/src/pages/Home.module.css create mode 100644 client/src/pages/Login.jsx create mode 100644 client/src/pages/Login.module.css create mode 100644 client/src/pages/Materials.jsx create mode 100644 client/src/pages/Materials.module.css create mode 100644 client/vite.config.js create mode 100644 docs/WINDOWS.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/.env.example create mode 100644 server/db/index.js create mode 100644 server/db/init.js create mode 100644 server/db/migrate.js create mode 100644 server/db/migrations/001_formex_tables.sql create mode 100644 server/db/migrations/002_formex_enhanced.sql create mode 100644 server/db/migrations/003_room_configs.sql create mode 100644 server/db/migrations/004_modules.sql create mode 100644 server/db/migrations/005_material_color.sql create mode 100644 server/db/schema.sql create mode 100644 server/db/seed.js create mode 100644 server/db/seeds.sql create mode 100644 server/index.js create mode 100644 server/package-lock.json create mode 100644 server/package.json create mode 100644 server/routes/ai.js create mode 100644 server/routes/blockTypes.js create mode 100644 server/routes/finishes.js create mode 100644 server/routes/layout.js create mode 100644 server/routes/modules.js create mode 100644 server/routes/orders.js create mode 100644 server/routes/plans.js create mode 100644 server/routes/price.js create mode 100644 server/routes/products.js create mode 100644 server/routes/room.js create mode 100644 server/routes/serviceOptions.js create mode 100644 server/routes/settings.js create mode 100644 server/routes/sync.js create mode 100644 server/services/cuttingEngine.js create mode 100644 server/services/designAdvisor.js create mode 100644 server/services/formexCrawler.js create mode 100644 server/services/layoutOptimizer.js create mode 100644 server/services/planGenerator.js create mode 100644 server/services/priceEngine.js create mode 100644 server/services/roomAnalyzer.js create mode 100644 server/services/scheduler.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8c11cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +node_modules/ +server/data/ +.env +*.db +*.sqlite +.DS_Store + +# Build output +client/dist/ + +# Downloaded material images (generated by Formex crawler) +client/public/materials/*.jpg +client/public/materials/*.png +client/public/materials/*.webp +!client/public/materials/.gitkeep + +# Uploaded room photos +client/public/rooms/*.jpg +client/public/rooms/*.jpeg +client/public/rooms/*.png +client/public/rooms/*.webp +!client/public/rooms/.gitkeep diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b77bf2a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..f7e4a22 --- /dev/null +++ b/client/index.html @@ -0,0 +1,15 @@ + + + + + + Configurateur armoires sur mesure — SIGNATURE + + + + + +
+ + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..96687f1 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,2541 @@ +{ + "name": "configurateur-armoires-client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "configurateur-armoires-client", + "version": "1.0.0", + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@react-three/drei": "^9.117.0", + "@react-three/fiber": "^8.17.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "three": "^0.169.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "vite": "^5.4.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "license": "MIT", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==", + "license": "MIT" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "license": "MIT", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/three": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/three/-/three-9.7.5.tgz", + "integrity": "sha512-RxIsCoQfUqOS3POmhVHa1wdWS0wyHAUway73uRLp3GAL5U2iYVNdnzQsep6M2NZ994BlW8TcKuMtQHUqOsy6WA==", + "license": "MIT", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "@react-three/fiber": ">=6.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "three": ">=0.126" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==", + "license": "MIT" + }, + "node_modules/@react-three/drei": { + "version": "9.122.0", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-9.122.0.tgz", + "integrity": "sha512-SEO/F/rBCTjlLez7WAlpys+iGe9hty4rNgjZvgkQeXFSiwqD4Hbk/wNHMAbdd8vprO2Aj81mihv4dF5bC7D0CA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@react-spring/three": "~9.7.5", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^2.9.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "react-composer": "^5.0.3", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.7.8", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.0", + "tunnel-rat": "^0.1.2", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^8", + "react": "^18", + "react-dom": "^18", + "three": ">=0.137" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-8.18.0.tgz", + "integrity": "sha512-FYZZqD0UUHUswKz3LQl2Z7H24AhD14XGTsIRw3SJaXUxyfVMi+1yiZGmqTcPt/CkPpdU7rrxqcyQ1zJE5DjvIQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/react-reconciler": "^0.26.7", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^1.0.6", + "react-reconciler": "^0.27.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.21.0", + "suspend-react": "^0.1.3", + "zustand": "^3.7.1" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=18 <19", + "react-dom": ">=18 <19", + "react-native": ">=0.64", + "three": ">=0.133" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/@react-three/fiber/node_modules/zustand": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-3.7.2.tgz", + "integrity": "sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==", + "license": "MIT", + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz", + "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.26.7.tgz", + "integrity": "sha512-mBDYl8x+oyPX/VBb3E638N0B7xG+SPk/EAMcVPeexqus/5aTpTphQi0curhhshOqRrc9t6OPoJfEUkbymse/lQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", + "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/camera-controls": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", + "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001766", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.283", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, + "node_modules/hls.js": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", + "integrity": "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-1.2.5.tgz", + "integrity": "sha512-fXtDA0X0t0eBYAGLVM5YsgJGsJ5jEmqZEPrGbzdf5awjv0xE7nqv3TVnvtUF060Tkes15DbDAKW/I48vsb6SyA==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.0" + }, + "peerDependencies": { + "react": ">=18.0" + } + }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-composer": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/react-composer/-/react-composer-5.0.3.tgz", + "integrity": "sha512-1uWd07EME6XZvMfapwZmc7NgCZqDemcvicRi3wMJzXsQLvZ3L7fTHVyPy1bZdnWXM4iPjYuNE+uJ41MLKeTtnA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.6.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/three": { + "version": "0.169.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.169.0.tgz", + "integrity": "sha512-Ed906MA3dR4TS5riErd4QBsRGPcx+HBDX2O5yYE5GqJeFQTPU+M56Va/f/Oph9X7uZo3W3o4l2ZhBZ6f6qUv0w==", + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.7.8.tgz", + "integrity": "sha512-BGEZTOIC14U0XIRw3tO4jY7IjP7n7v24nv9JXS1CyeVRWOCkcOMhRnmENUjuV39gktAw4Ofhr0OvIAiTspQrrw==", + "deprecated": "Deprecated due to three.js version incompatibility. Please use v0.8.0, instead.", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.151.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "license": "MIT" + }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..ec77a32 --- /dev/null +++ b/client/package.json @@ -0,0 +1,26 @@ +{ + "name": "configurateur-armoires-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@react-three/drei": "^9.117.0", + "@react-three/fiber": "^8.17.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "three": "^0.169.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "vite": "^5.4.10" + } +} diff --git a/client/public/materials/.gitkeep b/client/public/materials/.gitkeep new file mode 100644 index 0000000..226ca23 --- /dev/null +++ b/client/public/materials/.gitkeep @@ -0,0 +1,2 @@ +# This directory stores downloaded Formex material images +# The actual images are not tracked by git diff --git a/client/public/rooms/.gitkeep b/client/public/rooms/.gitkeep new file mode 100644 index 0000000..5fb5c90 --- /dev/null +++ b/client/public/rooms/.gitkeep @@ -0,0 +1,2 @@ +# This directory stores uploaded room photos +# The actual images are not tracked by git diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..dcb77e1 --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,27 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { AuthProvider } from './context/AuthContext'; +import Layout from './components/Layout'; +import ProtectedRoute from './components/ProtectedRoute'; +import Home from './pages/Home'; +import Configurator from './pages/Configurator'; +import Login from './pages/Login'; +import Admin from './pages/Admin'; +import Materials from './pages/Materials'; + +export default function App() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/client/src/api/client.js b/client/src/api/client.js new file mode 100644 index 0000000..d920dec --- /dev/null +++ b/client/src/api/client.js @@ -0,0 +1,157 @@ +const API_BASE = '/api'; + +export async function fetchProducts() { + const res = await fetch(`${API_BASE}/products`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchBlockTypes() { + const res = await fetch(`${API_BASE}/block-types`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchFinishes() { + const res = await fetch(`${API_BASE}/finishes`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchServiceOptions() { + const res = await fetch(`${API_BASE}/service-options`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchSettings() { + const res = await fetch(`${API_BASE}/settings`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function calculatePrice(config) { + const res = await fetch(`${API_BASE}/price`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchOrders() { + const res = await fetch(`${API_BASE}/orders`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function submitOrder(order) { + const res = await fetch(`${API_BASE}/orders`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(order) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +// Formex sync API + +export async function triggerFormexSync() { + const res = await fetch(`${API_BASE}/sync/formex`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +/** + * Get the SSE stream URL for real-time sync progress + */ +export function getFormexSyncStreamUrl() { + return `${API_BASE}/sync/formex/stream`; +} + +export async function fetchSyncLogs(limit = 20) { + const res = await fetch(`${API_BASE}/sync/logs?limit=${limit}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchSyncStatus() { + const res = await fetch(`${API_BASE}/sync/status`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +// Formex Materials CRUD + +export async function fetchFormexMaterials({ search = '', collection = '', category = '', active = null, page = 1, limit = 25 } = {}) { + const params = new URLSearchParams(); + if (search) params.set('search', search); + if (collection) params.set('collection', collection); + if (category) params.set('category', category); + if (active !== null) params.set('active', active ? '1' : '0'); + params.set('page', String(page)); + params.set('limit', String(limit)); + + const res = await fetch(`${API_BASE}/formex/materials?${params}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchFormexMaterial(id) { + const res = await fetch(`${API_BASE}/formex/materials/${id}`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function createFormexMaterial(data) { + const res = await fetch(`${API_BASE}/formex/materials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function updateFormexMaterial(id, data) { + const res = await fetch(`${API_BASE}/formex/materials/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function deleteFormexMaterial(id) { + const res = await fetch(`${API_BASE}/formex/materials/${id}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchFormexCollections() { + const res = await fetch(`${API_BASE}/formex/collections`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function fetchFormexCategories() { + const res = await fetch(`${API_BASE}/formex/categories`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +/** + * Fetch material details including dimensions and color variants + * Alias for fetchFormexMaterial for use in the configurator + */ +export async function fetchMaterialDetails(id) { + return fetchFormexMaterial(id); +} diff --git a/client/src/components/AIRenderModal.jsx b/client/src/components/AIRenderModal.jsx new file mode 100644 index 0000000..52ad97e --- /dev/null +++ b/client/src/components/AIRenderModal.jsx @@ -0,0 +1,162 @@ +/** + * AIRenderModal - Generates and displays a DALL-E 3 photorealistic render + * of the configured wardrobe, giving the customer a realistic preview. + */ +import { useState, useEffect, useRef } from 'react'; +import styles from './AIRenderModal.module.css'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +export default function AIRenderModal({ isOpen, config, roomConfig, onClose }) { + const [imageUrl, setImageUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [progress, setProgress] = useState(0); + const abortRef = useRef(null); + const timerRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + setImageUrl(null); + setError(null); + setLoading(true); + setProgress(0); + + // Simulate progress (DALL-E takes ~15-25s) + timerRef.current = setInterval(() => { + setProgress(p => Math.min(p + Math.random() * 8, 92)); + }, 1000); + + const controller = new AbortController(); + abortRef.current = controller; + + const body = { + config: { + width: config.totalWidth, + height: config.height, + depth: config.depth, + installationType: config.installationType, + columns: config.modules?.map(m => ({ + type: m.type || m.moduleCode, + width: m.width, + shelves: m.shelves, + drawers: m.drawers, + hasRod: m.hasRod + })), + materialName: config.finishLabel || null, + doorType: config.doorType + }, + roomContext: roomConfig || null + }; + + fetch(`${API_BASE}/api/ai/render`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal + }) + .then(r => { + if (!r.ok) throw new Error(r.status === 402 ? 'quota' : 'api'); + return r.json(); + }) + .then(data => { + if (data.imageUrl) { + setImageUrl(data.imageUrl); + setProgress(100); + } else { + throw new Error('no_image'); + } + }) + .catch(err => { + if (err.name === 'AbortError') return; + if (err.message === 'quota') { + setError('Quota IA dépassé. Réessayez plus tard.'); + } else { + setError('Impossible de générer le rendu. Vérifiez la configuration IA.'); + } + }) + .finally(() => { + setLoading(false); + if (timerRef.current) clearInterval(timerRef.current); + }); + + return () => { + controller.abort(); + if (timerRef.current) clearInterval(timerRef.current); + }; + }, [isOpen]); + + const handleDownload = () => { + if (!imageUrl) return; + const a = document.createElement('a'); + a.href = imageUrl; + a.download = `armoire-rendu-${Date.now()}.png`; + a.target = '_blank'; + a.click(); + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

+ IA + Rendu photoréaliste +

+ +
+ +
+ {loading && !imageUrl && ( +
+
+

Génération en cours...

+
+
+
+

+ L'IA crée un rendu réaliste de votre armoire. Cela peut prendre 15-25 secondes. +

+
+ )} + + {error && ( +
+

{error}

+ +
+ )} + + {imageUrl && ( +
+ Rendu photoréaliste de l'armoire +
+ )} +
+ +
+ {imageUrl && ( + + )} + +
+
+
+ ); +} diff --git a/client/src/components/AIRenderModal.module.css b/client/src/components/AIRenderModal.module.css new file mode 100644 index 0000000..24d3c87 --- /dev/null +++ b/client/src/components/AIRenderModal.module.css @@ -0,0 +1,199 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.modal { + background: #fff; + border-radius: 14px; + width: min(95vw, 900px); + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-light, #e5e7eb); +} + +.title { + display: flex; + align-items: center; + gap: 8px; + font-size: 1.1rem; + margin: 0; + color: var(--text, #1f2937); +} + +.aiBadge { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 7px; + border-radius: 4px; + background: var(--action, #4f46e5); + color: #fff; +} + +.closeBtn { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-muted, #6b7280); + padding: 4px 8px; + border-radius: 6px; +} + +.closeBtn:hover { + background: var(--bg-subtle, #f3f4f6); +} + +.body { + flex: 1; + overflow: auto; + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Loading */ +.loadingArea { + text-align: center; + padding: 40px 20px; +} + +.spinner { + width: 48px; + height: 48px; + border: 4px solid var(--border-light, #e5e7eb); + border-top-color: var(--action, #4f46e5); + border-radius: 50%; + animation: spin 0.8s linear infinite; + margin: 0 auto 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.loadingText { + font-size: 1rem; + font-weight: 600; + color: var(--text, #1f2937); + margin: 0 0 12px; +} + +.progressBar { + width: 260px; + height: 6px; + background: var(--border-light, #e5e7eb); + border-radius: 3px; + margin: 0 auto 12px; + overflow: hidden; +} + +.progressFill { + height: 100%; + background: var(--action, #4f46e5); + border-radius: 3px; + transition: width 0.5s ease; +} + +.loadingHint { + font-size: 0.8rem; + color: var(--text-muted, #6b7280); + margin: 0; +} + +/* Error */ +.errorArea { + text-align: center; + padding: 40px 20px; +} + +.errorText { + font-size: 0.9rem; + color: var(--danger, #dc2626); + margin: 0 0 12px; +} + +.retryBtn { + padding: 8px 16px; + border: 1px solid var(--action, #4f46e5); + background: transparent; + color: var(--action, #4f46e5); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.retryBtn:hover { + background: var(--bg-subtle, #f9fafb); +} + +/* Image */ +.imageArea { + padding: 12px; + width: 100%; +} + +.renderImage { + width: 100%; + height: auto; + border-radius: 8px; + display: block; +} + +/* Footer */ +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + padding: 12px 20px; + border-top: 1px solid var(--border-light, #e5e7eb); +} + +.downloadBtn { + padding: 8px 16px; + background: var(--action, #4f46e5); + color: #fff; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; + font-weight: 500; +} + +.downloadBtn:hover { + background: var(--action-hover, #4338ca); +} + +.closeFooterBtn { + padding: 8px 16px; + background: transparent; + color: var(--text-muted, #6b7280); + border: 1px solid var(--border-light, #e5e7eb); + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.closeFooterBtn:hover { + background: var(--bg-subtle, #f9fafb); +} diff --git a/client/src/components/ColorPickerModal.jsx b/client/src/components/ColorPickerModal.jsx new file mode 100644 index 0000000..b032bba --- /dev/null +++ b/client/src/components/ColorPickerModal.jsx @@ -0,0 +1,301 @@ +/** + * ColorPickerModal - Compact swatch-grid color picker for wardrobe elements. + * Sidebar collections + dense swatch grid = everything visible at once. + */ +import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; +import styles from './ColorPickerModal.module.css'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +const ELEMENT_LABELS = { + plintheBas: 'Plinthe bas', + fileur: 'Fileur', + bandeauHaut: 'Bandeau haut', + champs: 'Champs (bords)', + caisse: 'Caisse', + cotesDoublage: 'Doublage', + cotes: 'Cotes', + dessus: 'Dessus', + fond: 'Fond', + dos: 'Dos', + portes: 'Portes', + panneauxInterieurs: 'Panneaux int.', + separateurs: 'Separateurs', + etageres: 'Etageres', + tiroirs: 'Tiroirs', + penderie: 'Tringle' +}; + +export default function ColorPickerModal({ + isOpen, + elementType, + currentFinish, + finishes = [], + onSelect, + onClose, + onApplyToAll, + config +}) { + const [selectedFinish, setSelectedFinish] = useState(currentFinish); + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCollection, setSelectedCollection] = useState('all'); + const [hoveredFinish, setHoveredFinish] = useState(null); + const [aiRecs, setAiRecs] = useState([]); + const [aiLoading, setAiLoading] = useState(false); + const tooltipRef = useRef(null); + + useEffect(() => { + if (isOpen) { + setSelectedFinish(currentFinish); + setSearchTerm(''); + setSelectedCollection('all'); + setHoveredFinish(null); + setAiRecs([]); + // Fetch AI recommendations + if (config && finishes.length > 0) { + setAiLoading(true); + const controller = new AbortController(); + fetch(`${API_BASE}/api/ai/recommend-materials`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + config: { + width: config.totalWidth, + height: config.height, + depth: config.depth, + columns: config.modules?.map(m => ({ + type: m.type || m.moduleCode, + width: m.width + })), + materialName: config.finishLabel || null, + doorType: config.doorType + }, + finishes: finishes.map(f => ({ id: f.id, name: f.name || f.label, collection: f.collection, code: f.code })) + }), + signal: controller.signal + }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data?.recommendations) { + setAiRecs(data.recommendations); + } + }) + .catch(() => {}) + .finally(() => setAiLoading(false)); + return () => controller.abort(); + } + } + }, [isOpen, currentFinish]); + + const collections = useMemo(() => { + const cols = [...new Set(finishes.map(f => f.collection).filter(Boolean))]; + return cols.sort(); + }, [finishes]); + + const filteredFinishes = useMemo(() => { + return finishes.filter(finish => { + const matchesSearch = !searchTerm || + finish.name?.toLowerCase().includes(searchTerm.toLowerCase()) || + finish.label?.toLowerCase().includes(searchTerm.toLowerCase()) || + finish.code?.toLowerCase().includes(searchTerm.toLowerCase()); + const matchesCollection = selectedCollection === 'all' || + finish.collection === selectedCollection; + return matchesSearch && matchesCollection; + }); + }, [finishes, searchTerm, selectedCollection]); + + const handleConfirm = () => { + if (selectedFinish && onSelect) { + onSelect(elementType, selectedFinish); + } + onClose(); + }; + + const handleApplyToAll = () => { + if (selectedFinish && onApplyToAll) { + onApplyToAll(selectedFinish); + } + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + {/* Header: title + search */} +
+

+ {ELEMENT_LABELS[elementType] || elementType} +

+ setSearchTerm(e.target.value)} + autoFocus + /> + +
+ + {/* Body: sidebar + swatch grid */} +
+ {/* Collection sidebar */} +
+ + {collections.map(col => { + const count = finishes.filter(f => f.collection === col).length; + return ( + + ); + })} +
+ + {/* Swatch grid */} +
+ {/* AI Recommendations */} + {(aiRecs.length > 0 || aiLoading) && ( +
+
+ IA + Recommandations +
+ {aiLoading ? ( +
+
+
+
+
+ ) : ( +
+ {aiRecs.map(rec => { + const finish = finishes.find(f => f.id === rec.finishId); + if (!finish) return null; + const isSelected = selectedFinish?.id === finish.id; + return ( + + ); + })} +
+ )} +
+ )} + + {filteredFinishes.length > 0 ? ( +
+ {filteredFinishes.map(finish => { + const isSelected = selectedFinish?.id === finish.id; + const isHovered = hoveredFinish?.id === finish.id; + return ( + + ); + })} +
+ ) : ( +
Aucune finition trouvee
+ )} + + {/* Hover detail strip -- shows info of hovered swatch */} + {hoveredFinish && ( +
+ {hoveredFinish.image_url && ( + + )} +
+ {hoveredFinish.name || hoveredFinish.label} + {hoveredFinish.code && {hoveredFinish.code}} + {hoveredFinish.collection && {hoveredFinish.collection}} + {hoveredFinish.price_chf != null && ( + {hoveredFinish.price_chf.toFixed(2)} CHF + )} +
+
+ )} +
+
+ + {/* Footer: selection preview + actions */} +
+
+ {selectedFinish ? ( + <> + {selectedFinish.image_url ? ( + + ) : selectedFinish.hexColor ? ( +
+ ) : null} + {selectedFinish.name || selectedFinish.label} + + ) : ( + Aucune selection + )} +
+
+ + + +
+
+
+
+ ); +} diff --git a/client/src/components/ColorPickerModal.module.css b/client/src/components/ColorPickerModal.module.css new file mode 100644 index 0000000..c7c88cc --- /dev/null +++ b/client/src/components/ColorPickerModal.module.css @@ -0,0 +1,491 @@ +/* =================================================================== + ColorPickerModal -- compact swatch-grid layout + Sidebar collections + dense swatch grid = no scroll needed + =================================================================== */ + +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 16px; +} + +.modal { + background: #fff; + border-radius: 12px; + width: 95vw; + max-width: 1100px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + overflow: hidden; +} + +/* --- Header: title + search + close --- */ +.header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border-light); + background: var(--bg-subtle); + flex-shrink: 0; +} + +.title { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: var(--text); + white-space: nowrap; +} + +.searchInput { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--border-light); + border-radius: 6px; + font-size: 0.85rem; + max-width: 260px; +} + +.searchInput:focus { + outline: none; + border-color: var(--action); + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.1); +} + +.closeBtn { + background: none; + border: none; + font-size: 1.5rem; + color: var(--text-muted); + cursor: pointer; + padding: 2px 6px; + line-height: 1; + margin-left: auto; +} + +.closeBtn:hover { + color: var(--text); +} + +/* --- Body: sidebar + swatch area --- */ +.body { + display: flex; + flex: 1; + min-height: 0; /* allow flex shrink */ + overflow: hidden; +} + +/* --- Sidebar: vertical collection tabs --- */ +.sidebar { + width: 140px; + flex-shrink: 0; + border-right: 1px solid var(--border-light); + background: var(--bg-subtle); + overflow-y: auto; + padding: 6px 0; +} + +.sidebarTab { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 7px 12px; + border: none; + background: none; + font-size: 0.78rem; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + text-align: left; + transition: all 0.15s; + border-left: 3px solid transparent; +} + +.sidebarTab:hover { + color: var(--text); + background: rgba(0, 0, 0, 0.03); +} + +.sidebarTabActive { + color: var(--action); + background: #fff; + border-left-color: var(--action); + font-weight: 600; +} + +.tabCount { + font-size: 0.65rem; + color: var(--text-muted); + background: var(--border-light); + padding: 1px 5px; + border-radius: 8px; + min-width: 1.4rem; + text-align: center; +} + +.sidebarTabActive .tabCount { + background: rgba(79, 70, 229, 0.12); + color: var(--action); +} + +/* --- Swatch area: grid + hover detail --- */ +.swatchArea { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 12px 16px; + position: relative; +} + +/* Dense swatch grid -- small squares, many per row */ +.swatchGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); + gap: 6px; + align-content: start; +} + +.swatch { + position: relative; + width: 100%; + aspect-ratio: 1; + border: 2px solid var(--border-light); + border-radius: 6px; + overflow: hidden; + cursor: pointer; + transition: all 0.15s; + padding: 0; + background: #fff; +} + +.swatch:hover { + border-color: var(--action); + transform: scale(1.08); + z-index: 2; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.swatchSelected { + border-color: var(--action); + box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.25); + z-index: 1; +} + +.swatchImg { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.swatchColor { + width: 100%; + height: 100%; +} + +.swatchCheck { + position: absolute; + top: 2px; + right: 2px; + width: 16px; + height: 16px; + background: var(--action); + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.6rem; + font-weight: 700; + line-height: 1; +} + +.empty { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* --- Hover detail strip (bottom of swatch area) --- */ +.hoverDetail { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + margin-top: 8px; + background: var(--bg-subtle); + border: 1px solid var(--border-light); + border-radius: 8px; + flex-shrink: 0; +} + +.hoverImg { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 4px; + flex-shrink: 0; +} + +.hoverInfo { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + font-size: 0.8rem; + color: var(--text); +} + +.hoverInfo strong { + font-size: 0.85rem; +} + +.hoverCode { + font-size: 0.7rem; + color: var(--text-muted); + font-family: monospace; +} + +.hoverColl { + font-size: 0.7rem; + color: var(--text-muted); +} + +.hoverPrice { + font-size: 0.75rem; + color: var(--success); + font-weight: 600; +} + +/* --- Footer: selection preview + action buttons --- */ +.footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-top: 1px solid var(--border-light); + background: var(--bg-subtle); + flex-shrink: 0; + gap: 12px; +} + +.preview { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--text); + min-width: 0; +} + +.preview strong { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.previewImg { + width: 28px; + height: 28px; + object-fit: cover; + border-radius: 4px; + border: 1px solid var(--border-light); + flex-shrink: 0; +} + +.previewDot { + width: 20px; + height: 20px; + border-radius: 50%; + border: 2px solid var(--border-light); + flex-shrink: 0; +} + +.previewLabel { + color: var(--text-muted); + font-size: 0.8rem; +} + +.actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.cancelBtn, +.confirmBtn, +.secondaryBtn { + padding: 7px 14px; + border-radius: 6px; + font-size: 0.82rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.cancelBtn { + background: #fff; + border: 1px solid var(--border-light); + color: var(--text); +} + +.cancelBtn:hover { + background: var(--bg-subtle); +} + +.secondaryBtn { + background: #fff; + border: 1px solid var(--action); + color: var(--action); +} + +.secondaryBtn:hover:not(:disabled) { + background: #eef2ff; +} + +.secondaryBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.confirmBtn { + background: var(--action); + border: none; + color: #fff; +} + +.confirmBtn:hover:not(:disabled) { + background: var(--action-hover); +} + +.confirmBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* --- Mobile --- */ +@media (max-width: 640px) { + .modal { + max-height: 95vh; + max-width: 100%; + } + + .header { + flex-wrap: wrap; + } + + .searchInput { + max-width: none; + order: 3; + flex-basis: 100%; + } + + .sidebar { + width: 100px; + } + + .swatchGrid { + grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); + } + + .footer { + flex-direction: column; + gap: 8px; + } + + .actions { + width: 100%; + justify-content: flex-end; + } +} + +/* AI Recommendations section */ +.aiSection { + padding: 8px 12px; + margin-bottom: 8px; + background: linear-gradient(135deg, #f8f7ff 0%, #eef2ff 100%); + border-radius: 8px; + border: 1px solid rgba(79, 70, 229, 0.15); +} + +.aiHeader { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} + +.aiBadge { + font-size: 0.55rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 1px 5px; + border-radius: 3px; + background: var(--action, #4f46e5); + color: #fff; +} + +.aiLabel { + font-size: 0.75rem; + color: var(--text-muted, #6b7280); + font-weight: 500; +} + +.aiSwatches { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.aiSwatch { + position: relative; + border-color: rgba(79, 70, 229, 0.3) !important; +} + +.aiSwatchBadge { + position: absolute; + top: -3px; + right: -3px; + font-size: 0.5rem; + background: var(--action, #4f46e5); + color: #fff; + width: 12px; + height: 12px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; +} + +.aiShimmer { + display: flex; + gap: 6px; +} + +.aiShimmerDot { + width: 48px; + height: 48px; + border-radius: 6px; + background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); + background-size: 200% 100%; + animation: aiShimmer 1.5s infinite; +} + +@keyframes aiShimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} diff --git a/client/src/components/CuttingListTable.jsx b/client/src/components/CuttingListTable.jsx new file mode 100644 index 0000000..d113583 --- /dev/null +++ b/client/src/components/CuttingListTable.jsx @@ -0,0 +1,108 @@ +/** + * CuttingListTable - Display cutting list in a table format + * Shows pieces grouped by finish with dimensions and quantities + */ +import { useMemo } from 'react'; +import styles from './CuttingListTable.module.css'; + +export default function CuttingListTable({ cuttingList, onExport }) { + if (!cuttingList || !cuttingList.pieces) { + return ( +
+ Aucune donnée de débitage disponible +
+ ); + } + + const { pieces, byFinish, summary } = cuttingList; + + return ( +
+ {/* Summary */} +
+

Résumé

+
+
+ Pièces totales + {summary.totalPieces} +
+
+ Surface totale + {summary.totalSurfaceM2.toFixed(2)} m² +
+
+ Chant plaqué + {summary.edgeBandingMl} ml +
+
+ Finitions + {summary.finishCount} +
+
+
+ + {/* Tables by finish */} + {byFinish.map((group, groupIndex) => ( +
+

+ {group.finishLabel} + + {group.pieceCount} pièces • {group.totalSurfaceM2.toFixed(2)} m² + +

+ + + + + + + + + + + + + + + {group.pieces.map((piece, pieceIndex) => ( + + + + + + + + + + ))} + +
DescriptionLargeurHauteurÉp.QtéSurfaceChant
{piece.description}{piece.widthMm}{piece.heightMm}{piece.thicknessMm}{piece.quantity}{piece.surfaceM2.toFixed(3)} + {piece.edgeBanding.type !== 'none' && ( + + {piece.edgeBanding.sides} côtés + + )} +
+
+ ))} + + {/* Export buttons */} + {onExport && ( +
+ + +
+ )} +
+ ); +} diff --git a/client/src/components/CuttingListTable.module.css b/client/src/components/CuttingListTable.module.css new file mode 100644 index 0000000..1aecab5 --- /dev/null +++ b/client/src/components/CuttingListTable.module.css @@ -0,0 +1,195 @@ +.container { + background: #fff; + border-radius: 8px; + overflow: hidden; +} + +.empty { + padding: 40px; + text-align: center; + color: #6b7280; +} + +.summary { + background: #f0f7ff; + padding: 16px; + border-bottom: 1px solid #cce0ff; +} + +.summaryTitle { + margin: 0 0 12px; + font-size: 1rem; + font-weight: 600; + color: #1e40af; +} + +.summaryGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +} + +.summaryItem { + display: flex; + flex-direction: column; + gap: 2px; +} + +.summaryLabel { + font-size: 0.75rem; + color: #6b7280; +} + +.summaryValue { + font-size: 1.1rem; + font-weight: 600; + color: #1f2937; +} + +.finishGroup { + padding: 16px; + border-bottom: 1px solid #e5e7eb; +} + +.finishGroup:last-of-type { + border-bottom: none; +} + +.finishTitle { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 12px; + font-size: 0.9rem; + font-weight: 500; +} + +.finishBadge { + padding: 4px 10px; + background: #4f46e5; + color: #fff; + border-radius: 4px; + font-size: 0.8rem; +} + +.finishSummary { + color: #6b7280; + font-size: 0.8rem; + font-weight: 400; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.table th { + padding: 8px 10px; + background: #f9fafb; + border: 1px solid #e5e7eb; + text-align: left; + font-weight: 600; + color: #374151; + font-size: 0.75rem; + text-transform: uppercase; +} + +.table td { + padding: 8px 10px; + border: 1px solid #e5e7eb; + color: #1f2937; +} + +.descCell { + min-width: 180px; +} + +.dimCell { + text-align: right; + font-family: monospace; + font-size: 0.85rem; +} + +.qtyCell { + text-align: center; + font-weight: 500; +} + +.surfaceCell { + text-align: right; + font-family: monospace; + font-size: 0.85rem; + color: #059669; +} + +.edgeCell { + text-align: center; +} + +.edgeBadge { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.7rem; + font-weight: 500; +} + +.edgeBadge.visible { + background: #dcfce7; + color: #166534; +} + +.edgeBadge.hidden { + background: #fef3c7; + color: #92400e; +} + +.exportActions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding: 16px; + border-top: 1px solid #e5e7eb; + background: #f9fafb; +} + +.exportBtn { + padding: 10px 20px; + border-radius: 6px; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + background: #fff; + border: 1px solid #d1d5db; + color: #374151; +} + +.exportBtn:hover { + background: #f3f4f6; +} + +.exportBtn.primary { + background: #4f46e5; + border-color: #4f46e5; + color: #fff; +} + +.exportBtn.primary:hover { + background: #4338ca; +} + +@media (max-width: 640px) { + .table { + font-size: 0.75rem; + } + + .descCell { + min-width: 100px; + } + + .summaryGrid { + grid-template-columns: repeat(2, 1fr); + } +} diff --git a/client/src/components/DesignTip.jsx b/client/src/components/DesignTip.jsx new file mode 100644 index 0000000..c3174ef --- /dev/null +++ b/client/src/components/DesignTip.jsx @@ -0,0 +1,121 @@ +/** + * DesignTip - AI-powered design advice card that fades in below the 3D preview. + * Shows contextual tips, compliments, and trends based on current configuration. + */ +import { useState, useEffect, useRef, useCallback } from 'react'; +import styles from './DesignTip.module.css'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +const CATEGORY_ICONS = { + compliment: '✨', + conseil: '💡', + tendance: '🎯' +}; + +export default function DesignTip({ config }) { + const [tip, setTip] = useState(null); + const [loading, setLoading] = useState(false); + const [visible, setVisible] = useState(false); + const debounceRef = useRef(null); + const abortRef = useRef(null); + + const fetchTip = useCallback(async (cfg) => { + // Cancel previous request + if (abortRef.current) abortRef.current.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + setLoading(true); + try { + const body = { + config: { + width: cfg.totalWidth, + height: cfg.height, + depth: cfg.depth, + installationType: cfg.installationType, + columns: cfg.modules?.map(m => ({ + type: m.type || m.moduleCode, + width: m.width, + shelves: m.shelves, + drawers: m.drawers, + hasRod: m.hasRod + })), + materialName: cfg.finishLabel || null, + doorType: cfg.doorType + } + }; + + const res = await fetch(`${API_BASE}/api/ai/design-tips`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal + }); + + if (!res.ok) throw new Error('API error'); + const data = await res.json(); + + if (data.tip) { + setTip(data); + setVisible(true); + } + } catch (err) { + if (err.name !== 'AbortError') { + // Silently fail — tip is optional + setVisible(false); + } + } finally { + setLoading(false); + } + }, []); + + // Debounced fetch on config change + useEffect(() => { + if (!config || !config.modules?.length) return; + + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + fetchTip(config); + }, 2000); // 2s debounce to avoid spamming on rapid changes + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [ + config?.totalWidth, + config?.height, + config?.modules?.length, + config?.modules?.map(m => m.type).join(','), + config?.finishId, + config?.doorType, + fetchTip + ]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (abortRef.current) abortRef.current.abort(); + }; + }, []); + + if (!visible && !loading) return null; + + return ( +
+ {loading && !tip && ( +
+
+
+
+ )} + {tip && ( +
+ {CATEGORY_ICONS[tip.category] || '✨'} +

{tip.tip}

+ IA +
+ )} +
+ ); +} diff --git a/client/src/components/DesignTip.module.css b/client/src/components/DesignTip.module.css new file mode 100644 index 0000000..05a65f9 --- /dev/null +++ b/client/src/components/DesignTip.module.css @@ -0,0 +1,78 @@ +.container { + margin-top: 8px; + padding: 10px 14px; + border-radius: 10px; + background: linear-gradient(135deg, #f8f7ff 0%, #f0f4ff 100%); + border: 1px solid var(--border-light, #e5e7eb); + opacity: 0; + transform: translateY(6px); + transition: opacity 0.4s ease, transform 0.4s ease; + min-height: 40px; +} + +.visible { + opacity: 1; + transform: translateY(0); +} + +.tipContent { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.icon { + font-size: 1.2rem; + flex-shrink: 0; + margin-top: 1px; +} + +.text { + margin: 0; + font-size: 0.85rem; + color: var(--text, #374151); + line-height: 1.45; + flex: 1; +} + +.badge { + flex-shrink: 0; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 6px; + border-radius: 4px; + background: var(--action, #4f46e5); + color: #fff; + margin-top: 2px; +} + +/* Loading shimmer */ +.shimmer { + display: flex; + flex-direction: column; + gap: 6px; +} + +.shimmerLine, +.shimmerLineShort { + height: 12px; + border-radius: 6px; + background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%); + background-size: 200% 100%; + animation: shimmer 1.5s infinite; +} + +.shimmerLine { + width: 80%; +} + +.shimmerLineShort { + width: 50%; +} + +@keyframes shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx new file mode 100644 index 0000000..62e4781 --- /dev/null +++ b/client/src/components/Layout.jsx @@ -0,0 +1,37 @@ +import { Link, useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import styles from './Layout.module.css'; + +export default function Layout({ children }) { + const { isAdmin, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = () => { + logout(); + navigate('/login', { replace: true }); + }; + + return ( +
+
+ SIGNATURE + +
+
+ {children} +
+
+ ); +} diff --git a/client/src/components/Layout.module.css b/client/src/components/Layout.module.css new file mode 100644 index 0000000..ca06ee3 --- /dev/null +++ b/client/src/components/Layout.module.css @@ -0,0 +1,63 @@ +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + background: var(--bg-card); + border-bottom: 1px solid var(--border); +} + +.logo { + font-weight: 700; + font-size: 1.25rem; + letter-spacing: 0.02em; + text-decoration: none; + color: var(--primary); +} + +.logo:hover { + text-decoration: none; + color: var(--accent); +} + +.header nav { + display: flex; + gap: 1.5rem; +} + +.header nav a { + color: var(--text-muted); + font-weight: 500; +} + +.header nav a:hover { + color: var(--primary); +} + +.header nav .logoutBtn { + background: none; + border: none; + padding: 0; + font: inherit; + color: var(--text-muted); + font-weight: 500; + cursor: pointer; +} + +.header nav .logoutBtn:hover { + color: var(--primary); +} + +.main { + flex: 1; + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 0 1.5rem; +} diff --git a/client/src/components/MaterialDetailModal.jsx b/client/src/components/MaterialDetailModal.jsx new file mode 100644 index 0000000..0b7a0ba --- /dev/null +++ b/client/src/components/MaterialDetailModal.jsx @@ -0,0 +1,205 @@ +import { useState, useEffect } from 'react'; +import { fetchMaterialDetails } from '../api/client'; +import styles from './MaterialDetailModal.module.css'; + +/** + * Modal for displaying detailed material information in the configurator. + * Shows product image, specs, dimension variants, and color variants. + * + * @param {Object} props + * @param {Object} props.material - The material object (basic info) + * @param {number|null} props.selectedDimensionId - Currently selected dimension variant ID + * @param {Function} props.onSelectDimension - Called when a dimension is selected (dimensionId) + * @param {Function} props.onSelectVariant - Called when a color variant is selected (material) + * @param {Function} props.onConfirm - Called when user confirms selection + * @param {Function} props.onClose - Called to close the modal + */ +export default function MaterialDetailModal({ + material, + selectedDimensionId, + onSelectDimension, + onSelectVariant, + onConfirm, + onClose +}) { + const [details, setDetails] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [localDimensionId, setLocalDimensionId] = useState(selectedDimensionId); + + useEffect(() => { + if (!material?.id) return; + + setLoading(true); + setError(null); + + fetchMaterialDetails(material.id) + .then(data => { + setDetails(data); + // Reset dimension to default when material changes + if (data.dimensions?.length > 0) { + const defaultDim = data.dimensions.find(d => d.is_default) || data.dimensions[0]; + setLocalDimensionId(defaultDim.id); + if (onSelectDimension) onSelectDimension(defaultDim.id); + } + setLoading(false); + }) + .catch(err => { + setError(err.message); + setLoading(false); + }); + }, [material?.id]); + + const handleDimensionClick = (dimId) => { + setLocalDimensionId(dimId); + if (onSelectDimension) { + onSelectDimension(dimId); + } + }; + + const handleVariantClick = (variant) => { + if (onSelectVariant) { + onSelectVariant(variant); + } + }; + + const handleConfirm = () => { + if (onConfirm) { + onConfirm(material.id, localDimensionId); + } + onClose(); + }; + + const selectedDim = details?.dimensions?.find(d => d.id === localDimensionId); + + return ( +
+
e.stopPropagation()}> + + + {loading &&
Chargement...
} + + {error &&
Erreur: {error}
} + + {!loading && !error && details && ( + <> +
+ {/* Product Image */} +
+ {details.image_url ? ( + {details.name} + ) : ( +
Pas d'image
+ )} +
+ + {/* Product Info */} +
+

{details.name}

+ +
+
+ Code: + {details.formex_code} +
+
+ Collection: + {details.collection} +
+ {details.category && ( +
+ Catégorie: + {details.category} +
+ )} + {details.price_chf && ( +
+ Prix: + {details.price_chf.toFixed(2)} CHF +
+ )} +
+ + {/* Dimension Variants */} + {details.dimensions && details.dimensions.length > 0 && ( +
+

Dimensions disponibles

+
+ {details.dimensions.map(dim => ( + + ))} +
+ {selectedDim && ( +

+ Format sélectionné: {selectedDim.width_mm} x {selectedDim.height_mm} x {selectedDim.thickness_mm} mm +

+ )} +
+ )} + + {/* Color Variants */} + {details.variants && details.variants.length > 0 && ( +
+

Autres coloris

+
+ {details.variants.map(variant => ( + + ))} +
+
+ )} + + {/* Formex Link */} + {details.formex_url && ( + + Voir sur Formex.ch → + + )} +
+
+ + {/* Actions */} +
+ + +
+ + )} +
+
+ ); +} diff --git a/client/src/components/MaterialDetailModal.module.css b/client/src/components/MaterialDetailModal.module.css new file mode 100644 index 0000000..70213e0 --- /dev/null +++ b/client/src/components/MaterialDetailModal.module.css @@ -0,0 +1,270 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal { + background: #fff; + border-radius: 12px; + max-width: 900px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.closeBtn { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 2rem; + cursor: pointer; + color: #666; + line-height: 1; + padding: 0; + z-index: 10; +} + +.closeBtn:hover { + color: #000; +} + +.loading, +.error { + padding: 3rem; + text-align: center; + font-size: 1.1rem; +} + +.error { + color: #c00; +} + +.content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2rem; + padding: 2rem; +} + +@media (max-width: 768px) { + .content { + grid-template-columns: 1fr; + } +} + +/* Image Section */ +.imageSection { + display: flex; + align-items: center; + justify-content: center; + background: #f8f9fa; + border-radius: 8px; + min-height: 300px; +} + +.productImage { + max-width: 100%; + max-height: 400px; + object-fit: contain; + border-radius: 8px; +} + +.noImage { + color: #999; + font-style: italic; +} + +/* Info Section */ +.infoSection { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.productName { + margin: 0; + font-size: 1.5rem; + font-weight: 600; + color: #1a1a1a; + line-height: 1.3; +} + +/* Specs */ +.specs { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 1rem; + background: #f8f9fa; + border-radius: 8px; +} + +.specRow { + display: flex; + gap: 0.75rem; +} + +.specLabel { + font-weight: 500; + color: #666; + min-width: 100px; +} + +.specValue { + color: #1a1a1a; +} + +/* Dimensions */ +.dimensionsSection, +.variantsSection { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sectionTitle { + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #333; +} + +.dimensionButtons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.dimensionBtn { + padding: 0.5rem 1rem; + border: 2px solid #ddd; + border-radius: 6px; + background: #fff; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; +} + +.dimensionBtn:hover { + border-color: var(--action); +} + +.dimensionBtn.selected { + border-color: var(--action); + background: #eef2ff; + color: var(--action); + font-weight: 500; +} + +.selectedDimInfo { + margin: 0; + font-size: 0.9rem; + color: #666; +} + +/* Color Variants */ +.variantThumbs { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.variantThumb { + width: 60px; + height: 60px; + border: 2px solid #ddd; + border-radius: 6px; + padding: 2px; + background: #fff; + cursor: pointer; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.variantThumb:hover { + border-color: var(--action); + transform: scale(1.05); +} + +.variantThumb img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; +} + +.variantCode { + font-size: 0.7rem; + color: #666; + text-align: center; +} + +/* Formex Link */ +.formexLink { + display: inline-block; + color: var(--action); + text-decoration: none; + font-size: 0.9rem; + margin-top: auto; +} + +.formexLink:hover { + text-decoration: underline; +} + +/* Actions -- sticky at bottom */ +.actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + padding: 1.5rem 2rem; + border-top: 1px solid var(--border-light); + background: var(--bg-subtle); + border-radius: 0 0 12px 12px; + position: sticky; + bottom: 0; + z-index: 5; +} + +.cancelBtn, +.confirmBtn { + padding: 0.75rem 1.5rem; + border-radius: 6px; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.cancelBtn { + background: #fff; + border: 1px solid #ddd; + color: #666; +} + +.cancelBtn:hover { + background: #f5f5f5; +} + +.confirmBtn { + background: var(--action); + border: none; + color: #fff; + font-weight: 500; +} + +.confirmBtn:hover { + background: var(--action-hover); +} diff --git a/client/src/components/MaterialModal.jsx b/client/src/components/MaterialModal.jsx new file mode 100644 index 0000000..cf5a05b --- /dev/null +++ b/client/src/components/MaterialModal.jsx @@ -0,0 +1,201 @@ +import { useState, useEffect } from 'react'; +import { createFormexMaterial, updateFormexMaterial } from '../api/client'; +import styles from './MaterialModal.module.css'; + +export default function MaterialModal({ material, collections, onClose, onSave }) { + const isEditing = !!material; + + const [formData, setFormData] = useState({ + formex_code: '', + name: '', + collection: '', + width_mm: '', + height_mm: '', + thickness_mm: '', + formex_url: '' + }); + + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (material) { + setFormData({ + formex_code: material.formex_code || '', + name: material.name || '', + collection: material.collection || '', + width_mm: material.width_mm?.toString() || '', + height_mm: material.height_mm?.toString() || '', + thickness_mm: material.thickness_mm?.toString() || '', + formex_url: material.formex_url || '' + }); + } + }, [material]); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + // Validation + if (!formData.formex_code.trim() || !formData.name.trim() || !formData.collection.trim()) { + setError('Le code, le nom et la collection sont requis.'); + return; + } + + setSaving(true); + + try { + const payload = { + formex_code: formData.formex_code.trim(), + name: formData.name.trim(), + collection: formData.collection.trim(), + width_mm: formData.width_mm ? parseInt(formData.width_mm, 10) : null, + height_mm: formData.height_mm ? parseInt(formData.height_mm, 10) : null, + thickness_mm: formData.thickness_mm ? parseInt(formData.thickness_mm, 10) : null, + formex_url: formData.formex_url.trim() || null + }; + + if (isEditing) { + await updateFormexMaterial(material.id, payload); + } else { + await createFormexMaterial(payload); + } + + onSave(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + const handleOverlayClick = (e) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+
+

{isEditing ? 'Modifier le matériau' : 'Nouveau matériau'}

+ +
+ +
+ {error &&

{error}

} + +
+ + +
+ +
+ + +
+ +
+ + + + {collections.map((c) => ( + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/client/src/components/MaterialModal.module.css b/client/src/components/MaterialModal.module.css new file mode 100644 index 0000000..b30e61c --- /dev/null +++ b/client/src/components/MaterialModal.module.css @@ -0,0 +1,135 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal { + background: #fff; + border-radius: 8px; + max-width: 500px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); +} + +.header h2 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--primary); +} + +.closeBtn { + background: none; + border: none; + font-size: 1.5rem; + color: var(--text-muted); + cursor: pointer; + padding: 0; + line-height: 1; +} + +.closeBtn:hover { + color: var(--primary); +} + +.form { + padding: 1.5rem; +} + +.error { + color: #b91c1c; + font-size: 0.875rem; + margin: 0 0 1rem; + padding: 0.75rem; + background: #fef2f2; + border-radius: 6px; +} + +.field { + margin-bottom: 1rem; +} + +.field label { + display: block; + font-size: 0.875rem; + font-weight: 500; + margin-bottom: 0.375rem; + color: var(--primary); +} + +.field input { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.875rem; +} + +.field input:focus { + outline: none; + border-color: var(--accent); +} + +.row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; +} + +.actions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +.cancelBtn { + padding: 0.5rem 1rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 0.875rem; + cursor: pointer; +} + +.cancelBtn:hover { + border-color: var(--accent); +} + +.saveBtn { + padding: 0.5rem 1.25rem; + background: var(--accent); + border: none; + border-radius: 6px; + color: #fff; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; +} + +.saveBtn:hover:not(:disabled) { + background: var(--primary); +} + +.saveBtn:disabled { + opacity: 0.6; + cursor: not-allowed; +} diff --git a/client/src/components/Plans2DView.jsx b/client/src/components/Plans2DView.jsx new file mode 100644 index 0000000..43ab886 --- /dev/null +++ b/client/src/components/Plans2DView.jsx @@ -0,0 +1,184 @@ +/** + * Plans2DView - Display 2D plans (assembly, side, top views) + * With tabs for different views and export options + */ +import { useState, useEffect, useCallback } from 'react'; +import CuttingListTable from './CuttingListTable'; +import styles from './Plans2DView.module.css'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +export default function Plans2DView({ config, modules = [], colors = {}, onClose }) { + const [activeTab, setActiveTab] = useState('assembly'); + const [plans, setPlans] = useState(null); + const [cuttingList, setCuttingList] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch plans and cutting list + useEffect(() => { + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + // Fetch plans + const plansRes = await fetch(`${API_BASE}/api/plans/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + const plansData = await plansRes.json(); + + if (plansData.success) { + setPlans(plansData.plans); + } + + // Fetch cutting list + const cutRes = await fetch(`${API_BASE}/api/plans/cutting-list`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + const cutData = await cutRes.json(); + + if (cutData.success) { + setCuttingList(cutData.cuttingList); + } + + } catch (err) { + console.error('Error fetching plans:', err); + setError('Erreur lors de la génération des plans'); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [config, modules, colors]); + + // Export handlers + const handleExport = useCallback(async (format) => { + try { + if (format === 'text') { + const res = await fetch(`${API_BASE}/api/plans/export-text`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'liste-debitage.txt'; + a.click(); + URL.revokeObjectURL(url); + + } else if (format === 'pdf') { + const res = await fetch(`${API_BASE}/api/plans/export-pdf`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config, modules, colors }) + }); + const html = await res.text(); + + // Open in new window for printing + const printWindow = window.open('', '_blank'); + printWindow.document.write(html); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + } + } catch (err) { + console.error('Export error:', err); + alert('Erreur lors de l\'export'); + } + }, [config, modules, colors]); + + const tabs = [ + { id: 'assembly', label: 'Vue de face' }, + { id: 'side', label: 'Vue de côté' }, + { id: 'top', label: 'Vue de dessus' }, + { id: 'cutting', label: 'Liste de débitage' } + ]; + + return ( +
+
+
+

Plans de fabrication

+ +
+ +
+ {tabs.map(tab => ( + + ))} +
+ +
+ {loading ? ( +
Génération des plans...
+ ) : error ? ( +
{error}
+ ) : ( + <> + {activeTab === 'assembly' && plans?.assembly && ( +
+ )} + + {activeTab === 'side' && plans?.side && ( +
+ )} + + {activeTab === 'top' && plans?.top && ( +
+ )} + + {activeTab === 'cutting' && ( + + )} + + )} +
+ +
+
+ {config.widthMm} × {config.heightMm} × {config.depthMm} mm + • + {config.installationType === 'niche' ? 'Encastrée' : 'Libre'} + • + {config.doorType === 'sliding' ? 'Coulissantes' : 'Battantes'} +
+
+ +
+
+
+
+ ); +} diff --git a/client/src/components/Plans2DView.module.css b/client/src/components/Plans2DView.module.css new file mode 100644 index 0000000..8a6df7b --- /dev/null +++ b/client/src/components/Plans2DView.module.css @@ -0,0 +1,192 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 20px; +} + +.modal { + background: #fff; + border-radius: 12px; + width: 100%; + max-width: 1000px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-light); +} + +.title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--text); +} + +.closeBtn { + background: none; + border: none; + font-size: 1.5rem; + color: var(--text-muted); + cursor: pointer; + padding: 4px 8px; + line-height: 1; +} + +.closeBtn:hover { + color: var(--text); +} + +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border-light); + padding: 0 20px; + background: var(--bg-subtle); +} + +.tab { + padding: 12px 20px; + background: none; + border: none; + font-size: 0.9rem; + font-weight: 500; + color: var(--text-muted); + cursor: pointer; + position: relative; + transition: color 0.2s; +} + +.tab:hover { + color: var(--action); +} + +.tab.active { + color: var(--action); +} + +.tab.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: var(--action); +} + +.content { + flex: 1; + overflow: auto; + padding: 20px; + min-height: 400px; +} + +.loading, +.error { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 0.9rem; +} + +.error { + color: var(--danger); +} + +.planContainer { + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: var(--bg-subtle); + border-radius: 8px; + min-height: 300px; +} + +.planContainer svg { + max-width: 100%; + height: auto; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-top: 1px solid var(--border-light); + background: var(--bg-subtle); +} + +.specs { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.85rem; + color: var(--text-muted); +} + +.actions { + display: flex; + gap: 10px; +} + +.exportBtn { + padding: 10px 20px; + background: var(--action); + border: none; + border-radius: 6px; + color: #fff; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.exportBtn:hover { + background: var(--action-hover); +} + +@media (max-width: 768px) { + .modal { + max-height: 95vh; + } + + .tabs { + overflow-x: auto; + padding: 0 10px; + } + + .tab { + padding: 10px 14px; + white-space: nowrap; + font-size: 0.85rem; + } + + .footer { + flex-direction: column; + gap: 12px; + } + + .specs { + flex-wrap: wrap; + justify-content: center; + } +} diff --git a/client/src/components/ProtectedRoute.jsx b/client/src/components/ProtectedRoute.jsx new file mode 100644 index 0000000..06c4190 --- /dev/null +++ b/client/src/components/ProtectedRoute.jsx @@ -0,0 +1,13 @@ +import { Navigate, useLocation } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; + +export default function ProtectedRoute({ children }) { + const { isAdmin } = useAuth(); + const location = useLocation(); + + if (!isAdmin) { + return ; + } + + return children; +} diff --git a/client/src/components/Room3D.jsx b/client/src/components/Room3D.jsx new file mode 100644 index 0000000..3bf43e1 --- /dev/null +++ b/client/src/components/Room3D.jsx @@ -0,0 +1,370 @@ +import { useMemo } from 'react'; +import * as THREE from 'three'; + +/** + * Room3D component - Renders a 3D room based on AI analysis + * Used to display the wardrobe in context of the user's actual room + */ + +// Floor material based on type +function FloorMaterial({ material, color }) { + const texture = useMemo(() => { + // In a real implementation, you'd load actual textures + // For now, we use colors with some roughness variation + return null; + }, [material]); + + return ( + + ); +} + +// Get material properties from wall material type +function getWallMaterialProps(material, texture) { + const props = { roughness: 0.8, metalness: 0.02 }; + switch (material) { + case 'paint': + props.roughness = texture === 'glossy' ? 0.3 : texture === 'smooth' ? 0.7 : 0.8; + break; + case 'wallpaper': + props.roughness = 0.85; + break; + case 'brick': + props.roughness = 0.95; + props.metalness = 0.0; + break; + case 'wood_panel': + props.roughness = 0.65; + props.metalness = 0.05; + break; + case 'concrete': + props.roughness = 0.9; + props.metalness = 0.01; + break; + case 'plaster': + props.roughness = 0.85; + break; + case 'stone': + props.roughness = 0.92; + props.metalness = 0.0; + break; + case 'tile': + props.roughness = 0.3; + props.metalness = 0.05; + break; + default: + break; + } + return props; +} + +// Wall with optional window/door cutouts +function Wall({ position, rotation, size, color, material, texture, windows = [], doors = [] }) { + const [width, height] = size; + const matProps = getWallMaterialProps(material, texture); + + // Create wall geometry with cutouts (simplified - just overlays for now) + return ( + + {/* Main wall */} + + + + + + {/* Windows */} + {windows.map((win, i) => { + const winWidth = width * win.width; + const winHeight = height * win.height; + const winX = (win.x - 0.5) * width; + const winY = (win.y - 0.5) * height; + + return ( + + {/* Window glass */} + + + + + {/* Window frame */} + + + + + + ); + })} + + {/* Doors */} + {doors.map((door, i) => { + const doorWidth = width * door.width; + const doorHeight = height * door.height; + const doorX = (door.x - 0.5) * width; + const doorY = -(height / 2) + (doorHeight / 2); + + return ( + + {/* Door opening (darker rectangle) */} + + + + + {/* Door frame */} + + + + + + ); + })} + + ); +} + +export default function Room3D({ roomConfig, wardrobeConfig }) { + if (!roomConfig) return null; + + const { width, length, height, walls, windows, doors, floor, ceiling, wardrobePlacement } = roomConfig; + + // Calculate wall positions + const halfWidth = width / 2; + const halfLength = length / 2; + const halfHeight = height / 2; + + // Get windows/doors for each wall + const backWindows = windows.filter(w => w.wall === 'back').map(w => ({ + x: w.x, y: w.y, width: w.width, height: w.height + })); + const leftWindows = windows.filter(w => w.wall === 'left').map(w => ({ + x: w.x, y: w.y, width: w.width, height: w.height + })); + const rightWindows = windows.filter(w => w.wall === 'right').map(w => ({ + x: w.x, y: w.y, width: w.width, height: w.height + })); + + const backDoors = doors.filter(d => d.wall === 'back').map(d => ({ + x: d.x, width: d.width, height: d.height + })); + const leftDoors = doors.filter(d => d.wall === 'left').map(d => ({ + x: d.x, width: d.width, height: d.height + })); + const rightDoors = doors.filter(d => d.wall === 'right').map(d => ({ + x: d.x, width: d.width, height: d.height + })); + + // Calculate wardrobe position based on placement + const getWardrobePosition = () => { + if (!wardrobeConfig || !wardrobePlacement) return [0, 0, 0]; + + const wardrobeWidth = (wardrobeConfig.widthMm || 2400) / 1000; + const wardrobeDepth = (wardrobeConfig.depthMm || 600) / 1000; + + switch (wardrobePlacement.wall) { + case 'back': + return [ + ((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 2, + 0, + -halfLength + wardrobeDepth / 2 + 0.05 + ]; + case 'left': + return [ + -halfWidth + wardrobeDepth / 2 + 0.05, + 0, + ((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2 + ]; + case 'right': + return [ + halfWidth - wardrobeDepth / 2 - 0.05, + 0, + ((wardrobePlacement.xPercent / 100) - 0.5) * length + wardrobeWidth / 2 + ]; + default: + return [0, 0, -halfLength + wardrobeDepth / 2 + 0.05]; + } + }; + + const wardrobePosition = getWardrobePosition(); + const wardrobeRotation = wardrobePlacement?.wall === 'left' + ? [0, Math.PI / 2, 0] + : wardrobePlacement?.wall === 'right' + ? [0, -Math.PI / 2, 0] + : [0, 0, 0]; + + return ( + + {/* Floor */} + + + + + + {/* Back wall */} + {walls?.back?.visible !== false && ( + + )} + + {/* Left wall */} + {walls?.left?.visible !== false && ( + + )} + + {/* Right wall */} + {walls?.right?.visible !== false && ( + + )} + + {/* Ceiling (plafond) */} + {ceiling?.visible !== false && (() => { + const ceilingColor = ceiling?.color || '#ffffff'; + const ceilingType = ceiling?.type || 'flat'; + const slopeAngle = ceiling?.slopeAngleDeg || 0; + const ceilingMatProps = getWallMaterialProps(ceiling?.material || 'paint', 'smooth'); + + if (ceilingType === 'sloped' && slopeAngle > 0) { + // Sloped ceiling - render as a tilted plane + const slopeRad = (slopeAngle * Math.PI) / 180; + const slopeDir = ceiling?.slopeDirection || 'front_to_back'; + const slopeRotX = slopeDir === 'front_to_back' ? Math.PI / 2 + slopeRad * 0.5 : Math.PI / 2; + const slopeRotZ = slopeDir === 'left_to_right' ? slopeRad * 0.5 : 0; + const slopeW = slopeDir === 'left_to_right' ? width / Math.cos(slopeRad * 0.5) : width; + const slopeL = slopeDir === 'front_to_back' ? length / Math.cos(slopeRad * 0.5) : length; + return ( + + + + + ); + } + + // Flat ceiling + return ( + + + + + ); + })()} + + {/* Wardrobe position indicator (for debugging/visualization) */} + {wardrobePlacement && ( + + + + + )} + + ); +} + +// Export position calculator for use by parent +export function calculateWardrobeTransform(roomConfig, wardrobeConfig) { + if (!roomConfig || !roomConfig.wardrobePlacement) { + return { position: [0, 0, 0], rotation: [0, 0, 0] }; + } + + const { width, length, wardrobePlacement } = roomConfig; + const halfWidth = width / 2; + const halfLength = length / 2; + const wardrobeWidth = (wardrobeConfig?.widthMm || 2400) / 1000; + const wardrobeDepth = (wardrobeConfig?.depthMm || 600) / 1000; + + let position, rotation; + + switch (wardrobePlacement.wall) { + case 'back': + position = [ + ((wardrobePlacement.xPercent / 100) - 0.5) * width + wardrobeWidth / 4, + 0, + -halfLength + wardrobeDepth / 2 + 0.05 + ]; + rotation = [0, 0, 0]; + break; + case 'left': + position = [ + -halfWidth + wardrobeDepth / 2 + 0.05, + 0, + ((wardrobePlacement.xPercent / 100) - 0.5) * length + ]; + rotation = [0, Math.PI / 2, 0]; + break; + case 'right': + position = [ + halfWidth - wardrobeDepth / 2 - 0.05, + 0, + ((wardrobePlacement.xPercent / 100) - 0.5) * length + ]; + rotation = [0, -Math.PI / 2, 0]; + break; + default: + position = [0, 0, -halfLength + wardrobeDepth / 2 + 0.05]; + rotation = [0, 0, 0]; + } + + return { position, rotation }; +} diff --git a/client/src/components/RoomUpload.jsx b/client/src/components/RoomUpload.jsx new file mode 100644 index 0000000..6c5f100 --- /dev/null +++ b/client/src/components/RoomUpload.jsx @@ -0,0 +1,234 @@ +import { useState, useRef, useCallback } from 'react'; +import styles from './RoomUpload.module.css'; + +/** + * RoomUpload component - Drag & drop or click to upload room photos + * Sends to AI for analysis and returns room configuration + */ +export default function RoomUpload({ onAnalysisComplete, onClose }) { + const [dragOver, setDragOver] = useState(false); + const [preview, setPreview] = useState(null); + const [file, setFile] = useState(null); + const [analyzing, setAnalyzing] = useState(false); + const [error, setError] = useState(null); + const [analysis, setAnalysis] = useState(null); + const fileInputRef = useRef(null); + + const handleDragOver = useCallback((e) => { + e.preventDefault(); + setDragOver(true); + }, []); + + const handleDragLeave = useCallback((e) => { + e.preventDefault(); + setDragOver(false); + }, []); + + const handleDrop = useCallback((e) => { + e.preventDefault(); + setDragOver(false); + const droppedFile = e.dataTransfer.files[0]; + if (droppedFile && droppedFile.type.startsWith('image/')) { + handleFile(droppedFile); + } else { + setError('Veuillez déposer une image (JPEG, PNG ou WebP)'); + } + }, []); + + const handleFile = (selectedFile) => { + setFile(selectedFile); + setError(null); + setAnalysis(null); + + // Create preview + const reader = new FileReader(); + reader.onload = (e) => { + setPreview(e.target.result); + }; + reader.readAsDataURL(selectedFile); + }; + + const handleFileSelect = (e) => { + const selectedFile = e.target.files[0]; + if (selectedFile) { + handleFile(selectedFile); + } + }; + + const handleAnalyze = async () => { + if (!file) return; + + setAnalyzing(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('image', file); + + const response = await fetch('/api/room/analyze', { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Erreur lors de l\'analyse'); + } + + setAnalysis(data); + } catch (err) { + setError(err.message); + } finally { + setAnalyzing(false); + } + }; + + const handleConfirm = () => { + if (analysis && onAnalysisComplete) { + onAnalysisComplete(analysis); + } + }; + + const handleReset = () => { + setFile(null); + setPreview(null); + setAnalysis(null); + setError(null); + }; + + return ( +
+
+ + +

Visualiser dans ma pièce

+

+ Téléchargez une photo de votre pièce et notre IA analysera l'espace pour y placer votre armoire. +

+ + {!preview ? ( +
fileInputRef.current?.click()} + > +
📷
+

+ Glissez-déposez une photo ici
+ ou cliquez pour sélectionner +

+

JPEG, PNG ou WebP - Max 10 MB

+ +
+ ) : ( +
+ Aperçu de la pièce + + {!analysis && !analyzing && ( + + )} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {analyzing && ( +
+
+

Analyse de votre pièce en cours...

+

L'IA détecte les dimensions, fenêtres et portes

+
+ )} + + {analysis && ( +
+

Analyse terminée

+ +
+
+ Dimensions estimées + + {analysis.analysis.roomDimensions.widthM}m × {analysis.analysis.roomDimensions.lengthM}m × {analysis.analysis.roomDimensions.heightM}m + +
+ +
+ Type de pièce + + {analysis.analysis.roomType === 'bedroom' ? 'Chambre' : + analysis.analysis.roomType === 'living_room' ? 'Salon' : + analysis.analysis.roomType === 'hallway' ? 'Couloir' : + analysis.analysis.roomType === 'dressing' ? 'Dressing' : 'Autre'} + +
+ +
+ Fenêtres détectées + {analysis.analysis.windows?.length || 0} +
+ +
+ Portes détectées + {analysis.analysis.doors?.length || 0} +
+ +
+ Mur suggéré pour l'armoire + + {analysis.analysis.suggestedWardrobeWall === 'back' ? 'Fond' : + analysis.analysis.suggestedWardrobeWall === 'left' ? 'Gauche' : 'Droite'} + +
+ +
+ Confiance + {analysis.analysis.confidence}% +
+
+ + {analysis.analysis.suggestedWardrobePosition?.reason && ( +

+ Suggestion : {analysis.analysis.suggestedWardrobePosition.reason} +

+ )} +
+ )} + +
+ + + {preview && !analysis && !analyzing && ( + + )} + + {analysis && ( + + )} +
+
+
+ ); +} diff --git a/client/src/components/RoomUpload.module.css b/client/src/components/RoomUpload.module.css new file mode 100644 index 0000000..ce91f83 --- /dev/null +++ b/client/src/components/RoomUpload.module.css @@ -0,0 +1,272 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; +} + +.modal { + background: #fff; + border-radius: 12px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + position: relative; + padding: 2rem; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.closeBtn { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 2rem; + cursor: pointer; + color: #666; + line-height: 1; +} + +.closeBtn:hover { + color: #000; +} + +.title { + margin: 0 0 0.5rem; + font-size: 1.5rem; + font-weight: 600; + color: #1a1a1a; +} + +.subtitle { + margin: 0 0 1.5rem; + color: #666; + font-size: 0.95rem; + line-height: 1.5; +} + +/* Dropzone */ +.dropzone { + border: 2px dashed #ccc; + border-radius: 12px; + padding: 3rem 2rem; + text-align: center; + cursor: pointer; + transition: all 0.2s; + background: #fafafa; +} + +.dropzone:hover, +.dropzone.dragOver { + border-color: #0066cc; + background: #f0f7ff; +} + +.dropIcon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.dropText { + margin: 0 0 0.5rem; + font-size: 1rem; + color: #333; +} + +.dropText span { + color: #0066cc; + text-decoration: underline; +} + +.dropHint { + margin: 0; + font-size: 0.85rem; + color: #999; +} + +.fileInput { + display: none; +} + +/* Preview */ +.previewContainer { + position: relative; + border-radius: 8px; + overflow: hidden; +} + +.previewImage { + width: 100%; + max-height: 300px; + object-fit: contain; + background: #f0f0f0; + border-radius: 8px; +} + +.changeBtn { + position: absolute; + bottom: 10px; + right: 10px; + background: rgba(0, 0, 0, 0.7); + color: #fff; + border: none; + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; +} + +.changeBtn:hover { + background: rgba(0, 0, 0, 0.9); +} + +/* Error */ +.error { + margin-top: 1rem; + padding: 0.75rem 1rem; + background: #fee2e2; + border: 1px solid #fca5a5; + border-radius: 8px; + color: #b91c1c; + font-size: 0.9rem; +} + +/* Analyzing */ +.analyzing { + margin-top: 1.5rem; + text-align: center; + padding: 2rem; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #e0e0e0; + border-top-color: #0066cc; + border-radius: 50%; + margin: 0 auto 1rem; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.analyzing p { + margin: 0; + color: #333; + font-weight: 500; +} + +.analyzingHint { + margin-top: 0.5rem !important; + font-size: 0.85rem; + color: #666 !important; + font-weight: 400 !important; +} + +/* Analysis Result */ +.analysisResult { + margin-top: 1.5rem; + padding: 1.25rem; + background: #f0fdf4; + border: 1px solid #86efac; + border-radius: 8px; +} + +.analysisResult h3 { + margin: 0 0 1rem; + font-size: 1rem; + font-weight: 600; + color: #166534; +} + +.resultGrid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.resultItem { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.resultLabel { + font-size: 0.8rem; + color: #666; +} + +.resultValue { + font-size: 0.95rem; + font-weight: 500; + color: #1a1a1a; +} + +.suggestion { + margin: 1rem 0 0; + padding-top: 1rem; + border-top: 1px solid #86efac; + font-size: 0.9rem; + color: #333; + line-height: 1.5; +} + +/* Actions */ +.actions { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid #eee; +} + +.cancelBtn, +.analyzeBtn, +.confirmBtn { + padding: 0.75rem 1.5rem; + border-radius: 8px; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.cancelBtn { + background: #fff; + border: 1px solid #ddd; + color: #666; +} + +.cancelBtn:hover { + background: #f5f5f5; +} + +.analyzeBtn { + background: #0066cc; + border: none; + color: #fff; + font-weight: 500; +} + +.analyzeBtn:hover { + background: #0052a3; +} + +.confirmBtn { + background: #16a34a; + border: none; + color: #fff; + font-weight: 500; +} + +.confirmBtn:hover { + background: #15803d; +} diff --git a/client/src/components/WardrobeElement.jsx b/client/src/components/WardrobeElement.jsx new file mode 100644 index 0000000..387693b --- /dev/null +++ b/client/src/components/WardrobeElement.jsx @@ -0,0 +1,447 @@ +/** + * WardrobeElement - Clickable 3D element for the wardrobe preview + * Each element can have its own color/finish and responds to click events + * Uses dominant color extracted from material swatch images for realistic preview + */ +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useThree } from '@react-three/fiber'; +import * as THREE from 'three'; + +// Default colors for different element types +const DEFAULT_COLORS = { + plintheBas: '#8b7355', + fileur: '#8b7355', + bandeauHaut: '#8b7355', + champs: '#6b5344', + caisse: '#c4a574', + cotesDoublage: '#c4a574', + cotes: '#b09060', + dessus: '#c4a574', + fond: '#d4c4a4', + dos: '#d4c4a4', + portes: '#a08060', + panneauxInterieurs: '#d4c4a4', + separateurs: '#6b5344', + etageres: '#d4c4a4', + tiroirs: '#c4a574', + penderie: '#8b7355' +}; + +// Element labels for display +const ELEMENT_LABELS = { + plintheBas: 'Plinthe bas', + fileur: 'Fileur', + bandeauHaut: 'Bandeau haut', + champs: 'Champs', + caisse: 'Caisse', + cotesDoublage: 'Côtés de doublage', + cotes: 'Côtés', + dessus: 'Dessus', + fond: 'Fond', + dos: 'Dos', + portes: 'Portes', + panneauxInterieurs: 'Panneaux intérieurs', + separateurs: 'Séparateurs', + etageres: 'Étagères', + tiroirs: 'Tiroirs', + penderie: 'Tringle penderie' +}; + +/** + * Get color from config or use default + */ +function getElementColor(elementType, colors) { + if (colors && colors[elementType]) { + if (colors[elementType].hexColor) { + return colors[elementType].hexColor; + } + } + return DEFAULT_COLORS[elementType] || '#cccccc'; +} + +/** + * Get image URL for element texture (if available) + */ +function getElementImageUrl(elementType, colors) { + if (colors && colors[elementType] && colors[elementType].imageUrl) { + return colors[elementType].imageUrl; + } + return null; +} + +/** + * Global cache: image URL → extracted dominant color (hex string) + * Avoids re-extracting colors for the same image URL + */ +const colorCache = new Map(); + +/** + * Extract the dominant color from the center of a swatch image. + * The Formex swatch images have black borders and watermarks, + * so we sample only the center 40% to get the actual material color. + * Returns a promise that resolves to a hex color string. + */ +function extractDominantColor(imageUrl) { + if (colorCache.has(imageUrl)) { + return colorCache.get(imageUrl); + } + + const promise = new Promise((resolve) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + // Sample center 40% of the image (avoids border + watermark) + const cropFraction = 0.4; + const sx = Math.floor(img.width * (1 - cropFraction) / 2); + const sy = Math.floor(img.height * (1 - cropFraction) / 2); + const sw = Math.floor(img.width * cropFraction); + const sh = Math.floor(img.height * cropFraction); + + // Draw cropped region to small canvas for fast sampling + canvas.width = 16; + canvas.height = 16; + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, 16, 16); + + const data = ctx.getImageData(0, 0, 16, 16).data; + let r = 0, g = 0, b = 0, count = 0; + for (let i = 0; i < data.length; i += 4) { + // Skip very dark pixels (might be border remnants) + if (data[i] < 15 && data[i+1] < 15 && data[i+2] < 15) continue; + r += data[i]; + g += data[i + 1]; + b += data[i + 2]; + count++; + } + if (count === 0) count = 1; + r = Math.round(r / count); + g = Math.round(g / count); + b = Math.round(b / count); + const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + resolve(hex); + } catch { + resolve(null); + } + }; + img.onerror = () => resolve(null); + img.src = imageUrl; + }); + + colorCache.set(imageUrl, promise); + return promise; +} + +/** + * Hook to extract the dominant color from a material swatch image URL. + * Returns a hex color string or null while loading. + */ +function useExtractedColor(imageUrl) { + const [extractedColor, setExtractedColor] = useState(null); + + useEffect(() => { + if (!imageUrl) { + setExtractedColor(null); + return; + } + let cancelled = false; + extractDominantColor(imageUrl).then((hex) => { + if (!cancelled && hex) { + setExtractedColor(hex); + } + }); + return () => { cancelled = true; }; + }, [imageUrl]); + + return extractedColor; +} + +/** + * Global texture cache — stores cropped CanvasTextures (borders removed) + */ +const textureCache = new Map(); + +/** + * Load a material swatch image, crop out the borders/watermark, + * and create a Three.js CanvasTexture from the center region. + * Returns a cache entry { texture, error, loading }. + */ +function loadCroppedTexture(url) { + if (textureCache.has(url)) { + return textureCache.get(url); + } + + const entry = { texture: null, error: false, loading: true }; + textureCache.set(url, entry); + + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + // Crop center 60% to remove black border + FORMEX watermark + const cropFraction = 0.6; + const sx = Math.floor(img.width * (1 - cropFraction) / 2); + const sy = Math.floor(img.height * (1 - cropFraction) / 2); + const sw = Math.floor(img.width * cropFraction); + const sh = Math.floor(img.height * cropFraction); + + canvas.width = sw; + canvas.height = sh; + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh); + + const tex = new THREE.CanvasTexture(canvas); + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + tex.colorSpace = THREE.SRGBColorSpace; + tex.minFilter = THREE.LinearMipmapLinearFilter; + tex.magFilter = THREE.LinearFilter; + tex.needsUpdate = true; + + entry.texture = tex; + entry.loading = false; + } catch { + entry.error = true; + entry.loading = false; + } + }; + img.onerror = () => { + entry.error = true; + entry.loading = false; + }; + img.src = url; + + return entry; +} + +/** + * Hook to load a cropped texture from a swatch image URL. + * Returns a cloned texture with proper repeat settings, or null. + */ +function useCroppedTexture(imageUrl, geometryArgs, geometryType) { + const [texture, setTexture] = useState(null); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { mountedRef.current = false; }; + }, []); + + useEffect(() => { + if (!imageUrl) { + setTexture((prev) => { if (prev) prev.dispose(); return null; }); + return; + } + + const entry = loadCroppedTexture(imageUrl); + + if (entry.texture) { + const tex = entry.texture.clone(); + configureTextureRepeat(tex, geometryArgs, geometryType); + setTexture((prev) => { if (prev) prev.dispose(); return tex; }); + return; + } + + if (entry.error) { + setTexture((prev) => { if (prev) prev.dispose(); return null; }); + return; + } + + // Poll until loaded + const interval = setInterval(() => { + if (!mountedRef.current) { clearInterval(interval); return; } + if (entry.texture) { + const tex = entry.texture.clone(); + configureTextureRepeat(tex, geometryArgs, geometryType); + setTexture((prev) => { if (prev) prev.dispose(); return tex; }); + clearInterval(interval); + } else if (entry.error) { + setTexture((prev) => { if (prev) prev.dispose(); return null; }); + clearInterval(interval); + } + }, 50); + + return () => clearInterval(interval); + }, [imageUrl, geometryType, geometryArgs]); + + // Update repeat when geometry changes (but same texture) + useEffect(() => { + if (texture) { + configureTextureRepeat(texture, geometryArgs, geometryType); + } + }, [geometryArgs, geometryType]); + + return texture; +} + +/** + * Configure texture repeat based on geometry dimensions. + * The cropped swatch represents approximately 0.3m x 0.3m of real material. + */ +function configureTextureRepeat(tex, geometryArgs, geometryType) { + if (!tex || !geometryArgs) return; + + const TEXTURE_PATCH_SIZE = 0.3; // meters + let widthM, heightM; + + switch (geometryType) { + case 'box': + widthM = geometryArgs[0] || 1; + heightM = geometryArgs[1] || 1; + break; + case 'plane': + widthM = geometryArgs[0] || 1; + heightM = geometryArgs[1] || 1; + break; + case 'cylinder': + widthM = 0.3; + heightM = 0.3; + break; + default: + widthM = 1; + heightM = 1; + } + + tex.repeat.set( + Math.max(1, widthM / TEXTURE_PATCH_SIZE), + Math.max(1, heightM / TEXTURE_PATCH_SIZE) + ); + tex.needsUpdate = true; +} + +/** + * WardrobeElement - A single clickable 3D mesh with optional texture + */ +export default function WardrobeElement({ + elementType, + geometryType = 'box', + geometryArgs = [1, 1, 1], + position = [0, 0, 0], + rotation = [0, 0, 0], + colors = {}, + onElementClick, + castShadow = true, + receiveShadow = true, + transparent = false, + opacity = 1, + materialProps = {} +}) { + const [hovered, setHovered] = useState(false); + const { gl } = useThree(); + const pointerDownPos = useRef(null); + const materialRef = useRef(); + + const color = useMemo(() => + getElementColor(elementType, colors), + [elementType, colors] + ); + + const imageUrl = useMemo(() => + getElementImageUrl(elementType, colors), + [elementType, colors] + ); + + // Extract dominant color from swatch image (immediate visual feedback) + const extractedColor = useExtractedColor(imageUrl); + + // Load cropped texture (for materials with visible grain/pattern) + const texture = useCroppedTexture(imageUrl, geometryArgs, geometryType); + + // Determine the display color: extracted > hex > default + const baseColor = extractedColor || color; + + // Hover effect - slightly brighter color + const hoverColor = useMemo(() => { + if (!hovered) return baseColor; + const hex = baseColor.replace('#', ''); + const r = Math.min(255, parseInt(hex.slice(0, 2), 16) + 30); + const g = Math.min(255, parseInt(hex.slice(2, 4), 16) + 30); + const b = Math.min(255, parseInt(hex.slice(4, 6), 16) + 30); + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`; + }, [baseColor, hovered]); + + const handlePointerOver = useCallback((e) => { + e.stopPropagation(); + setHovered(true); + gl.domElement.style.cursor = 'pointer'; + }, [gl]); + + const handlePointerOut = useCallback((e) => { + e.stopPropagation(); + setHovered(false); + gl.domElement.style.cursor = 'auto'; + }, [gl]); + + // Track pointer position on down to distinguish clicks from drags + const handlePointerDown = useCallback((e) => { + e.stopPropagation(); + pointerDownPos.current = { x: e.clientX, y: e.clientY }; + }, []); + + const handleClick = useCallback((e) => { + e.stopPropagation(); + if (!pointerDownPos.current) return; + const dx = e.clientX - pointerDownPos.current.x; + const dy = e.clientY - pointerDownPos.current.y; + const distance = Math.sqrt(dx * dx + dy * dy); + pointerDownPos.current = null; + if (distance > 5) return; // Was a drag, not a click + if (onElementClick) { + onElementClick(elementType, ELEMENT_LABELS[elementType] || elementType); + } + }, [elementType, onElementClick]); + + const geometry = useMemo(() => { + switch (geometryType) { + case 'plane': + return ; + case 'cylinder': + return ; + case 'box': + default: + return ; + } + }, [geometryType, geometryArgs]); + + // Imperatively force material update when the texture map reference changes + // (R3F sometimes misses null↔Texture transitions without a shader recompile) + useEffect(() => { + const mat = materialRef.current; + if (!mat) return; + mat.map = texture || null; + mat.needsUpdate = true; + }, [texture]); + + // Determine current color for the material + const displayColor = texture ? (hovered ? '#e8e8e8' : '#ffffff') : hoverColor; + + return ( + + {geometry} + + + ); +} + +// Export helper functions and constants +export { DEFAULT_COLORS, ELEMENT_LABELS, getElementColor }; diff --git a/client/src/components/WardrobePreview.jsx b/client/src/components/WardrobePreview.jsx new file mode 100644 index 0000000..b0c195f --- /dev/null +++ b/client/src/components/WardrobePreview.jsx @@ -0,0 +1,801 @@ +import { useRef, Suspense, useMemo, useState, useCallback, forwardRef, useImperativeHandle } from 'react'; +import { Canvas, useThree } from '@react-three/fiber'; +import { OrbitControls, Html } from '@react-three/drei'; +import * as THREE from 'three'; +import Room3D, { calculateWardrobeTransform } from './Room3D'; +import WardrobeElement from './WardrobeElement'; +import styles from './WardrobePreview.module.css'; + +const SCALE = 1 / 1000; +const PANEL_THICKNESS = 0.019; // 19mm in meters + +/* =================================================================== + ResizableSeparator - a draggable handle between two modules + =================================================================== */ +function ResizableSeparator({ + position, + height, + depth, + separatorIndex, + colors, + onElementClick, + isResizing, + onResizePointerDown, +}) { + const [hovered, setHovered] = useState(false); + const { gl } = useThree(); + const meshRef = useRef(); + + const handlePointerOver = useCallback((e) => { + e.stopPropagation(); + setHovered(true); + gl.domElement.style.cursor = 'col-resize'; + }, [gl]); + + const handlePointerOut = useCallback((e) => { + e.stopPropagation(); + if (!isResizing) { + setHovered(false); + gl.domElement.style.cursor = 'auto'; + } + }, [gl, isResizing]); + + const handlePointerDown = useCallback((e) => { + e.stopPropagation(); + if (onResizePointerDown) { + onResizePointerDown(separatorIndex, e); + } + }, [separatorIndex, onResizePointerDown]); + + const baseColor = hovered || isResizing ? '#4f46e5' : '#d4c4a4'; + + return ( + + {/* Visual panel (thin) */} + + {/* Invisible wider hit area for grabbing */} + + + + + {/* Highlight overlay when hovered/resizing */} + {(hovered || isResizing) && ( + + + + + )} + + ); +} + +/* =================================================================== + WidthLabel - floating HTML label showing module width in mm + =================================================================== */ +function WidthLabel({ position, widthMm }) { + return ( + +
+ {Math.round(widthMm)}mm +
+ + ); +} + +/* =================================================================== + DrawerBox - renders drawer front faces stacked vertically + =================================================================== */ +function DrawerBox({ moduleCenter, moduleWidth, bodyH, plinthH, drawersCount, colors, onElementClick }) { + const drawerHeight = 0.15 * SCALE * 1000; // ~150mm in scene units + const gap = 0.008; // gap between drawers + const totalDrawerZone = Math.min(bodyH * 0.6, drawersCount * (drawerHeight + gap)); + const actualDrawerH = (totalDrawerZone - gap * (drawersCount - 1)) / drawersCount; + const startY = -bodyH / 2 + PANEL_THICKNESS; + + return ( + <> + {Array.from({ length: drawersCount }).map((_, di) => { + const y = startY + actualDrawerH / 2 + di * (actualDrawerH + gap); + return ( + 0 ? 0.28 : 0]} + colors={colors} + onElementClick={onElementClick} + materialProps={{ roughness: 0.5, metalness: 0.05 }} + /> + ); + })} + + ); +} + +/* =================================================================== + WardrobeMesh - Full wardrobe model with drag/resize/drawers + =================================================================== */ +function WardrobeMesh({ + config, + colors = {}, + onElementClick, + onModuleClick, + selectedModuleIndex, + modules = [], + showDoors = true, + onResizeModules, +}) { + const { gl, camera, raycaster } = useThree(); + const useModules = modules && modules.length > 0; + + // --- Dimensions --- + const totalWidth = useModules + ? modules.reduce((s, m) => s + (m.widthMm || 0), 0) + : config.widthMm || 2400; + + const width = totalWidth * SCALE; + const height = (config.heightMm || 2400) * SCALE; + const depth = (config.depthMm || 600) * SCALE; + const plinthH = (config.plinthHeightMm || 100) * SCALE; + const bodyH = height - plinthH; + const isNiche = config.installationType === 'niche'; + const isSliding = config.doorType === 'sliding'; + + // --- Module positions --- + const modulePositions = useMemo(() => { + const items = useModules ? modules : []; + const result = []; + let offset = 0; + items.forEach((item) => { + const w = (item.widthMm || 0) * SCALE; + result.push({ x0: offset, width: w, center: -width / 2 + offset + w / 2 }); + offset += w; + }); + return result; + }, [useModules, modules, width]); + + // --- Separator positions (between modules) --- + const separatorPositions = useMemo(() => { + const items = useModules ? modules : []; + return items.slice(0, -1).map((item, i) => { + const x = modulePositions[i].x0 + modulePositions[i].width; + return { x: -width / 2 + x, index: i }; + }); + }, [modulePositions, width, useModules, modules]); + + // --- Hover state for width labels --- + const [hoveredModule, setHoveredModule] = useState(null); + const [resizeState, setResizeState] = useState(null); // { separatorIndex, startX } + const planeRef = useRef(new THREE.Plane(new THREE.Vector3(0, 1, 0), 0)); + const intersectPoint = useRef(new THREE.Vector3()); + + // --- Helpers to project pointer to world X --- + const getWorldX = useCallback((e) => { + const rect = gl.domElement.getBoundingClientRect(); + const mouse = new THREE.Vector2( + ((e.clientX - rect.left) / rect.width) * 2 - 1, + -((e.clientY - rect.top) / rect.height) * 2 + 1 + ); + raycaster.setFromCamera(mouse, camera); + planeRef.current.set(new THREE.Vector3(0, 1, 0), -height / 2); + if (raycaster.ray.intersectPlane(planeRef.current, intersectPoint.current)) { + return intersectPoint.current.x; + } + return null; + }, [gl, camera, raycaster, height]); + + // --- Resize pointer down --- + const handleResizePointerDown = useCallback((separatorIndex, e) => { + e.stopPropagation(); + const worldX = getWorldX(e); + if (worldX === null) return; + setResizeState({ separatorIndex, startX: worldX }); + }, [getWorldX]); + + // Attach window-level pointer events for resize + const onPointerMoveRef = useRef(null); + const onPointerUpRef = useRef(null); + + useMemo(() => { + const handleMove = (e) => { + if (resizeState) { + const worldX = getWorldX(e); + if (worldX !== null) { + const deltaX = worldX - resizeState.startX; + const deltaXmm = deltaX / SCALE; + if (onResizeModules && Math.abs(deltaXmm) > 1) { + onResizeModules(resizeState.separatorIndex, deltaXmm); + setResizeState(prev => prev ? { ...prev, startX: worldX } : null); + } + } + } + }; + + const handleUp = () => { + if (resizeState) { + setResizeState(null); + gl.domElement.style.cursor = 'auto'; + } + }; + + onPointerMoveRef.current = handleMove; + onPointerUpRef.current = handleUp; + }, [resizeState, getWorldX, onResizeModules, gl]); + + // Attach/detach event listeners + useMemo(() => { + const el = gl.domElement; + const move = (e) => onPointerMoveRef.current?.(e); + const up = (e) => onPointerUpRef.current?.(e); + + el.addEventListener('pointermove', move); + el.addEventListener('pointerup', up); + + return () => { + el.removeEventListener('pointermove', move); + el.removeEventListener('pointerup', up); + }; + }, [gl]); + + // Disable OrbitControls during resize + const interactionActive = resizeState !== null; + + return ( + + {/* Plinthe bas */} + + + {/* Bandeau haut */} + + + {/* Dessus (top panel) */} + + + {/* Fond (bottom panel) */} + + + {/* Côté gauche */} + + + {/* Côté droit */} + + + {/* Dos (back panel) */} + + + {/* Fileurs (si encastré) */} + {isNiche && ( + <> + + + + )} + + {/* Separators between modules (resizable) */} + {useModules && onResizeModules + ? separatorPositions.map((pos, i) => ( + + )) + : separatorPositions.map((pos, i) => ( + + )) + } + + {/* Width labels -- show during resize or hover */} + {modules.length > 0 && (resizeState !== null || hoveredModule !== null) && ( + <> + {resizeState !== null && ( + <> + {modules[resizeState.separatorIndex] && ( + + )} + {modules[resizeState.separatorIndex + 1] && ( + + )} + + )} + {resizeState === null && hoveredModule !== null && modules[hoveredModule] && ( + + )} + + )} + + {/* Module contents: doors, shelves, rods, drawers + click-to-select */} + {useModules && modules.map((module, moduleIndex) => { + const pos = modulePositions[moduleIndex]; + if (!pos) return null; + const moduleCenter = pos.center; + const moduleWidth = pos.width; + const shelfCount = module.shelves || 0; + const drawersCount = module.drawers || 0; + const isSelected = selectedModuleIndex === moduleIndex; + + return ( + + {/* Invisible click/hover area for module selection */} + setHoveredModule(moduleIndex)} + onPointerOut={() => setHoveredModule(prev => prev === moduleIndex ? null : prev)} + onClick={(e) => { + e.stopPropagation(); + if (onModuleClick) onModuleClick(moduleIndex); + }} + > + + + + + {/* Selection highlight */} + {isSelected && ( + + + + + )} + + {/* Portes battantes per module */} + {showDoors && !isSliding && ( + + )} + + {/* Shelves */} + {Array.from({ length: shelfCount }).map((_, shelfIndex) => { + const shelfY = -height / 2 + plinthH + PANEL_THICKNESS + + ((bodyH - PANEL_THICKNESS * 2) / (shelfCount + 1)) * (shelfIndex + 1); + return ( + + ); + })} + + {/* Drawers */} + {drawersCount > 0 && ( + + )} + + {/* Rod (penderie) */} + {module.hasRod && ( + + )} + + ); + })} + + {/* Portes coulissantes (sliding) */} + {showDoors && isSliding && ( + + )} + + {/* Module content is rendered above in the module loop */} + {/* Non-module door rendering is also above */} + + {/* Pass interaction state to parent for OrbitControls toggling */} + + + ); +} + +/* =================================================================== + InteractionBlocker - disables OrbitControls during drag/resize + Uses useThree to access the controls + =================================================================== */ +function InteractionBlocker({ active }) { + const { controls } = useThree(); + useMemo(() => { + if (controls) { + controls.enabled = !active; + } + }, [active, controls]); + return null; +} + +/* =================================================================== + CameraController - exposes zoom/reset via ref + =================================================================== */ +const CameraController = forwardRef(({ roomConfig, config }, ref) => { + const { camera, gl } = useThree(); + const controlsRef = useRef(); + + const minDistance = roomConfig ? 1 : 0.5; + const maxDistance = roomConfig ? 15 : 8; + + const cameraTarget = useMemo(() => { + if (roomConfig) { + return [0, roomConfig.height / 2, 0]; + } + return [0, (config.heightMm || 2400) * SCALE * 0.5, 0]; + }, [roomConfig, config]); + + useImperativeHandle(ref, () => ({ + zoomIn: () => { + if (controlsRef.current) { + const currentDistance = camera.position.distanceTo(controlsRef.current.target); + const newDistance = Math.max(minDistance, currentDistance * 0.8); + const direction = camera.position.clone().sub(controlsRef.current.target).normalize(); + camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance)); + controlsRef.current.update(); + } + }, + zoomOut: () => { + if (controlsRef.current) { + const currentDistance = camera.position.distanceTo(controlsRef.current.target); + const newDistance = Math.min(maxDistance, currentDistance * 1.25); + const direction = camera.position.clone().sub(controlsRef.current.target).normalize(); + camera.position.copy(controlsRef.current.target).add(direction.multiplyScalar(newDistance)); + controlsRef.current.update(); + } + }, + resetView: () => { + if (controlsRef.current) { + const defaultDist = roomConfig ? Math.max(roomConfig.width, roomConfig.length) * 1.2 : 3; + camera.position.set(defaultDist * 0.7, defaultDist * 0.5, defaultDist * 0.7); + controlsRef.current.update(); + } + } + }), [camera, minDistance, maxDistance, roomConfig]); + + return ( + + ); +}); + +/* =================================================================== + Scene - assembles everything + =================================================================== */ +function Scene({ config, roomConfig, controllerRef, colors, modules, onElementClick, onModuleClick, selectedModuleIndex, showDoors, onResizeModules }) { + const wardrobeTransform = useMemo(() => { + if (roomConfig) { + return calculateWardrobeTransform(roomConfig, config); + } + return { position: [0, 0, 0], rotation: [0, 0, 0] }; + }, [roomConfig, config]); + + return ( + <> + + + + + {roomConfig && ( + + )} + + + + + + + + ); +} + +/* =================================================================== + WardrobePreview - main exported component + =================================================================== */ +export default function WardrobePreview({ + config, + roomConfig, + colors = {}, + modules = [], + onElementClick, + onModuleClick, + onResizeModules, + selectedModuleIndex, +}) { + const containerRef = useRef(null); + const controllerRef = useRef(null); + const [selectedElement, setSelectedElement] = useState(null); + const [showDoors, setShowDoors] = useState(true); + + const cameraPosition = useMemo(() => { + if (roomConfig) { + const roomSize = Math.max(roomConfig.width, roomConfig.length); + return [roomSize * 0.8, roomSize * 0.5, roomSize * 0.8]; + } + return [2.5, 1.5, 2.5]; + }, [roomConfig]); + + const handleZoomIn = useCallback(() => { + controllerRef.current?.zoomIn(); + }, []); + + const handleZoomOut = useCallback(() => { + controllerRef.current?.zoomOut(); + }, []); + + const handleResetView = useCallback(() => { + controllerRef.current?.resetView(); + }, []); + + const handleToggleDoors = useCallback(() => { + setShowDoors(prev => !prev); + }, []); + + const handleElementClick = useCallback((elementType, label) => { + setSelectedElement({ type: elementType, label }); + if (onElementClick) { + onElementClick(elementType, label); + } + }, [onElementClick]); + + // All element types that can be color-picked (including back panel) + const PICKABLE_ELEMENTS = useMemo(() => [ + { type: 'portes', label: 'Portes' }, + { type: 'cotes', label: 'Côtés' }, + { type: 'dessus', label: 'Dessus' }, + { type: 'fond', label: 'Fond (bas)' }, + { type: 'dos', label: 'Dos (arrière)' }, + { type: 'plintheBas', label: 'Plinthe' }, + { type: 'bandeauHaut', label: 'Bandeau haut' }, + { type: 'panneauxInterieurs', label: 'Panneaux int.' }, + { type: 'separateurs', label: 'Séparateurs' }, + { type: 'etageres', label: 'Étagères' }, + { type: 'tiroirs', label: 'Tiroirs' }, + { type: 'penderie', label: 'Tringle' }, + ], []); + + const handleElementSelect = useCallback((e) => { + const val = e.target.value; + if (!val) return; + const item = PICKABLE_ELEMENTS.find(p => p.type === val); + if (item) { + handleElementClick(item.type, item.label); + } + // Reset select to placeholder + e.target.value = ''; + }, [PICKABLE_ELEMENTS, handleElementClick]); + + return ( +
+ {selectedElement && ( +
+ Élément sélectionné: {selectedElement.label} +
+ )} + + {/* Zoom Controls */} +
+ + + +
+ +
+ +
+ + Chargement 3D…
}> + + + + + + {/* Hint */} +
+ Cliquer un module = configurer {'\u2022'} Cliquer un {'\u00e9'}l{'\u00e9'}ment = couleur {'\u2022'} Tirer s{'\u00e9'}parateurs = redimensionner +
+
+ ); +} diff --git a/client/src/components/WardrobePreview.module.css b/client/src/components/WardrobePreview.module.css new file mode 100644 index 0000000..d4e98a1 --- /dev/null +++ b/client/src/components/WardrobePreview.module.css @@ -0,0 +1,123 @@ +.container { + position: relative; + width: 100%; + height: 100%; + min-height: 280px; + border-radius: 8px; + overflow: hidden; +} + +.controls { + position: absolute; + top: 10px; + right: 10px; + display: flex; + flex-direction: column; + gap: 6px; + z-index: 10; +} + +.controlBtn { + width: 36px; + height: 36px; + border: none; + border-radius: 8px; + background: rgba(255, 255, 255, 0.9); + color: #333; + font-size: 1.25rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: all 0.2s; +} + +.controlBtn:hover { + background: #fff; + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.controlBtn:active { + transform: scale(0.95); +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + font-size: 0.875rem; +} + +.hint { + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + padding: 4px 12px; + background: rgba(0, 0, 0, 0.5); + color: #fff; + font-size: 0.75rem; + border-radius: 12px; + white-space: nowrap; + pointer-events: none; + opacity: 0.7; +} + +.selectedElement { + position: absolute; + top: 10px; + left: 10px; + padding: 8px 14px; + background: rgba(255, 255, 255, 0.95); + color: #333; + font-size: 0.85rem; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 10; +} + +.selectedElement strong { + color: var(--action); +} + +.controlDivider { + width: 24px; + height: 1px; + background: rgba(0, 0, 0, 0.15); + margin: 4px 6px; +} + +.controlBtnActive { + background: var(--action); + color: #fff; +} + +.controlBtnActive:hover { + background: var(--action-hover); +} + +.elementSelect { + font-size: 0.75rem; + padding: 4px 6px; + border: 1px solid rgba(0, 0, 0, 0.15); + border-radius: 6px; + background: rgba(255, 255, 255, 0.95); + color: #333; + cursor: pointer; + max-width: 110px; + outline: none; +} + +.elementSelect:hover { + border-color: var(--action); +} + +.elementSelect:focus { + border-color: var(--action); + box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.2); +} diff --git a/client/src/context/AuthContext.jsx b/client/src/context/AuthContext.jsx new file mode 100644 index 0000000..4389ff1 --- /dev/null +++ b/client/src/context/AuthContext.jsx @@ -0,0 +1,38 @@ +import { createContext, useContext, useState, useEffect } from 'react'; + +const STORAGE_KEY = 'admin'; +const ADMIN_PASSWORD = 'ketimapex'; + +const AuthContext = createContext(null); + +export function AuthProvider({ children }) { + const [isAdmin, setIsAdmin] = useState(() => typeof window !== 'undefined' && sessionStorage.getItem(STORAGE_KEY) === 'true'); + + useEffect(() => { + if (typeof window === 'undefined') return; + if (isAdmin) sessionStorage.setItem(STORAGE_KEY, 'true'); + else sessionStorage.removeItem(STORAGE_KEY); + }, [isAdmin]); + + const login = (password) => { + if (password === ADMIN_PASSWORD) { + setIsAdmin(true); + return true; + } + return false; + }; + + const logout = () => setIsAdmin(false); + + return ( + + {children} + + ); +} + +export function useAuth() { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..d40f421 --- /dev/null +++ b/client/src/index.css @@ -0,0 +1,51 @@ +:root { + --bg: #fafaf9; + --bg-card: #ffffff; + --bg-subtle: #f9fafb; + --text: #374151; + --text-muted: #6b7280; + --accent: #78716c; + --wood: #a8a29e; + --border: #e7e5e4; + --border-light: #e5e7eb; + --primary: #1c1917; + --action: #4f46e5; + --action-hover: #4338ca; + --success: #059669; + --warning: #f59e0b; + --danger: #dc2626; + --font: 'DM Sans', system-ui, sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: var(--font); + font-size: 16px; + line-height: 1.5; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +button { + font-family: inherit; + cursor: pointer; +} + +input, select { + font-family: inherit; + font-size: 1rem; +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000..47742c1 --- /dev/null +++ b/client/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/client/src/pages/Admin.jsx b/client/src/pages/Admin.jsx new file mode 100644 index 0000000..c1ede66 --- /dev/null +++ b/client/src/pages/Admin.jsx @@ -0,0 +1,240 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import { fetchOrders, fetchSyncLogs, getFormexSyncStreamUrl } from '../api/client'; +import styles from './Admin.module.css'; + +export default function Admin() { + const { isAdmin, logout } = useAuth(); + const navigate = useNavigate(); + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + // Formex sync state + const [syncLogs, setSyncLogs] = useState([]); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + + // SSE live progress state + const [liveMessages, setLiveMessages] = useState([]); + const [progress, setProgress] = useState({ current: 0, total: 0, collection: '' }); + const logsEndRef = useRef(null); + + useEffect(() => { + if (!isAdmin) { + navigate('/login?next=/admin', { replace: true }); + return; + } + // Fetch orders and sync logs in parallel + Promise.all([fetchOrders(), fetchSyncLogs(10)]) + .then(([ordersData, logsData]) => { + setOrders(ordersData); + setSyncLogs(logsData); + }) + .catch((err) => setError(err.message)) + .finally(() => setLoading(false)); + }, [isAdmin, navigate]); + + // Auto-scroll to bottom of live logs + useEffect(() => { + if (logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [liveMessages]); + + const handleLogout = () => { + logout(); + navigate('/', { replace: true }); + }; + + const handleFormexSync = () => { + setSyncing(true); + setSyncResult(null); + setLiveMessages([]); + setProgress({ current: 0, total: 0, collection: '' }); + + const eventSource = new EventSource(getFormexSyncStreamUrl()); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'log') { + setLiveMessages(prev => [...prev, data.message]); + } else if (data.type === 'progress') { + setProgress({ + current: data.current, + total: data.total, + collection: data.collection || '' + }); + } else if (data.type === 'complete') { + setSyncResult(data.result); + setSyncing(false); + eventSource.close(); + // Refresh sync logs + fetchSyncLogs(10).then(setSyncLogs).catch(() => {}); + } else if (data.type === 'error') { + setSyncResult({ success: false, error: data.error }); + setSyncing(false); + eventSource.close(); + } + } catch (e) { + console.error('SSE parse error:', e); + } + }; + + eventSource.onerror = () => { + if (syncing) { + setSyncResult({ success: false, error: 'Connexion perdue' }); + setSyncing(false); + } + eventSource.close(); + }; + }; + + const progressPercent = progress.total > 0 + ? Math.round((progress.current / progress.total) * 100) + : 0; + + if (!isAdmin) return null; + + return ( +
+
+

Administration

+ +
+ {loading &&

Chargement…

} + {error &&

{error}

} + + {/* Formex Sync Section */} + {!loading && !error && ( +
+

Synchronisation Formex

+
+ + {syncResult && !syncing && ( + + {syncResult.success + ? `${syncResult.itemsCount} produits synchronisés (${syncResult.duration}s)` + : `Erreur: ${syncResult.error}`} + + )} +
+ + {/* Progress bar and live logs during sync */} + {syncing && ( +
+
+
+ {progress.collection || 'Initialisation…'} + {progressPercent}% +
+
+
+
+
+ {progress.current} / {progress.total} collections +
+
+
+
+ {liveMessages.map((msg, idx) => ( +
{msg}
+ ))} +
+
+
+
+ )} + + {syncLogs.length > 0 && !syncing && ( +
+

Historique des synchronisations

+ + + + + + + + + + + + {syncLogs.map((log) => ( + + + + + + + + ))} + +
DateSourceStatutProduitsErreur
{log.created_at ? new Date(log.created_at).toLocaleString('fr-CH') : '—'}{log.source} + {log.status === 'success' ? 'Succès' : 'Erreur'} + {log.items_count ?? '—'}{log.error || '—'}
+
+ )} +
+ )} + + {/* Orders Section */} + {!loading && !error && ( +
+

Commandes

+
+ + + + + + + + + + + + + + {orders.length === 0 ? ( + + + + ) : ( + orders.map((o) => ( + + + + + + + + + + )) + )} + +
IDDateTotal CHFNomEmailTéléphoneRemarques
+ Aucune commande. +
{o.id}{o.created_at ? new Date(o.created_at).toLocaleString('fr-CH') : '—'}{o.total_chf != null ? Number(o.total_chf).toLocaleString('fr-CH') : '—'}{o.name || '—'}{o.email || '—'}{o.phone || '—'}{o.notes || '—'}
+
+
+ )} +
+ ); +} diff --git a/client/src/pages/Admin.module.css b/client/src/pages/Admin.module.css new file mode 100644 index 0000000..c6b05b3 --- /dev/null +++ b/client/src/pages/Admin.module.css @@ -0,0 +1,240 @@ +.page { + padding: 1.5rem 0 4rem; +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.header h1 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + color: var(--primary); +} + +.logout { + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-muted); + font-size: 0.9375rem; + cursor: pointer; +} + +.logout:hover { + border-color: var(--accent); + color: var(--primary); +} + +.muted { + color: var(--text-muted); + font-size: 0.9375rem; + margin: 0; +} + +.error { + color: #b91c1c; + font-size: 0.9375rem; + margin: 0 0 1rem; +} + +.tableWrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 8px; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.table th, +.table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.table th { + background: var(--bg); + font-weight: 600; + color: var(--primary); +} + +.table tbody tr:last-child td { + border-bottom: none; +} + +.table tbody tr:hover { + background: var(--bg); +} + +.empty { + color: var(--text-muted); + text-align: center; + padding: 2rem; +} + +/* Section styling */ +.section { + margin-bottom: 2.5rem; +} + +.section h2 { + font-size: 1.125rem; + font-weight: 600; + color: var(--primary); + margin: 0 0 1rem; +} + +.section h3 { + font-size: 0.9375rem; + font-weight: 600; + color: var(--text-muted); + margin: 1.5rem 0 0.75rem; +} + +/* Sync actions */ +.syncActions { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.syncBtn { + padding: 0.625rem 1.25rem; + background: var(--accent); + border: none; + border-radius: 6px; + color: #fff; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.syncBtn:hover:not(:disabled) { + background: var(--primary); +} + +.syncBtn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.success { + color: #16a34a; +} + +/* Sync logs table */ +.logsWrap { + margin-top: 1rem; +} + +.logsTable { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.logsTable th, +.logsTable td { + padding: 0.5rem 0.75rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.logsTable th { + background: var(--bg); + font-weight: 600; + color: var(--text-muted); +} + +.logsTable tbody tr:last-child td { + border-bottom: none; +} + +/* Live sync progress */ +.liveSync { + margin-top: 1rem; + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.progressWrap { + padding: 1rem; + background: var(--bg); + border-bottom: 1px solid var(--border); +} + +.progressInfo { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + font-size: 0.875rem; +} + +.progressInfo span:first-child { + font-weight: 500; + color: var(--primary); +} + +.progressInfo span:last-child { + color: var(--text-muted); +} + +.progressBar { + width: 100%; + height: 8px; + background: var(--border); + border-radius: 4px; + overflow: hidden; +} + +.progressFill { + height: 100%; + background: var(--accent); + border-radius: 4px; + transition: width 0.3s ease; +} + +.progressStats { + margin-top: 0.5rem; + font-size: 0.75rem; + color: var(--text-muted); + text-align: right; +} + +.liveLogsPanel { + max-height: 300px; + overflow-y: auto; + background: #1e1e1e; +} + +.liveLogsContent { + padding: 0.75rem 1rem; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.75rem; + line-height: 1.5; +} + +.logLine { + color: #d4d4d4; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/client/src/pages/Configurator.jsx b/client/src/pages/Configurator.jsx new file mode 100644 index 0000000..b500c82 --- /dev/null +++ b/client/src/pages/Configurator.jsx @@ -0,0 +1,1170 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { + fetchFinishes, + fetchServiceOptions, + calculatePrice, + submitOrder +} from '../api/client'; +import WardrobePreview from '../components/WardrobePreview'; +import MaterialDetailModal from '../components/MaterialDetailModal'; +import RoomUpload from '../components/RoomUpload'; +import ColorPickerModal from '../components/ColorPickerModal'; +import Plans2DView from '../components/Plans2DView'; +import DesignTip from '../components/DesignTip'; +import AIRenderModal from '../components/AIRenderModal'; +import styles from './Configurator.module.css'; + +const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:3001'; + +// Column element types -- single source of truth +const COLUMN_TYPES = [ + { key: 'penderie', label: 'Penderie', icon: '👔', shelves: 1, drawers: 0, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1200 }, + { key: 'tiroirs', label: 'Tiroirs', icon: '🗄️', shelves: 0, drawers: 3, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 800 }, + { key: 'rayonnage', label: 'Rayonnage', icon: '📚', shelves: 4, drawers: 0, hasRod: false, defaultWidth: 500, minWidth: 400, maxWidth: 900 }, + { key: 'bloc_carre', label: 'Bloc carré', icon: '⬜', shelves: 0, drawers: 0, hasRod: false, defaultWidth: 400, minWidth: 300, maxWidth: 600 }, + { key: 'mixte', label: 'Mixte', icon: '📦', shelves: 2, drawers: 2, hasRod: true, defaultWidth: 600, minWidth: 400, maxWidth: 1000 }, +]; + +// Default cycling types when creating new columns +const DEFAULT_CYCLE = ['penderie', 'rayonnage', 'tiroirs']; + +// Default colors for each element type — must include ALL element types rendered in 3D +const DEFAULT_COLORS = { + plintheBas: { finishId: null, label: 'Non défini', hexColor: '#8b7355' }, + fileur: { finishId: null, label: 'Non défini', hexColor: '#8b7355' }, + bandeauHaut: { finishId: null, label: 'Non défini', hexColor: '#8b7355' }, + champs: { finishId: null, label: 'Non défini', hexColor: '#6b5344' }, + caisse: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, + cotesDoublage: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, + cotes: { finishId: null, label: 'Non défini', hexColor: '#b09060' }, + dessus: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, + fond: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, + dos: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, + portes: { finishId: null, label: 'Non défini', hexColor: '#a08060' }, + panneauxInterieurs: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, + separateurs: { finishId: null, label: 'Non défini', hexColor: '#6b5344' }, + etageres: { finishId: null, label: 'Non défini', hexColor: '#d4c4a4' }, + tiroirs: { finishId: null, label: 'Non défini', hexColor: '#c4a574' }, + penderie: { finishId: null, label: 'Non défini', hexColor: '#8b7355' } +}; + +const DEFAULT_CONFIG = { + installationType: 'free', + widthMm: 2400, + heightMm: 2400, + depthMm: 600, + plinthHeightMm: 100, + modules: [], + colors: { ...DEFAULT_COLORS }, + hardware: { + handleVariantId: null, + hingeVariantId: null, + drawerSystemId: null + }, + doorType: 'hinged', + slidingPanelsCount: 2, + finishId: null, + dimensionId: null, + serviceOptionIds: [] +}; + +export default function Configurator() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [finishes, setFinishes] = useState([]); + const [serviceOptions, setServiceOptions] = useState([]); + const [priceResult, setPriceResult] = useState(null); + const [loading, setLoading] = useState(true); + const [orderSent, setOrderSent] = useState(false); + const [orderForm, setOrderForm] = useState({ email: '', name: '', phone: '', notes: '' }); + + // Material detail modal state + const [selectedMaterial, setSelectedMaterial] = useState(null); + const [showMaterialModal, setShowMaterialModal] = useState(false); + const [materialFilter, setMaterialFilter] = useState(''); + + // Room visualization state + const [showRoomUpload, setShowRoomUpload] = useState(false); + const [roomConfig, setRoomConfig] = useState(null); + const [roomAnalysis, setRoomAnalysis] = useState(null); + const [roomViewActive, setRoomViewActive] = useState(false); + + // Column-based module state + const [numColumns, setNumColumns] = useState(3); + const [expandedColumn, setExpandedColumn] = useState(null); + + // Material picker modal state + const [showMaterialPicker, setShowMaterialPicker] = useState(false); + const [selectedCollection, setSelectedCollection] = useState('all'); + + // Hardware state + const [handleVariants, setHandleVariants] = useState([]); + const [hingeVariants, setHingeVariants] = useState([]); + const [blumHardware, setBlumHardware] = useState([]); + + // Color picker modal state + const [showColorPicker, setShowColorPicker] = useState(false); + const [colorPickerElement, setColorPickerElement] = useState(null); + + // Plans view + const [showPlansView, setShowPlansView] = useState(false); + const [showAIRender, setShowAIRender] = useState(false); + const [aiOptimizing, setAiOptimizing] = useState(false); + const [aiOptimizeMsg, setAiOptimizeMsg] = useState(''); + + + useEffect(() => { + Promise.all([fetchFinishes(), fetchServiceOptions()]) + .then(([fin, opt]) => { + setFinishes(fin); + setServiceOptions(opt); + if (fin.length && !config.finishId) { + const firstMaterial = fin[0]; + const defaultDim = firstMaterial.dimensions?.find(d => d.is_default) || firstMaterial.dimensions?.[0]; + setConfig((c) => ({ + ...c, + finishId: firstMaterial.id, + dimensionId: defaultDim?.id || null + })); + } + setLoading(false); + }) + .catch(console.error); + + // Fetch V2 data: hardware, handles, hinges + fetch(`${API_BASE}/api/handles`) + .then(r => r.json()) + .then(setHandleVariants) + .catch(console.error); + + fetch(`${API_BASE}/api/hinges`) + .then(r => r.json()) + .then(setHingeVariants) + .catch(console.error); + + fetch(`${API_BASE}/api/hardware`) + .then(r => r.json()) + .then(setBlumHardware) + .catch(console.error); + }, []); + + const updatePrice = useCallback(() => { + const totalW = config.modules.reduce((s, m) => s + (m.widthMm || 0), 0) || config.widthMm; + const payload = { + ...config, + widthMm: totalW, + blocks: config.modules.map((m) => ({ blockTypeId: 1, widthMm: m.widthMm })) + }; + calculatePrice(payload) + .then(setPriceResult) + .catch((err) => setPriceResult({ totalChf: 0, errors: [{ message: err.message }] })); + }, [config]); + + useEffect(() => { + if (!loading) updatePrice(); + }, [config, loading, updatePrice]); + + + const set = (key, value) => setConfig((c) => ({ ...c, [key]: value })); + + // V2: Column-based layout generation + const generateColumns = useCallback((nCols, totalW, existingModules = []) => { + // Calculate width: locked columns keep their width, rest share equally + const lockedWidth = existingModules + .filter((m, i) => i < nCols && m?.locked) + .reduce((s, m) => s + (m.widthMm || 0), 0); + const lockedCount = existingModules + .filter((m, i) => i < nCols && m?.locked).length; + const unlockedCount = nCols - lockedCount; + const remainingW = totalW - lockedWidth; + const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : Math.round(totalW / nCols); + + return Array.from({ length: nCols }, (_, i) => { + const existing = existingModules[i]; + // Locked columns keep everything + if (existing?.locked) { + return { ...existing, position: i }; + } + const typeKey = existing?.type || DEFAULT_CYCLE[i % DEFAULT_CYCLE.length]; + const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0]; + return { + id: existing?.id || `col-${Date.now()}-${i}`, + type: typeDef.key, + moduleCode: typeDef.key, + widthMm: colWidth, + minWidth: 300, + maxWidth: 1200, + position: i, + locked: false, + shelves: typeDef.shelves, + drawers: typeDef.drawers, + hasRod: typeDef.hasRod, + }; + }); + }, []); + + // Handle column count change + const handleNumColumnsChange = useCallback((newNum) => { + setNumColumns(newNum); + const modules = generateColumns(newNum, config.widthMm, config.modules); + setConfig(c => ({ ...c, modules })); + }, [config.widthMm, config.modules, generateColumns]); + + // AI layout optimization + const handleAIOptimize = useCallback(async () => { + setAiOptimizing(true); + setAiOptimizeMsg(''); + try { + const res = await fetch(`${API_BASE}/api/layout/optimize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + totalWidth: config.totalWidth || config.widthMm, + height: config.height, + depth: config.depth, + installationType: config.installationType, + modules: config.modules.map(m => ({ + type: m.type || m.moduleCode, + width: m.width, + locked: m.locked || false, + shelves: m.shelves, + drawers: m.drawers, + hasRod: m.hasRod + })) + }) + }); + if (!res.ok) throw new Error('API error'); + const data = await res.json(); + if (data.modules && Array.isArray(data.modules)) { + setConfig(c => { + const newModules = data.modules.map((m, i) => ({ + ...c.modules[i], + ...m, + id: c.modules[i]?.id || `col-${i}` + })); + return { ...c, modules: newModules }; + }); + setNumColumns(data.modules.length); + } + if (data.reasoning) { + setAiOptimizeMsg(data.reasoning); + } + } catch (err) { + setAiOptimizeMsg('Impossible d\'optimiser. Vérifiez la configuration IA.'); + } finally { + setAiOptimizing(false); + } + }, [config]); + + // Handle column type change + const handleColumnTypeChange = useCallback((index, typeKey) => { + const typeDef = COLUMN_TYPES.find(t => t.key === typeKey) || COLUMN_TYPES[0]; + setConfig(c => { + const modules = [...c.modules]; + modules[index] = { + ...modules[index], + type: typeDef.key, + moduleCode: typeDef.key, + shelves: typeDef.shelves, + drawers: typeDef.drawers, + hasRod: typeDef.hasRod, + }; + return { ...c, modules }; + }); + }, []); + + // Handle column lock toggle + const handleColumnLock = useCallback((index) => { + setConfig(c => { + const modules = [...c.modules]; + modules[index] = { ...modules[index], locked: !modules[index].locked }; + return { ...c, modules }; + }); + }, []); + + // Redistribute widths (keeps locked widths, redistributes rest) + const redistributeWidths = useCallback(() => { + setConfig(c => { + const modules = [...c.modules]; + const lockedWidth = modules.filter(m => m.locked).reduce((s, m) => s + m.widthMm, 0); + const unlockedCount = modules.filter(m => !m.locked).length; + const remainingW = c.widthMm - lockedWidth; + const colWidth = unlockedCount > 0 ? Math.round(remainingW / unlockedCount) : 0; + return { + ...c, + modules: modules.map(m => m.locked ? m : { ...m, widthMm: colWidth }) + }; + }); + }, []); + + // Re-generate layout when wardrobe width changes (if we have modules) + useEffect(() => { + if (config.modules.length > 0) { + redistributeWidths(); + } + }, [config.widthMm]); + + // Initialize columns on first load + useEffect(() => { + if (config.modules.length === 0) { + const modules = generateColumns(numColumns, config.widthMm, []); + setConfig(c => ({ ...c, modules })); + } + }, []); + + // Per-column property update (shelves, drawers, rod, etc.) + const handleColumnUpdate = useCallback((index, updates) => { + setConfig(c => { + const modules = [...c.modules]; + modules[index] = { ...modules[index], ...updates }; + return { ...c, modules }; + }); + }, []); + + // Module click from 3D view -- expand that column + const handleModuleClick = useCallback((moduleIndex) => { + setExpandedColumn(prev => prev === moduleIndex ? null : moduleIndex); + }, []); + + // V2: 3D resize callback (from dragging separators in 3D view) + const handleResizeModules = useCallback((separatorIndex, deltaXmm) => { + setConfig((c) => { + const modules = [...c.modules]; + const left = { ...modules[separatorIndex] }; + const right = { ...modules[separatorIndex + 1] }; + + const leftMin = left.minWidth || 300; + const leftMax = left.maxWidth || 1200; + const rightMin = right.minWidth || 300; + const rightMax = right.maxWidth || 1200; + + let newLeftW = left.widthMm + deltaXmm; + let newRightW = right.widthMm - deltaXmm; + + // Clamp + if (newLeftW < leftMin) { newRightW += (newLeftW - leftMin); newLeftW = leftMin; } + if (newLeftW > leftMax) { newRightW += (newLeftW - leftMax); newLeftW = leftMax; } + if (newRightW < rightMin) { newLeftW += (newRightW - rightMin); newRightW = rightMin; } + if (newRightW > rightMax) { newLeftW += (newRightW - rightMax); newRightW = rightMax; } + + left.widthMm = Math.round(newLeftW); + right.widthMm = Math.round(newRightW); + modules[separatorIndex] = left; + modules[separatorIndex + 1] = right; + return { ...c, modules }; + }); + }, []); + + // V2: Color management + const handleElementClick = (elementType, label) => { + setColorPickerElement({ type: elementType, label }); + setShowColorPicker(true); + }; + + const handleColorSelect = (elementType, finish) => { + setConfig((c) => ({ + ...c, + colors: { + ...c.colors, + [elementType]: { + finishId: finish.id, + label: finish.label || finish.name, + hexColor: finish.hexColor || '#c4a574', + imageUrl: finish.image_url || null + } + } + })); + }; + + const handleColorApplyToAll = (finish) => { + const colorEntry = { + finishId: finish.id, + label: finish.label || finish.name, + hexColor: finish.hexColor || '#c4a574', + imageUrl: finish.image_url || null + }; + const newColors = {}; + // Cover all standard element types + Object.keys(DEFAULT_COLORS).forEach(key => { + newColors[key] = { ...colorEntry }; + }); + // Also include any extra keys already in config.colors + Object.keys(config.colors).forEach(key => { + if (!newColors[key]) newColors[key] = { ...colorEntry }; + }); + setConfig((c) => ({ ...c, colors: newColors })); + }; + + // V2: Hardware management + const setHardware = (key, value) => { + setConfig((c) => ({ + ...c, + hardware: { ...c.hardware, [key]: value } + })); + }; + + + // Calculate total module width + const totalModuleWidth = useMemo(() => + config.modules.reduce((sum, m) => sum + (m.widthMm || 0), 0), + [config.modules] + ); + + const handleSubmitOrder = (e) => { + e.preventDefault(); + submitOrder({ config, totalChf: priceResult?.totalChf, ...orderForm }) + .then(() => setOrderSent(true)) + .catch(alert); + }; + + // Material selection handlers + const handleMaterialClick = (material) => { + setSelectedMaterial(material); + setShowMaterialModal(true); + }; + + const handleMaterialConfirm = (materialId, dimensionId) => { + // Also apply the selected material to all 3D element colors + const material = finishes.find(f => f.id === materialId); + setConfig((c) => { + const updated = { ...c, finishId: materialId, dimensionId }; + if (material) { + const colorEntry = { + finishId: material.id, + label: material.label || material.name, + hexColor: material.hexColor || '#c4a574', + imageUrl: material.image_url || null + }; + const newColors = {}; + Object.keys(DEFAULT_COLORS).forEach(key => { + newColors[key] = { ...colorEntry }; + }); + // Also include any extra keys already in config.colors + Object.keys(c.colors).forEach(key => { + if (!newColors[key]) newColors[key] = { ...colorEntry }; + }); + updated.colors = newColors; + } + return updated; + }); + setShowMaterialModal(false); + }; + + const handleVariantSwitch = (variant) => { + // When switching to a color variant, update the selectedMaterial + const variantMaterial = finishes.find(f => f.code === variant.variant_code); + if (variantMaterial) { + setSelectedMaterial(variantMaterial); + } + }; + + // Group finishes by collection for display + const materialsByCollection = finishes.reduce((acc, mat) => { + const coll = mat.collection || 'Autres'; + if (!acc[coll]) acc[coll] = []; + acc[coll].push(mat); + return acc; + }, {}); + + // Filter materials by search term and selected collection + const filteredCollections = Object.entries(materialsByCollection).reduce((acc, [coll, mats]) => { + // Filter by selected collection tab + if (selectedCollection !== 'all' && coll !== selectedCollection) return acc; + // Filter by search term + if (!materialFilter) { + acc[coll] = mats; + } else { + const filtered = mats.filter(m => + m.label?.toLowerCase().includes(materialFilter.toLowerCase()) || + m.code?.toLowerCase().includes(materialFilter.toLowerCase()) + ); + if (filtered.length > 0) acc[coll] = filtered; + } + return acc; + }, {}); + + const selectedFinish = finishes.find(f => f.id === config.finishId); + + if (loading) return
Chargement…
; + + const errors = priceResult?.errors || []; + const totalChf = priceResult?.totalChf ?? 0; + + return ( +
+

Configurer mon armoire

+ +
+
+
+

Installation

+ + +
+ +
+

Dimensions (mm)

+
+ + + +
+
+ +
+

Plinthe (60–160 mm)

+ +
+ +
+

Configuration intérieure

+ + {/* Column count selector */} +
+ Nombre de colonnes +
+ {[1, 2, 3, 4, 5, 6].map(n => ( + + ))} + +
+
+ {aiOptimizeMsg && ( +
+ IA + {aiOptimizeMsg} + +
+ )} + + {/* Expandable column list */} + {config.modules.length > 0 && ( +
+ {config.modules.map((mod, i) => { + const isExpanded = expandedColumn === i; + const typeDef = COLUMN_TYPES.find(t => t.key === mod.type) || COLUMN_TYPES[0]; + return ( +
+ {/* Collapsed header row */} +
setExpandedColumn(isExpanded ? null : i)} + > + {i + 1} + {typeDef.icon} {typeDef.label} + {mod.widthMm}mm + + ▸ +
+ + {/* Expanded detail controls */} + {isExpanded && ( +
+ {/* Type preset */} +
+ Type + +
+ + {/* Shelves counter */} +
+ Étagères +
+ + {mod.shelves || 0} + +
+
+ + {/* Drawers counter */} +
+ Tiroirs +
+ + {mod.drawers || 0} + +
+
+ + {/* Rod toggle */} +
+ Tringle + +
+
+ )} +
+ ); + })} +
+ )} + + {/* Summary */} + {config.modules.length > 0 && ( +
+

+ {config.modules.length} colonne{config.modules.length > 1 ? 's' : ''} — Largeur totale : {totalModuleWidth} mm +

+

+ Cliquer sur un module dans la vue 3D pour le configurer • Tirer les séparateurs pour redimensionner +

+
+ )} +
+ + {/* V2: Hardware section */} +
+

Quincaillerie

+ + {/* Handle variants */} +
+ +
+ {handleVariants.map(h => ( + + ))} +
+
+ + {/* Hinge variants */} +
+ +
+ {hingeVariants.map(h => ( + + ))} +
+
+ + {/* Drawer systems (if modules with drawers) */} + {config.modules.some(m => m.drawers > 0) && ( +
+ +
+ {blumHardware.filter(h => h.type === 'drawer_system').map(h => ( + + ))} +
+
+ )} +
+ +
+

Portes

+ + + {config.doorType === 'sliding' && ( + + )} +
+ +
+

Finitions / Matériaux

+ + {/* Current selection + browse button */} +
+ {selectedFinish ? ( +
setShowMaterialPicker(true)}> + {selectedFinish.image_url ? ( + {selectedFinish.label} + ) : ( +
?
+ )} +
+ {selectedFinish.label} + {selectedFinish.code} — {selectedFinish.collection} +
+
+ ) : ( + Aucun matériau sélectionné + )} + +
+
+ +
+

Options

+ {serviceOptions.map((opt) => ( + + ))} +
+
+ +
+ {/* Room visualization controls */} +
+ {roomViewActive && roomConfig ? ( + <> + + Vue: Ma pièce ({roomAnalysis?.roomDimensions?.widthM}m × {roomAnalysis?.roomDimensions?.lengthM}m) + +
+ + +
+ + ) : ( + <> + Vue: Simple +
+ {roomConfig && ( + + )} + + +
+ + )} +
+ +
+ +
+ +
+

Récapitulatif

+ {errors.length > 0 && ( +
    + {errors.map((err, i) => ( +
  • {err.message}
  • + ))} +
+ )} + + {/* Panel Optimization Info */} + {priceResult?.details?.panelOptimization && ( +
+

Optimisation panneaux

+
+
+ Format optimal : + + {priceResult.details.panelOptimization.selectedDimension.width_mm} x{' '} + {priceResult.details.panelOptimization.selectedDimension.height_mm} mm + +
+
+ Panneaux nécessaires : + {priceResult.details.panelOptimization.panelsNeeded} +
+
+ Surface requise : + {priceResult.surfaceM2?.toFixed(2)} m² +
+
+ Chute estimée : + {priceResult.details.panelOptimization.wasteM2?.toFixed(2)} m² ({priceResult.details.panelOptimization.wastePercent}%) +
+
+
+ )} + + {/* Plans button */} + + +

+ Total : CHF {totalChf.toLocaleString('fr-CH', { minimumFractionDigits: 2 })}.– +

+

Le prix tient compte des formats réels de fabrication.

+ {!orderSent ? ( +
+ setOrderForm((f) => ({ ...f, email: e.target.value }))} + required + /> + setOrderForm((f) => ({ ...f, name: e.target.value }))} + /> + setOrderForm((f) => ({ ...f, phone: e.target.value }))} + /> +