Compare commits
4 Commits
99a1aa6460
...
e236e1f673
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e236e1f673 | ||
|
|
beeee82896 | ||
|
|
0b70891bca | ||
|
|
4fab26106c |
296
dashboard/package-lock.json
generated
296
dashboard/package-lock.json
generated
@@ -8,8 +8,12 @@
|
|||||||
"name": "bordanlage-dashboard",
|
"name": "bordanlage-dashboard",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"maplibre-gl": "^5.21.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"spotify-web-api-js": "^1.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
@@ -739,6 +743,122 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/point-geometry": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/tiny-sdf": {
|
||||||
|
"version": "2.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
|
||||||
|
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/unitbezier": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/vector-tile": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "~1.1.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"pbf": "^4.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@mapbox/whoots-js": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "6.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
|
||||||
|
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||||
|
"version": "24.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz",
|
||||||
|
"integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"minimist": "^1.2.8",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"rw": "^1.3.3",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"gl-style-format": "dist/gl-style-format.mjs",
|
||||||
|
"gl-style-migrate": "dist/gl-style-migrate.mjs",
|
||||||
|
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/mlt": {
|
||||||
|
"version": "1.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
|
||||||
|
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
|
||||||
|
"license": "(MIT OR Apache-2.0)",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@maplibre/geojson-vt": "^5.0.4",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"@types/supercluster": "^7.1.3",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"supercluster": "^8.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/@react-leaflet/core": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
@@ -1148,6 +1268,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/supercluster": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
@@ -1262,6 +1397,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/earcut": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.325",
|
"version": "1.5.325",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.325.tgz",
|
||||||
@@ -1343,6 +1484,12 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gl-matrix": {
|
||||||
|
"version": "3.4.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||||
|
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -1362,6 +1509,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/json-stringify-pretty-compact": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/json5": {
|
"node_modules/json5": {
|
||||||
"version": "2.2.3",
|
"version": "2.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||||
@@ -1375,6 +1528,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kdbush": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@@ -1397,6 +1562,49 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/maplibre-gl": {
|
||||||
|
"version": "5.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz",
|
||||||
|
"integrity": "sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||||
|
"@mapbox/point-geometry": "^1.1.0",
|
||||||
|
"@mapbox/tiny-sdf": "^2.0.7",
|
||||||
|
"@mapbox/unitbezier": "^0.0.1",
|
||||||
|
"@mapbox/vector-tile": "^2.0.4",
|
||||||
|
"@mapbox/whoots-js": "^3.1.0",
|
||||||
|
"@maplibre/geojson-vt": "^6.0.4",
|
||||||
|
"@maplibre/maplibre-gl-style-spec": "^24.7.0",
|
||||||
|
"@maplibre/mlt": "^1.1.8",
|
||||||
|
"@maplibre/vt-pbf": "^4.3.0",
|
||||||
|
"@types/geojson": "^7946.0.16",
|
||||||
|
"earcut": "^3.0.2",
|
||||||
|
"gl-matrix": "^3.4.4",
|
||||||
|
"kdbush": "^4.0.2",
|
||||||
|
"murmurhash-js": "^1.0.0",
|
||||||
|
"pbf": "^4.0.1",
|
||||||
|
"potpack": "^2.1.0",
|
||||||
|
"quickselect": "^3.0.0",
|
||||||
|
"tinyqueue": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.14.0",
|
||||||
|
"npm": ">=8.1.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minimist": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -1404,6 +1612,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/murmurhash-js": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -1430,6 +1644,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pbf": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"resolve-protobuf-schema": "^2.1.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pbf": "bin/pbf"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -1466,6 +1692,24 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/potpack": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/protocol-buffers-schema": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/quickselect": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
@@ -1491,6 +1735,20 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-leaflet": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
|
||||||
|
"license": "Hippocratic-2.1",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-leaflet/core": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"leaflet": "^1.9.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -1501,6 +1759,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resolve-protobuf-schema": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"protocol-buffers-schema": "^3.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.0",
|
"version": "4.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz",
|
||||||
@@ -1546,6 +1813,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rw": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.23.2",
|
"version": "0.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||||
@@ -1575,6 +1848,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/spotify-web-api-js": {
|
||||||
|
"version": "1.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/spotify-web-api-js/-/spotify-web-api-js-1.5.2.tgz",
|
||||||
|
"integrity": "sha512-ie1gbg1wCabfobIkXTIBLUMyULS/hMCpF44Cdx2pAO0/+FrjhNSDjlDzcwCEDy+ZIo3Fscs+Gkg/GTeQ/ijo+Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/supercluster": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"kdbush": "^4.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tinyqueue": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|||||||
@@ -9,8 +9,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
|
"maplibre-gl": "^5.21.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "^5.0.0",
|
||||||
|
"spotify-web-api-js": "^1.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext.jsx'
|
||||||
|
import { SpotifyProvider } from './contexts/SpotifyContext.jsx'
|
||||||
import TopBar from './components/layout/TopBar.jsx'
|
import TopBar from './components/layout/TopBar.jsx'
|
||||||
import TabNav from './components/layout/TabNav.jsx'
|
import TabNav from './components/layout/TabNav.jsx'
|
||||||
import Overview from './pages/Overview.jsx'
|
import Overview from './pages/Overview.jsx'
|
||||||
@@ -18,13 +20,17 @@ export default function App() {
|
|||||||
const Page = PAGES[tab] || Overview
|
const Page = PAGES[tab] || Overview
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.app}>
|
<ThemeProvider>
|
||||||
<TopBar />
|
<SpotifyProvider>
|
||||||
<TabNav activeTab={tab} onTabChange={setTab} />
|
<div style={styles.app}>
|
||||||
<main style={styles.main}>
|
<TopBar />
|
||||||
<Page />
|
<TabNav activeTab={tab} onTabChange={setTab} />
|
||||||
</main>
|
<main style={styles.main}>
|
||||||
</div>
|
<Page />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</SpotifyProvider>
|
||||||
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,28 +63,35 @@ export default function NowPlaying({ compact = false }) {
|
|||||||
const styles = {
|
const styles = {
|
||||||
container: {
|
container: {
|
||||||
display: 'flex', gap: 16, padding: 16,
|
display: 'flex', gap: 16, padding: 16,
|
||||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
background: 'var(--glass-bg)',
|
||||||
border: '1px solid var(--border)',
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
animation: 'slideInDown 0.3s ease-out',
|
||||||
|
transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
compact: { padding: '10px 14px' },
|
compact: { padding: '10px 14px' },
|
||||||
cover: {
|
cover: {
|
||||||
width: 64, height: 64, flexShrink: 0,
|
width: 64, height: 64, flexShrink: 0,
|
||||||
background: 'var(--surface2)', borderRadius: 6,
|
background: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
},
|
},
|
||||||
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
|
coverImg: { width: '100%', height: '100%', objectFit: 'cover' },
|
||||||
coverIcon: { fontSize: 28, color: 'var(--muted)' },
|
coverIcon: { fontSize: 28, color: 'var(--muted)' },
|
||||||
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 },
|
info: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 6 },
|
||||||
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
title: { fontWeight: 600, fontSize: 14, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
|
||||||
artist: { fontSize: 12, color: 'var(--muted)' },
|
artist: { fontSize: 12, color: 'var(--muted)', fontWeight: 500 },
|
||||||
album: { fontSize: 11, color: 'var(--muted)', opacity: 0.7 },
|
album: { fontSize: 11, color: 'var(--muted)', opacity: 0.7 },
|
||||||
progressRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 },
|
progressRow: { display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 },
|
||||||
progressBg: { flex: 1, height: 3, background: 'var(--border)', borderRadius: 2, overflow: 'hidden' },
|
progressBg: { flex: 1, height: 3, background: 'rgba(255, 255, 255, 0.1)', borderRadius: 2, overflow: 'hidden' },
|
||||||
progressFill: { height: '100%', background: 'var(--accent)', borderRadius: 2, transition: 'width 1s linear' },
|
progressFill: { height: '100%', background: 'var(--accent)', borderRadius: 2, transition: 'width 1s linear' },
|
||||||
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30 },
|
timeText: { fontSize: 10, color: 'var(--muted)', fontFamily: 'var(--font-mono)', minWidth: 30, fontWeight: 500 },
|
||||||
controls: { display: 'flex', gap: 4, marginTop: 4 },
|
controls: { display: 'flex', gap: 6, marginTop: 4 },
|
||||||
btn: { width: 36, height: 36, fontSize: 14, background: 'var(--surface2)', color: 'var(--text)', minWidth: 36 },
|
btn: { width: 36, height: 36, fontSize: 14, background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)', minWidth: 36, border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)', transition: 'all 0.2s' },
|
||||||
playBtn: { background: 'var(--accent)', color: '#000', fontWeight: 700 },
|
playBtn: { background: 'var(--accent)', color: 'white', fontWeight: 700, border: 'none' },
|
||||||
}
|
}
|
||||||
|
|||||||
180
dashboard/src/components/audio/SpotifyAccountManager.jsx
Normal file
180
dashboard/src/components/audio/SpotifyAccountManager.jsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useSpotify } from '../../contexts/SpotifyContext.jsx'
|
||||||
|
|
||||||
|
export default function SpotifyAccountManager() {
|
||||||
|
const { accounts, addAccount, removeAccount } = useSpotify()
|
||||||
|
const [isAdding, setIsAdding] = useState(false)
|
||||||
|
const [form, setForm] = useState({ displayName: '', email: '' })
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
if (!form.email) return
|
||||||
|
|
||||||
|
addAccount({
|
||||||
|
id: `spotify-${Date.now()}`,
|
||||||
|
email: form.email,
|
||||||
|
displayName: form.displayName || form.email.split('@')[0],
|
||||||
|
addedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
setForm({ displayName: '', email: '' })
|
||||||
|
setIsAdding(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div style={styles.header}>
|
||||||
|
<span style={styles.title}>🎵 Spotify Accounts</span>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={() => setIsAdding(!isAdding)}
|
||||||
|
style={{ fontSize: 18 }}
|
||||||
|
title="Add account"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isAdding && (
|
||||||
|
<div style={styles.form}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Display name (optional)"
|
||||||
|
value={form.displayName}
|
||||||
|
onChange={e => setForm({ ...form, displayName: e.target.value })}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({ ...form, email: e.target.value })}
|
||||||
|
style={styles.input}
|
||||||
|
/>
|
||||||
|
<div style={styles.formButtons}>
|
||||||
|
<button
|
||||||
|
className="primary"
|
||||||
|
onClick={handleAdd}
|
||||||
|
style={{ flex: 1, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => setIsAdding(false)}
|
||||||
|
style={{ flex: 1, fontSize: 12 }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={styles.list}>
|
||||||
|
{accounts.length === 0 ? (
|
||||||
|
<div style={styles.empty}>No Spotify accounts</div>
|
||||||
|
) : (
|
||||||
|
accounts.map(account => (
|
||||||
|
<div key={account.id} style={styles.item}>
|
||||||
|
<div style={styles.itemInfo}>
|
||||||
|
<div style={styles.itemName}>{account.displayName}</div>
|
||||||
|
<div style={styles.itemEmail}>{account.email}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={() => removeAccount(account.id)}
|
||||||
|
style={{ fontSize: 14, color: '#ef4444' }}
|
||||||
|
title="Remove account"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
background: 'var(--glass-bg)',
|
||||||
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: 14,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingBottom: 8,
|
||||||
|
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: 'var(--text)',
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 8,
|
||||||
|
padding: 10,
|
||||||
|
background: 'rgba(29, 185, 84, 0.08)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
border: '1px solid rgba(29, 185, 84, 0.2)',
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
padding: '8px 10px',
|
||||||
|
background: 'rgba(255, 255, 255, 0.05)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
fontSize: 12,
|
||||||
|
fontFamily: 'var(--font-ui)',
|
||||||
|
},
|
||||||
|
formButtons: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
maxHeight: 300,
|
||||||
|
overflowY: 'auto',
|
||||||
|
},
|
||||||
|
empty: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'var(--muted)',
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: 12,
|
||||||
|
},
|
||||||
|
item: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 8,
|
||||||
|
background: 'rgba(29, 185, 84, 0.08)',
|
||||||
|
border: '1px solid rgba(29, 185, 84, 0.15)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
},
|
||||||
|
itemInfo: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
itemName: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text)',
|
||||||
|
},
|
||||||
|
itemEmail: {
|
||||||
|
fontSize: 10,
|
||||||
|
color: 'var(--muted)',
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
|
import { useSpotify } from '../../contexts/SpotifyContext.jsx'
|
||||||
|
|
||||||
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
|
export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, groupedWith }) {
|
||||||
const { id, name, active, volume, muted, source } = zone
|
const { id, name, active, volume, muted, source } = zone
|
||||||
|
const { getAccountForZone, assignAccountToZone, removeAccountFromZone, accounts } = useSpotify()
|
||||||
|
|
||||||
|
const spotifyAccount = source === 'Spotify' ? getAccountForZone(id) : null
|
||||||
|
|
||||||
// Map source to emoji and connection info
|
// Map source to emoji and connection info
|
||||||
const sourceInfo = {
|
const sourceInfo = {
|
||||||
@@ -9,6 +14,17 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
|||||||
}
|
}
|
||||||
const info = sourceInfo[source] || { emoji: '📢', color: 'var(--muted)', label: source }
|
const info = sourceInfo[source] || { emoji: '📢', color: 'var(--muted)', label: source }
|
||||||
|
|
||||||
|
const handleSourceChange = (newSource) => {
|
||||||
|
onSource(id, newSource)
|
||||||
|
|
||||||
|
// If switching to Spotify, assign an account if not already assigned
|
||||||
|
if (newSource === 'Spotify' && !spotifyAccount && accounts.length > 0) {
|
||||||
|
assignAccountToZone(id, accounts[0].id)
|
||||||
|
} else if (newSource !== 'Spotify' && spotifyAccount) {
|
||||||
|
removeAccountFromZone(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
...styles.card,
|
...styles.card,
|
||||||
@@ -23,6 +39,11 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
|||||||
<span style={{ ...styles.sourceTag, background: `${info.color}22`, color: info.color }}>
|
<span style={{ ...styles.sourceTag, background: `${info.color}22`, color: info.color }}>
|
||||||
{info.emoji} {info.label}
|
{info.emoji} {info.label}
|
||||||
</span>
|
</span>
|
||||||
|
{spotifyAccount && (
|
||||||
|
<span style={{ ...styles.accountTag, color: '#1DB954', fontSize: 10 }}>
|
||||||
|
👤 {spotifyAccount.displayName || spotifyAccount.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.badges}>
|
<div style={styles.badges}>
|
||||||
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
<span style={{ ...styles.badge, background: active ? '#34d39922' : 'var(--border)', color: active ? 'var(--success)' : 'var(--muted)' }}>
|
||||||
@@ -45,13 +66,34 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
|||||||
<div style={styles.sourceControl}>
|
<div style={styles.sourceControl}>
|
||||||
<select
|
<select
|
||||||
value={source}
|
value={source}
|
||||||
onChange={e => onSource(id, e.target.value)}
|
onChange={e => handleSourceChange(e.target.value)}
|
||||||
style={styles.sourceSelect}
|
style={styles.sourceSelect}
|
||||||
>
|
>
|
||||||
<option value="Spotify">🎵 Spotify</option>
|
<option value="Spotify">🎵 Spotify</option>
|
||||||
<option value="AirPlay">🎙️ AirPlay</option>
|
<option value="AirPlay">🎙️ AirPlay</option>
|
||||||
<option value="Mopidy">📻 Mopidy</option>
|
<option value="Mopidy">📻 Mopidy</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
{source === 'Spotify' && accounts.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={spotifyAccount?.id || ''}
|
||||||
|
onChange={e => {
|
||||||
|
if (e.target.value) {
|
||||||
|
assignAccountToZone(id, e.target.value)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={styles.accountSelect}
|
||||||
|
title="Select Spotify account for this zone"
|
||||||
|
>
|
||||||
|
<option value="">Select Account</option>
|
||||||
|
{accounts.map(acc => (
|
||||||
|
<option key={acc.id} value={acc.id}>
|
||||||
|
{acc.displayName || acc.email}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
{onGroup && (
|
{onGroup && (
|
||||||
<button
|
<button
|
||||||
style={styles.groupBtn}
|
style={styles.groupBtn}
|
||||||
@@ -81,49 +123,69 @@ export default function ZoneCard({ zone, onVolume, onMute, onSource, onGroup, gr
|
|||||||
const styles = {
|
const styles = {
|
||||||
card: {
|
card: {
|
||||||
padding: 14,
|
padding: 14,
|
||||||
background: 'var(--surface)',
|
background: 'var(--glass-bg)',
|
||||||
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
border: '1px solid var(--border)',
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
display: 'flex', flexDirection: 'column', gap: 10,
|
display: 'flex', flexDirection: 'column', gap: 10,
|
||||||
transition: 'border-color 0.2s, opacity 0.2s',
|
transition: 'all 0.3s cubic-bezier(0.23, 1, 0.320, 1)',
|
||||||
|
cursor: 'pointer',
|
||||||
},
|
},
|
||||||
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 },
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 10 },
|
||||||
titleArea: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1 },
|
titleArea: { display: 'flex', flexDirection: 'column', gap: 6, flex: 1 },
|
||||||
name: { fontWeight: 600, fontSize: 14 },
|
name: { fontWeight: 600, fontSize: 15, letterSpacing: '-0.01em' },
|
||||||
sourceTag: {
|
sourceTag: {
|
||||||
fontSize: 11, padding: '3px 8px', borderRadius: 4, fontWeight: 600,
|
fontSize: 11, padding: '4px 8px', borderRadius: 6, fontWeight: 600,
|
||||||
display: 'inline-block', width: 'fit-content',
|
display: 'inline-block', width: 'fit-content',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
},
|
||||||
|
accountTag: {
|
||||||
|
fontSize: 10, padding: '2px 6px', borderRadius: 4, fontWeight: 500,
|
||||||
|
display: 'inline-block', width: 'fit-content',
|
||||||
|
background: 'rgba(29, 185, 84, 0.1)',
|
||||||
},
|
},
|
||||||
badges: { display: 'flex', gap: 4 },
|
badges: { display: 'flex', gap: 4 },
|
||||||
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center' },
|
badge: { fontSize: 12, padding: '4px 8px', borderRadius: 4, fontWeight: 700, minHeight: 24, minWidth: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'all 0.2s' },
|
||||||
|
|
||||||
groupInfo: {
|
groupInfo: {
|
||||||
fontSize: 11, color: 'var(--muted)',
|
fontSize: 11, color: 'var(--muted)',
|
||||||
background: '#38bdf811', padding: 8, borderRadius: 4,
|
background: 'rgba(14, 165, 233, 0.08)', padding: 8, borderRadius: 6,
|
||||||
borderLeft: '2px solid var(--accent)',
|
borderLeft: '2px solid var(--accent)',
|
||||||
|
animation: 'slideInUp 0.3s ease-out',
|
||||||
},
|
},
|
||||||
groupLabel: { fontWeight: 600, marginBottom: 4 },
|
groupLabel: { fontWeight: 600, marginBottom: 4, display: 'block' },
|
||||||
groupZones: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 },
|
groupZones: { display: 'flex', gap: 4, flexWrap: 'wrap', marginTop: 4 },
|
||||||
groupZone: {
|
groupZone: {
|
||||||
fontSize: 10, padding: '2px 6px', background: 'var(--accent)',
|
fontSize: 10, padding: '3px 8px', background: 'var(--accent)',
|
||||||
color: 'var(--bg)', borderRadius: 3, fontWeight: 600,
|
color: 'white', borderRadius: 4, fontWeight: 600,
|
||||||
|
animation: 'slideInUp 0.2s ease-out',
|
||||||
},
|
},
|
||||||
|
|
||||||
sourceControl: { display: 'flex', gap: 8, alignItems: 'center' },
|
sourceControl: { display: 'flex', gap: 6, alignItems: 'center' },
|
||||||
sourceSelect: {
|
sourceSelect: {
|
||||||
flex: 1, padding: '8px 10px', background: 'var(--bg)', color: 'var(--text)',
|
flex: 1.2, padding: '8px 10px', background: 'rgba(255, 255, 255, 0.05)',
|
||||||
border: '1px solid var(--border)', borderRadius: 'var(--radius)',
|
color: 'var(--text)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
|
||||||
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
},
|
||||||
|
accountSelect: {
|
||||||
|
flex: 1, padding: '8px 10px', background: 'rgba(29, 185, 84, 0.08)',
|
||||||
|
color: 'var(--text)',
|
||||||
|
border: '1px solid rgba(29, 185, 84, 0.2)', borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 11, fontWeight: 600, cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
groupBtn: {
|
groupBtn: {
|
||||||
minWidth: 44, minHeight: 44, padding: 0,
|
minWidth: 40, minHeight: 40, padding: 0,
|
||||||
background: 'var(--border)', color: 'var(--text)',
|
background: 'rgba(255, 255, 255, 0.08)', color: 'var(--text)',
|
||||||
border: 'none', borderRadius: 'var(--radius)', cursor: 'pointer',
|
border: '1px solid rgba(255, 255, 255, 0.1)', borderRadius: 'var(--radius)',
|
||||||
fontSize: 18, transition: 'background 0.2s',
|
cursor: 'pointer',
|
||||||
'&:hover': { background: 'var(--accent)' },
|
fontSize: 16, transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
|
|
||||||
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
volumeRow: { display: 'flex', alignItems: 'center', gap: 10 },
|
||||||
muteBtn: { fontSize: 18, minWidth: 44, minHeight: 44, background: 'none', border: 'none', cursor: 'pointer' },
|
muteBtn: { fontSize: 18, minWidth: 40, minHeight: 40, background: 'none', border: 'none', cursor: 'pointer', transition: 'transform 0.2s' },
|
||||||
volVal: { fontFamily: 'var(--font-mono)', fontSize: 13, minWidth: 26, textAlign: 'right', color: 'var(--muted)' },
|
volVal: { fontFamily: 'var(--font-mono)', fontSize: 12, minWidth: 28, textAlign: 'right', color: 'var(--muted)' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,25 +100,29 @@ const styles = {
|
|||||||
gridTemplateColumns: '240px 1fr',
|
gridTemplateColumns: '240px 1fr',
|
||||||
gap: 16,
|
gap: 16,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
padding: 16,
|
||||||
},
|
},
|
||||||
groupingPanel: {
|
groupingPanel: {
|
||||||
background: 'var(--surface)',
|
background: 'var(--glass-bg)',
|
||||||
borderRadius: 'var(--radius)',
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
border: '1px solid var(--border)',
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
padding: 12,
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
padding: 14,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 10,
|
gap: 10,
|
||||||
maxHeight: '100vh',
|
maxHeight: 'calc(100vh - 200px)',
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
|
animation: 'slideInDown 0.3s ease-out',
|
||||||
},
|
},
|
||||||
panelTitle: {
|
panelTitle: {
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: 'var(--muted)',
|
color: 'var(--muted)',
|
||||||
margin: '0 0 8px 0',
|
margin: '0 0 8px 0',
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.6,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -132,13 +136,14 @@ const styles = {
|
|||||||
gap: 6,
|
gap: 6,
|
||||||
},
|
},
|
||||||
groupListItem: {
|
groupListItem: {
|
||||||
background: '#38bdf811',
|
background: 'rgba(14, 165, 233, 0.08)',
|
||||||
border: '1px solid var(--accent)',
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
borderRadius: 4,
|
borderRadius: 6,
|
||||||
padding: '8px 10px',
|
padding: '8px 10px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
groupName: {
|
groupName: {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
@@ -155,11 +160,13 @@ const styles = {
|
|||||||
padding: '0 4px',
|
padding: '0 4px',
|
||||||
minWidth: 24,
|
minWidth: 24,
|
||||||
minHeight: 24,
|
minHeight: 24,
|
||||||
|
transition: 'transform 0.2s',
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
|
animation: 'slideInUp 0.3s ease-out',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function TabNav({ activeTab, onTabChange }) {
|
|||||||
...(activeTab === tab.id ? styles.active : {}),
|
...(activeTab === tab.id ? styles.active : {}),
|
||||||
}}
|
}}
|
||||||
onClick={() => onTabChange(tab.id)}
|
onClick={() => onTabChange(tab.id)}
|
||||||
|
className={activeTab === tab.id ? 'slide-in-down' : ''}
|
||||||
>
|
>
|
||||||
<span style={styles.icon}>{tab.icon}</span>
|
<span style={styles.icon}>{tab.icon}</span>
|
||||||
<span style={styles.label}>{tab.label}</span>
|
<span style={styles.label}>{tab.label}</span>
|
||||||
@@ -31,6 +32,7 @@ const styles = {
|
|||||||
background: 'var(--surface)',
|
background: 'var(--surface)',
|
||||||
borderBottom: '1px solid var(--border)',
|
borderBottom: '1px solid var(--border)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
backdropFilter: 'blur(8px)',
|
||||||
},
|
},
|
||||||
tab: {
|
tab: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -43,14 +45,14 @@ const styles = {
|
|||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: 'var(--muted)',
|
color: 'var(--muted)',
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
borderBottom: '2px solid transparent',
|
borderBottom: '3px solid transparent',
|
||||||
transition: 'color 0.15s, border-color 0.15s',
|
transition: 'color 0.2s cubic-bezier(0.23, 1, 0.320, 1), border-color 0.2s cubic-bezier(0.23, 1, 0.320, 1)',
|
||||||
minHeight: 48,
|
minHeight: 48,
|
||||||
},
|
},
|
||||||
active: {
|
active: {
|
||||||
color: 'var(--accent)',
|
color: 'var(--accent)',
|
||||||
borderBottom: '2px solid var(--accent)',
|
borderBottom: '3px solid var(--accent)',
|
||||||
},
|
},
|
||||||
icon: { fontSize: 16 },
|
icon: { fontSize: 18, transition: 'transform 0.2s' },
|
||||||
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
|
label: { fontSize: 10, fontWeight: 600, letterSpacing: '0.05em', textTransform: 'uppercase' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNMEA } from '../../hooks/useNMEA.js'
|
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||||
|
import { useTheme } from '../../contexts/ThemeContext.jsx'
|
||||||
|
|
||||||
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
|
const isMock = import.meta.env.VITE_USE_MOCK === 'true'
|
||||||
const isDev = import.meta.env.DEV
|
const isDev = import.meta.env.DEV
|
||||||
@@ -10,6 +11,7 @@ function formatTime() {
|
|||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { sog, heading, connected } = useNMEA()
|
const { sog, heading, connected } = useNMEA()
|
||||||
|
const { isDark, toggleTheme } = useTheme()
|
||||||
const [time, setTime] = useState(formatTime())
|
const [time, setTime] = useState(formatTime())
|
||||||
|
|
||||||
// Clock tick
|
// Clock tick
|
||||||
@@ -23,7 +25,7 @@ export default function TopBar() {
|
|||||||
<div style={styles.left}>
|
<div style={styles.left}>
|
||||||
<span style={styles.logo}>⚓ Bordanlage</span>
|
<span style={styles.logo}>⚓ Bordanlage</span>
|
||||||
{isMock && <span style={styles.devBadge}>DEV · MOCK DATA</span>}
|
{isMock && <span style={styles.devBadge}>DEV · MOCK DATA</span>}
|
||||||
{isDev && !isMock && <span style={{ ...styles.devBadge, background: '#38bdf822', color: 'var(--accent)', borderColor: 'var(--accent)' }}>DEV · LIVE</span>}
|
{isDev && !isMock && <span style={{ ...styles.devBadge, background: '#0ea5e922', color: 'var(--accent)', borderColor: 'var(--accent)' }}>DEV · LIVE</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.center}>
|
<div style={styles.center}>
|
||||||
@@ -45,6 +47,14 @@ export default function TopBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.right}>
|
<div style={styles.right}>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
title={isDark ? 'Light Mode' : 'Dark Mode'}
|
||||||
|
style={{ fontSize: 18 }}
|
||||||
|
>
|
||||||
|
{isDark ? '☀️' : '🌙'}
|
||||||
|
</button>
|
||||||
<span style={styles.time}>{time}</span>
|
<span style={styles.time}>{time}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import WindRose from '../instruments/WindRose.jsx'
|
|||||||
|
|
||||||
function DataRow({ label, value, unit, highlight }) {
|
function DataRow({ label, value, unit, highlight }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ ...styles.row, background: highlight ? '#38bdf811' : 'transparent' }}>
|
<div style={{ ...styles.row, background: highlight ? 'rgba(14, 165, 233, 0.08)' : 'transparent' }}>
|
||||||
<span style={styles.label}>{label}</span>
|
<span style={styles.label}>{label}</span>
|
||||||
<span style={styles.value}>
|
<span style={styles.value}>
|
||||||
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
|
{value != null ? `${typeof value === 'number' ? value.toFixed(1) : value}` : '—'}
|
||||||
@@ -97,12 +97,16 @@ export default function InstrumentPanel() {
|
|||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
|
panel: { display: 'flex', flexDirection: 'column', gap: 16 },
|
||||||
gauges: { display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' },
|
gauges: { display: 'flex', gap: 10, flexWrap: 'wrap', justifyContent: 'center' },
|
||||||
|
|
||||||
waypointBox: {
|
waypointBox: {
|
||||||
background: 'var(--surface)', borderRadius: 'var(--radius)',
|
background: 'var(--glass-bg)',
|
||||||
border: '1px solid var(--accent)',
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.2)',
|
||||||
padding: 12,
|
padding: 12,
|
||||||
|
animation: 'slideInUp 0.3s ease-out',
|
||||||
},
|
},
|
||||||
waypointTitle: {
|
waypointTitle: {
|
||||||
fontSize: 12, fontWeight: 700, color: 'var(--accent)',
|
fontSize: 12, fontWeight: 700, color: 'var(--accent)',
|
||||||
@@ -116,7 +120,7 @@ const styles = {
|
|||||||
waypointRoute: {
|
waypointRoute: {
|
||||||
display: 'flex', flexDirection: 'column', gap: 6,
|
display: 'flex', flexDirection: 'column', gap: 6,
|
||||||
marginTop: 4, paddingTop: 8,
|
marginTop: 4, paddingTop: 8,
|
||||||
borderTop: '1px solid var(--border)',
|
borderTop: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
},
|
},
|
||||||
routeLabel: { fontSize: 11, color: 'var(--muted)' },
|
routeLabel: { fontSize: 11, color: 'var(--muted)' },
|
||||||
waypoints: {
|
waypoints: {
|
||||||
@@ -125,22 +129,26 @@ const styles = {
|
|||||||
waypointTag: {
|
waypointTag: {
|
||||||
width: 28, height: 28,
|
width: 28, height: 28,
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
borderRadius: 4, fontSize: 11, fontWeight: 600,
|
borderRadius: 6, fontSize: 11, fontWeight: 600,
|
||||||
transition: 'all 0.2s',
|
transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
|
|
||||||
table: {
|
table: {
|
||||||
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
display: 'grid', gridTemplateColumns: '1fr 1fr',
|
||||||
gap: '0 24px', background: 'var(--surface)',
|
gap: '0 24px',
|
||||||
borderRadius: 'var(--radius)', padding: 16,
|
background: 'var(--glass-bg)',
|
||||||
border: '1px solid var(--border)',
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
borderRadius: 'var(--radius-lg)', padding: 16,
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
animation: 'slideInUp 0.3s ease-out',
|
||||||
},
|
},
|
||||||
row: {
|
row: {
|
||||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||||
padding: '7px 0', borderBottom: '1px solid var(--border)',
|
padding: '7px 0', borderBottom: '1px solid rgba(255, 255, 255, 0.05)',
|
||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
},
|
},
|
||||||
label: { fontSize: 12, color: 'var(--muted)' },
|
label: { fontSize: 12, color: 'var(--muted)', fontWeight: 500 },
|
||||||
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)' },
|
value: { fontFamily: 'var(--font-mono)', fontSize: 13, color: 'var(--text)', fontWeight: 600 },
|
||||||
unit: { color: 'var(--muted)', fontSize: 11 },
|
unit: { color: 'var(--muted)', fontSize: 11 },
|
||||||
}
|
}
|
||||||
|
|||||||
387
dashboard/src/components/nav/NavigationMap.jsx
Normal file
387
dashboard/src/components/nav/NavigationMap.jsx
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import maplibregl from 'maplibre-gl'
|
||||||
|
import { useNMEA } from '../../hooks/useNMEA.js'
|
||||||
|
import { getApi } from '../../mock/index.js'
|
||||||
|
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||||
|
|
||||||
|
export default function NavigationMap() {
|
||||||
|
const { lat, lon, heading, sog } = useNMEA()
|
||||||
|
const mapContainer = useRef(null)
|
||||||
|
const map = useRef(null)
|
||||||
|
const shipMarkerRef = useRef(null)
|
||||||
|
const trackSourceRef = useRef(false)
|
||||||
|
const [zoom, setZoom] = useState(11)
|
||||||
|
|
||||||
|
const api = getApi()
|
||||||
|
const snapshot = api.signalk.getSnapshot?.()
|
||||||
|
const waypoints = api.signalk.getWaypoints?.() || []
|
||||||
|
|
||||||
|
const mapLat = lat ?? 55.32
|
||||||
|
const mapLon = lon ?? 15.22
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (map.current) return
|
||||||
|
|
||||||
|
map.current = new maplibregl.Map({
|
||||||
|
container: mapContainer.current,
|
||||||
|
style: {
|
||||||
|
version: 8,
|
||||||
|
sources: {
|
||||||
|
'osm': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
},
|
||||||
|
'seamark': {
|
||||||
|
type: 'raster',
|
||||||
|
tiles: ['https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png'],
|
||||||
|
tileSize: 256,
|
||||||
|
attribution: '© OpenSeaMap contributors',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'osm-base',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'osm',
|
||||||
|
minzoom: 0,
|
||||||
|
maxzoom: 19,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'seamark-overlay',
|
||||||
|
type: 'raster',
|
||||||
|
source: 'seamark',
|
||||||
|
minzoom: 5,
|
||||||
|
maxzoom: 19,
|
||||||
|
paint: {
|
||||||
|
'raster-opacity': 0.8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf',
|
||||||
|
},
|
||||||
|
center: [mapLon, mapLat],
|
||||||
|
zoom: 11,
|
||||||
|
pitch: 0,
|
||||||
|
bearing: 0,
|
||||||
|
antialias: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Add navigation controls
|
||||||
|
map.current.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||||
|
|
||||||
|
// Add ship marker source and layer
|
||||||
|
map.current.on('load', () => {
|
||||||
|
if (!map.current.getSource('ship')) {
|
||||||
|
map.current.addSource('ship', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [mapLon, mapLat] },
|
||||||
|
properties: { heading: 0 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.current.addLayer({
|
||||||
|
id: 'ship-marker',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'ship',
|
||||||
|
layout: {
|
||||||
|
'icon-image': 'marker-blue',
|
||||||
|
'icon-size': 1.2,
|
||||||
|
'icon-rotate': ['get', 'heading'],
|
||||||
|
'icon-allow-overlap': true,
|
||||||
|
'icon-ignore-placement': true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add track source for ship trail
|
||||||
|
if (!map.current.getSource('track')) {
|
||||||
|
map.current.addSource('track', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'LineString', coordinates: [[mapLon, mapLat]] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.current.addLayer({
|
||||||
|
id: 'track-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'track',
|
||||||
|
paint: {
|
||||||
|
'line-color': '#0ea5e9',
|
||||||
|
'line-width': 2,
|
||||||
|
'line-opacity': 0.7,
|
||||||
|
'line-dasharray': [5, 5],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
trackSourceRef.current = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add waypoints
|
||||||
|
if (waypoints.length > 0) {
|
||||||
|
const waypointFeatures = waypoints.map((wp, idx) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [wp.lon, wp.lat] },
|
||||||
|
properties: { index: idx, isCurrent: idx === snapshot?.currentWaypoint },
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (!map.current.getSource('waypoints')) {
|
||||||
|
map.current.addSource('waypoints', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: waypointFeatures,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.current.addLayer({
|
||||||
|
id: 'waypoint-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'waypoints',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 8,
|
||||||
|
'circle-color': ['case', ['get', 'isCurrent'], '#0ea5e9', '#f59e0b'],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
map.current.addLayer({
|
||||||
|
id: 'waypoint-labels',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'waypoints',
|
||||||
|
layout: {
|
||||||
|
'text-field': ['to-string', ['+', ['get', 'index'], 1]],
|
||||||
|
'text-size': 12,
|
||||||
|
'text-font': ['Open Sans Semibold'],
|
||||||
|
'text-offset': [0, 0],
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': '#ffffff',
|
||||||
|
'text-halo-color': '#000000',
|
||||||
|
'text-halo-width': 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
map.current.getSource('waypoints').setData({
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: waypointFeatures,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
map.current.on('zoom', () => setZoom(map.current.getZoom()))
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Cleanup is handled by React
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Update ship position
|
||||||
|
useEffect(() => {
|
||||||
|
if (!map.current || lat == null || lon == null) return
|
||||||
|
|
||||||
|
if (map.current.getSource('ship')) {
|
||||||
|
const trackSource = map.current.getSource('track')
|
||||||
|
if (trackSource && trackSourceRef.current) {
|
||||||
|
const currentData = trackSource._data
|
||||||
|
if (currentData.geometry.coordinates.length > 500) {
|
||||||
|
currentData.geometry.coordinates.shift() // Keep last 500 points
|
||||||
|
}
|
||||||
|
currentData.geometry.coordinates.push([lon, lat])
|
||||||
|
trackSource.setData(currentData)
|
||||||
|
}
|
||||||
|
|
||||||
|
map.current.getSource('ship').setData({
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: { type: 'Point', coordinates: [lon, lat] },
|
||||||
|
properties: { heading: heading ?? 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-center on ship
|
||||||
|
map.current.flyTo({
|
||||||
|
center: [lon, lat],
|
||||||
|
zoom: zoom,
|
||||||
|
speed: 0.5,
|
||||||
|
curve: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [lat, lon, heading, zoom])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<div ref={mapContainer} style={styles.mapBox} />
|
||||||
|
|
||||||
|
{/* Map Controls */}
|
||||||
|
<div style={styles.controls}>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={() => map.current?.zoomIn()}
|
||||||
|
title="Zoom in"
|
||||||
|
style={{ fontSize: 18 }}
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={() => map.current?.zoomOut()}
|
||||||
|
title="Zoom out"
|
||||||
|
style={{ fontSize: 18 }}
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="icon ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (lat != null && lon != null) {
|
||||||
|
map.current?.flyTo({
|
||||||
|
center: [lon, lat],
|
||||||
|
zoom: 12,
|
||||||
|
duration: 1000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Center on ship"
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
>
|
||||||
|
⊙
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Panel */}
|
||||||
|
{lat != null && lon != null && (
|
||||||
|
<div style={styles.infoPanel}>
|
||||||
|
<div style={styles.infoSection}>
|
||||||
|
<div style={styles.infoRow}>
|
||||||
|
<span style={styles.label}>Position</span>
|
||||||
|
<span style={styles.value}>{lat.toFixed(5)}°</span>
|
||||||
|
</div>
|
||||||
|
<div style={styles.infoRow}>
|
||||||
|
<span style={styles.label}></span>
|
||||||
|
<span style={styles.value}>{lon.toFixed(5)}°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{heading != null && (
|
||||||
|
<div style={styles.infoSection}>
|
||||||
|
<div style={styles.infoRow}>
|
||||||
|
<span style={styles.label}>Heading</span>
|
||||||
|
<span style={styles.value}>{Math.round(heading)}°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sog != null && (
|
||||||
|
<div style={styles.infoSection}>
|
||||||
|
<div style={styles.infoRow}>
|
||||||
|
<span style={styles.label}>Speed</span>
|
||||||
|
<span style={styles.value}>{(sog * 1.943844).toFixed(1)} kn</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{waypoints.length > 0 && snapshot?.currentWaypoint != null && (
|
||||||
|
<div style={styles.infoSection}>
|
||||||
|
<div style={styles.infoRow}>
|
||||||
|
<span style={styles.label}>Route</span>
|
||||||
|
<span style={styles.value}>WP {snapshot.currentWaypoint + 1} / {waypoints.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{snapshot?.distanceToWaypoint != null && (
|
||||||
|
<div style={styles.infoSection}>
|
||||||
|
<div style={styles.infoRow}>
|
||||||
|
<span style={styles.label}>Distance</span>
|
||||||
|
<span style={styles.value}>{(snapshot.distanceToWaypoint * 1.852).toFixed(1)} km</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map Style Toggle */}
|
||||||
|
<div style={styles.layerToggle}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--muted)' }}>+ SeaMarks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
container: {
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
},
|
||||||
|
mapBox: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 12,
|
||||||
|
right: 12,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
infoPanel: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
left: 12,
|
||||||
|
background: 'var(--glass-bg)',
|
||||||
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
padding: '10px 12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'var(--text)',
|
||||||
|
zIndex: 500,
|
||||||
|
maxWidth: 180,
|
||||||
|
animation: 'slideInUp 0.3s ease-out',
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: 'var(--muted)',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 10,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--accent)',
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
layerToggle: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 60,
|
||||||
|
right: 12,
|
||||||
|
background: 'rgba(14, 165, 233, 0.1)',
|
||||||
|
border: '1px solid rgba(14, 165, 233, 0.3)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
padding: '4px 8px',
|
||||||
|
zIndex: 500,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -15,8 +15,13 @@ export default function EngineData() {
|
|||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
grid: {
|
grid: {
|
||||||
display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center',
|
display: 'flex', gap: 12, flexWrap: 'wrap', justifyContent: 'center',
|
||||||
padding: 16, background: 'var(--surface)',
|
padding: 16,
|
||||||
borderRadius: 'var(--radius)', border: '1px solid var(--border)',
|
background: 'var(--glass-bg)',
|
||||||
|
backdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
WebkitBackdropFilter: 'blur(var(--glass-blur))',
|
||||||
|
borderRadius: 'var(--radius-lg)',
|
||||||
|
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||||
|
animation: 'slideInUp 0.3s ease-out',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
149
dashboard/src/contexts/SpotifyContext.jsx
Normal file
149
dashboard/src/contexts/SpotifyContext.jsx
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { createContext, useContext, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const SpotifyContext = createContext()
|
||||||
|
|
||||||
|
export function SpotifyProvider({ children }) {
|
||||||
|
// Account management
|
||||||
|
const [accounts, setAccounts] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('spotify_accounts')
|
||||||
|
return saved ? JSON.parse(saved) : []
|
||||||
|
})
|
||||||
|
|
||||||
|
// Zone to account mapping: { zoneId: accountId }
|
||||||
|
const [zoneAccounts, setZoneAccounts] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('zone_accounts')
|
||||||
|
return saved ? JSON.parse(saved) : {}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Active Spotify Connect instances per account
|
||||||
|
const [spotifyConnects, setSpotifyConnects] = useState({})
|
||||||
|
|
||||||
|
// Add or update account
|
||||||
|
const addAccount = useCallback((account) => {
|
||||||
|
setAccounts(prev => {
|
||||||
|
const filtered = prev.filter(a => a.id !== account.id)
|
||||||
|
const updated = [...filtered, account]
|
||||||
|
localStorage.setItem('spotify_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Remove account
|
||||||
|
const removeAccount = useCallback((accountId) => {
|
||||||
|
setAccounts(prev => {
|
||||||
|
const updated = prev.filter(a => a.id !== accountId)
|
||||||
|
localStorage.setItem('spotify_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove zone mappings for this account
|
||||||
|
setZoneAccounts(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
Object.keys(updated).forEach(zoneId => {
|
||||||
|
if (updated[zoneId] === accountId) delete updated[zoneId]
|
||||||
|
})
|
||||||
|
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Assign account to zone
|
||||||
|
const assignAccountToZone = useCallback((zoneId, accountId) => {
|
||||||
|
setZoneAccounts(prev => {
|
||||||
|
const updated = { ...prev, [zoneId]: accountId }
|
||||||
|
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Activate Spotify Connect for this account if not already active
|
||||||
|
if (accountId && !spotifyConnects[accountId]) {
|
||||||
|
activateSpotifyConnect(accountId)
|
||||||
|
}
|
||||||
|
}, [spotifyConnects])
|
||||||
|
|
||||||
|
// Remove account from zone
|
||||||
|
const removeAccountFromZone = useCallback((zoneId) => {
|
||||||
|
const accountId = zoneAccounts[zoneId]
|
||||||
|
setZoneAccounts(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
delete updated[zoneId]
|
||||||
|
localStorage.setItem('zone_accounts', JSON.stringify(updated))
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deactivate Spotify Connect if no zones use this account
|
||||||
|
if (accountId && !Object.values(zoneAccounts).includes(accountId)) {
|
||||||
|
deactivateSpotifyConnect(accountId)
|
||||||
|
}
|
||||||
|
}, [zoneAccounts])
|
||||||
|
|
||||||
|
// Activate Spotify Connect for account
|
||||||
|
const activateSpotifyConnect = useCallback((accountId) => {
|
||||||
|
if (!accountId) return
|
||||||
|
|
||||||
|
setSpotifyConnects(prev => {
|
||||||
|
if (prev[accountId]) return prev
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[accountId]: {
|
||||||
|
id: `spotify-${accountId}`,
|
||||||
|
status: 'active',
|
||||||
|
device: 'Bordanlage',
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Deactivate Spotify Connect for account
|
||||||
|
const deactivateSpotifyConnect = useCallback((accountId) => {
|
||||||
|
setSpotifyConnects(prev => {
|
||||||
|
const updated = { ...prev }
|
||||||
|
delete updated[accountId]
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Get account for zone
|
||||||
|
const getAccountForZone = useCallback((zoneId) => {
|
||||||
|
const accountId = zoneAccounts[zoneId]
|
||||||
|
if (!accountId) return null
|
||||||
|
return accounts.find(a => a.id === accountId)
|
||||||
|
}, [zoneAccounts, accounts])
|
||||||
|
|
||||||
|
// Get zones using account
|
||||||
|
const getZonesForAccount = useCallback((accountId) => {
|
||||||
|
return Object.entries(zoneAccounts)
|
||||||
|
.filter(([_, aId]) => aId === accountId)
|
||||||
|
.map(([zId]) => zId)
|
||||||
|
}, [zoneAccounts])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SpotifyContext.Provider
|
||||||
|
value={{
|
||||||
|
accounts,
|
||||||
|
addAccount,
|
||||||
|
removeAccount,
|
||||||
|
zoneAccounts,
|
||||||
|
assignAccountToZone,
|
||||||
|
removeAccountFromZone,
|
||||||
|
spotifyConnects,
|
||||||
|
activateSpotifyConnect,
|
||||||
|
deactivateSpotifyConnect,
|
||||||
|
getAccountForZone,
|
||||||
|
getZonesForAccount,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SpotifyContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSpotify() {
|
||||||
|
const context = useContext(SpotifyContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSpotify must be used within SpotifyProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
35
dashboard/src/contexts/ThemeContext.jsx
Normal file
35
dashboard/src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const ThemeContext = createContext()
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }) {
|
||||||
|
const [isDark, setIsDark] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('theme')
|
||||||
|
if (saved) return saved === 'dark'
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement
|
||||||
|
if (isDark) {
|
||||||
|
root.classList.remove('light-mode')
|
||||||
|
} else {
|
||||||
|
root.classList.add('light-mode')
|
||||||
|
}
|
||||||
|
localStorage.setItem('theme', isDark ? 'dark' : 'light')
|
||||||
|
}, [isDark])
|
||||||
|
|
||||||
|
const toggleTheme = () => setIsDark(prev => !prev)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ isDark, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext)
|
||||||
|
if (!context) throw new Error('useTheme must be used within ThemeProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
/* ─── CSS Custom Properties ───────────────────────────────────────────────── */
|
/* ─── CSS Custom Properties ───────────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
|
/* Dark Mode Colors */
|
||||||
--bg: #07111f;
|
--bg: #07111f;
|
||||||
--surface: #0a1928;
|
--surface: #0a1928;
|
||||||
--surface2: #0d2035;
|
--surface2: #0d2035;
|
||||||
--border: #1e2a3a;
|
--border: #1e2a3a;
|
||||||
--text: #e2eaf2;
|
--text: #e2eaf2;
|
||||||
--muted: #4a6080;
|
--muted: #4a6080;
|
||||||
--accent: #38bdf8;
|
|
||||||
--success: #34d399;
|
/* Brand Colors */
|
||||||
|
--accent: #0ea5e9;
|
||||||
|
--success: #10b981;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--spotify: #1DB954;
|
--spotify: #1DB954;
|
||||||
@@ -18,6 +21,34 @@
|
|||||||
|
|
||||||
--radius: 8px;
|
--radius: 8px;
|
||||||
--radius-lg: 16px;
|
--radius-lg: 16px;
|
||||||
|
|
||||||
|
/* Glassmorphism */
|
||||||
|
--glass-bg: rgba(10, 25, 40, 0.7);
|
||||||
|
--glass-blur: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light Mode */
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--surface: #f1f5f9;
|
||||||
|
--surface2: #e2e8f0;
|
||||||
|
--border: #cbd5e1;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--glass-bg: rgba(241, 245, 249, 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Light Mode Toggle */
|
||||||
|
html.light-mode {
|
||||||
|
--bg: #f8fafc;
|
||||||
|
--surface: #f1f5f9;
|
||||||
|
--surface2: #e2e8f0;
|
||||||
|
--border: #cbd5e1;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--glass-bg: rgba(241, 245, 249, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Reset ───────────────────────────────────────────────────────────────── */
|
/* ─── Reset ───────────────────────────────────────────────────────────────── */
|
||||||
@@ -39,11 +70,76 @@ html, body, #root {
|
|||||||
::-webkit-scrollbar-track { background: var(--surface); }
|
::-webkit-scrollbar-track { background: var(--surface); }
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||||
|
|
||||||
|
/* ─── Animations ───────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes slideInUp {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInDown {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: -1000px 0; }
|
||||||
|
100% { background-position: 1000px 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Glassmorphic Components ───────────────────────────────────────────────── */
|
||||||
|
.glass-panel {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: var(--glass-bg);
|
||||||
|
backdrop-filter: blur(var(--glass-blur));
|
||||||
|
-webkit-backdrop-filter: blur(var(--glass-blur));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
transition: all 0.3s cubic-bezier(0.23, 1, 0.320, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card:hover {
|
||||||
|
background: rgba(10, 25, 40, 0.85);
|
||||||
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 32px rgba(14, 165, 233, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Light mode card hover */
|
||||||
|
html.light-mode .glass-card:hover {
|
||||||
|
background: rgba(241, 245, 249, 0.95);
|
||||||
|
box-shadow: 0 8px 32px rgba(14, 165, 233, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Utilities ───────────────────────────────────────────────────────────── */
|
/* ─── Utilities ───────────────────────────────────────────────────────────── */
|
||||||
.mono { font-family: var(--font-mono); }
|
.mono { font-family: var(--font-mono); }
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--muted); }
|
||||||
.accent { color: var(--accent); }
|
.accent { color: var(--accent); }
|
||||||
|
|
||||||
|
.fade-in { animation: fadeIn 0.3s ease-out; }
|
||||||
|
.slide-in-up { animation: slideInUp 0.3s cubic-bezier(0.23, 1, 0.320, 1); }
|
||||||
|
.slide-in-down { animation: slideInDown 0.3s cubic-bezier(0.23, 1, 0.320, 1); }
|
||||||
|
.pulse { animation: pulse 2s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ─── Button Variants ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* ─── Button Variants ───────────────────────────────────────────────────────── */
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -56,9 +152,52 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
transition: background 0.15s, opacity 0.15s;
|
transition: all 0.2s cubic-bezier(0.23, 1, 0.320, 1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary Button */
|
||||||
|
button.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 4px 15px rgba(14, 165, 233, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background: #06b6d4;
|
||||||
|
box-shadow: 0 6px 20px rgba(14, 165, 233, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ghost Button */
|
||||||
|
button.ghost {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.ghost:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon Button */
|
||||||
|
button.icon {
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.icon:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
button:active { opacity: 0.7; }
|
|
||||||
|
|
||||||
input[type=range] {
|
input[type=range] {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export function createSignalKMock() {
|
|||||||
waterUsed: 23,
|
waterUsed: 23,
|
||||||
wasteWater: 18,
|
wasteWater: 18,
|
||||||
freshWater: 156,
|
freshWater: 156,
|
||||||
|
trackPoints: [{ lat: WAYPOINTS[0].lat, lon: WAYPOINTS[0].lon }],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to next waypoint
|
// Navigate to next waypoint
|
||||||
@@ -115,6 +116,12 @@ export function createSignalKMock() {
|
|||||||
|
|
||||||
state.lat += dLat
|
state.lat += dLat
|
||||||
state.lon += dLon
|
state.lon += dLon
|
||||||
|
|
||||||
|
// Record track point (keep last 500)
|
||||||
|
state.trackPoints.push({ lat: state.lat, lon: state.lon })
|
||||||
|
if (state.trackPoints.length > 500) {
|
||||||
|
state.trackPoints.shift()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDelta() {
|
function buildDelta() {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
import NowPlaying from '../components/audio/NowPlaying.jsx'
|
||||||
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
import ZoneGrid from '../components/audio/ZoneGrid.jsx'
|
||||||
|
import SpotifyAccountManager from '../components/audio/SpotifyAccountManager.jsx'
|
||||||
import SourcePicker from '../components/audio/SourcePicker.jsx'
|
import SourcePicker from '../components/audio/SourcePicker.jsx'
|
||||||
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
import RadioBrowser from '../components/audio/RadioBrowser.jsx'
|
||||||
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
import LibraryBrowser from '../components/audio/LibraryBrowser.jsx'
|
||||||
|
|
||||||
const SUB_TABS = ['Zones', 'Radio', 'Library']
|
const SUB_TABS = ['Zones', 'Accounts', 'Radio', 'Library']
|
||||||
|
|
||||||
export default function Audio() {
|
export default function Audio() {
|
||||||
const [subTab, setSubTab] = useState('Zones')
|
const [subTab, setSubTab] = useState('Zones')
|
||||||
@@ -28,9 +29,10 @@ export default function Audio() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.content}>
|
<div style={styles.content}>
|
||||||
{subTab === 'Zones' && <ZoneGrid />}
|
{subTab === 'Zones' && <ZoneGrid />}
|
||||||
{subTab === 'Radio' && <RadioBrowser />}
|
{subTab === 'Accounts' && <SpotifyAccountManager />}
|
||||||
{subTab === 'Library' && <LibraryBrowser />}
|
{subTab === 'Radio' && <RadioBrowser />}
|
||||||
|
{subTab === 'Library' && <LibraryBrowser />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -40,10 +42,10 @@ const styles = {
|
|||||||
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flex: 1 },
|
page: { padding: 16, display: 'flex', flexDirection: 'column', gap: 14, overflow: 'auto', flex: 1 },
|
||||||
subTabs: { display: 'flex', gap: 2, background: 'var(--surface)', borderRadius: 'var(--radius)', padding: 3, border: '1px solid var(--border)' },
|
subTabs: { display: 'flex', gap: 2, background: 'var(--surface)', borderRadius: 'var(--radius)', padding: 3, border: '1px solid var(--border)' },
|
||||||
subTab: {
|
subTab: {
|
||||||
flex: 1, height: 36, fontSize: 13, fontWeight: 500,
|
flex: 1, height: 36, fontSize: 12, fontWeight: 600,
|
||||||
color: 'var(--muted)', borderRadius: 6,
|
color: 'var(--muted)', borderRadius: 'var(--radius)',
|
||||||
background: 'none', minHeight: 36,
|
background: 'none', minHeight: 36, transition: 'all 0.2s',
|
||||||
},
|
},
|
||||||
subTabActive: { background: 'var(--surface2)', color: 'var(--text)' },
|
subTabActive: { background: 'var(--accent)', color: 'white' },
|
||||||
content: { flex: 1 },
|
content: { flex: 1, overflowY: 'auto' },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import ChartPlaceholder from '../components/nav/ChartPlaceholder.jsx'
|
import NavigationMap from '../components/nav/NavigationMap.jsx'
|
||||||
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
|
import InstrumentPanel from '../components/nav/InstrumentPanel.jsx'
|
||||||
|
|
||||||
export default function Navigation() {
|
export default function Navigation() {
|
||||||
return (
|
return (
|
||||||
<div style={styles.layout}>
|
<div style={styles.layout}>
|
||||||
<div style={styles.chart}>
|
<div style={styles.chart}>
|
||||||
<ChartPlaceholder />
|
<NavigationMap />
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.panel}>
|
<div style={styles.panel}>
|
||||||
<InstrumentPanel />
|
<InstrumentPanel />
|
||||||
|
|||||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Reference in New Issue
Block a user