Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c178baa01 | ||
|
|
0ec4f27640 | ||
|
|
c027a30b3f | ||
|
|
bd5e02ac1b | ||
|
|
c810056d67 | ||
|
|
56ae6d2e3f | ||
|
|
f43cfb649e | ||
|
|
91af70dbb5 | ||
|
|
64c78cea70 | ||
|
|
bc9fbaae6e | ||
|
|
feaa1c9f14 | ||
|
|
91d77b8bd4 | ||
|
|
a01ae24908 | ||
|
|
8ce490f0ef | ||
|
|
c5f8c848a5 | ||
|
|
49f3b0807d | ||
|
|
4474b8ae33 | ||
|
|
c32afff1c5 | ||
|
|
c7dcf193f9 | ||
|
|
4523f8035f | ||
|
|
18c1df93d1 | ||
|
|
243bb13537 | ||
|
|
3595599dae | ||
|
|
8e514418d4 | ||
|
|
10cbedb6ef | ||
|
|
0c8820d109 | ||
|
|
4914b64fe2 | ||
|
|
4aa8eb7ff0 | ||
|
|
cde2c4660b | ||
|
|
1c6951d245 | ||
|
|
60f71485da | ||
|
|
6c0664d05a | ||
|
|
43cc39fd62 | ||
|
|
75b65bfcaa | ||
|
|
7e0723a776 | ||
|
|
6304db66c2 | ||
|
|
8e9b17f6dd | ||
|
|
c45bc778ec | ||
|
|
816046d05a | ||
|
|
12e540b61b | ||
|
|
1e1130f965 | ||
|
|
8ddf9ff393 | ||
|
|
9abcb78327 | ||
|
|
f5f09e31fe | ||
|
|
510c63e7b0 | ||
|
|
6eb5e42111 | ||
|
|
152aacb1c3 | ||
|
|
3017535d02 | ||
|
|
9b1ddf6dcc | ||
|
|
1f2b578c91 | ||
|
|
a5f2f012dc | ||
|
|
0381bc3ce8 | ||
|
|
f2637c7469 | ||
|
|
2b0300b075 | ||
|
|
a6ebe5af1e | ||
|
|
6d0af310ae | ||
|
|
2283d156ea | ||
|
|
3697575f54 | ||
|
|
9fbf22bc32 | ||
|
|
7568d0dba4 | ||
|
|
022d4169b5 |
@@ -2,6 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
|
||||
<link rel="icon" type="image/svg+xml" href="volcano.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VDAP</title>
|
||||
|
||||
Generated
+390
@@ -11,6 +11,7 @@
|
||||
"@chakra-ui/react": "^3.22.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@svar-ui/react-gantt": "^2.7.3",
|
||||
"axios": "^1.18.1",
|
||||
"framer-motion": "^12.23.0",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -3106,6 +3107,385 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/comments-locales": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/comments-locales/-/comments-locales-2.6.1.tgz",
|
||||
"integrity": "sha512-jouLSwt0rPp2818bHftsb37aU8ERsyBney8XVUHhnWrsIaGJy/0TSOCw6YqN3cae6SQBd6KBO+P1+hBcDEsmUg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/core-locales": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/core-locales/-/core-locales-2.6.1.tgz",
|
||||
"integrity": "sha512-18xu7qn8Bxwi2EHguVV3Xya5kS+A+6Gnuo2wl55dofp9y948iZ5z7HIRh1+Mj1BgzbdVt6+B04JOCDMLJXyjtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/editor-locales": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/editor-locales/-/editor-locales-2.6.1.tgz",
|
||||
"integrity": "sha512-HFEB43W+AEUjoBh3ZsrlLFFTVh8WYPVpZeOiCfptS5hS0qC8pEnqaXtreM6TM1g3W5VHHOR+sXsWkIxAoeKVvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/filter-locales": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/filter-locales/-/filter-locales-2.6.1.tgz",
|
||||
"integrity": "sha512-m963r4TnZxiZ6XzfJYvSmiYcmgSgKhkzpOVcUP25AviWle1oPulyv4Qe28Rak3JaAOFAHQAf0EucoaSMJzvK6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/filter-store": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/filter-store/-/filter-store-2.6.1.tgz",
|
||||
"integrity": "sha512-2UHeXuNYN56MokBhB48EJ7KvKDVAJbRP2nIbE6t7lbsnWhRsBGxPc4jjUQn/9K+MTGkxrzPrOfeoH8L4p3Hy5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-state": "1.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/filter-store/node_modules/@svar-ui/lib-state": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
|
||||
"integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/gantt-data-provider": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/gantt-data-provider/-/gantt-data-provider-2.7.2.tgz",
|
||||
"integrity": "sha512-4j+fWq0y7+qfBcu0R5YS13M1u9fKJiH9YQt/81fRRFcRu12Rvpi/HDXeOVpE/APvsM8NoHJdd5kKK6f59qmE+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-data-provider": "1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/gantt-locales": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/gantt-locales/-/gantt-locales-2.7.2.tgz",
|
||||
"integrity": "sha512-yEihUiPx9dgr0FLfAoNpgS6QcVjYLqdW2TbyilZ0UaA3YLORly9lbmCI0PPnAALa73ws0qUy2AYaL1nTq0T4/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/gantt-store": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/gantt-store/-/gantt-store-2.7.2.tgz",
|
||||
"integrity": "sha512-krax1GzUWBOTcMVTaaqYlMuJ5lNBkzA6VIhNCnHq+0kFnHg52Hx+D0MQnMkSOUyFA2w0SoiYvUYuAGiQx4eA9A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-state": "1.9.7",
|
||||
"date-fns": "3.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/grid-data-provider": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/grid-data-provider/-/grid-data-provider-2.7.3.tgz",
|
||||
"integrity": "sha512-MFobgoczW6wBPjKq8fTORlJEEn2f6jHKWH1h3pC6qmcKr8GRA+TaRaFPVcI2iD5pOc+h/JZxuKE6L+x99S2Uwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-data-provider": "1.7.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/grid-data-provider/node_modules/@svar-ui/lib-data-provider": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.2.tgz",
|
||||
"integrity": "sha512-Od93Iiy0vBwVzgJm25WdizH3d4Xlli/HAA2kqoUj1pzeZ2SWBeFzvE59iihQz8NuIlfAtQJ6MMe168458T+2lQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-state": "1.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/grid-data-provider/node_modules/@svar-ui/lib-state": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
|
||||
"integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/grid-locales": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/grid-locales/-/grid-locales-2.7.3.tgz",
|
||||
"integrity": "sha512-bgB2TfSKjY7mYeTzJjCyOoPco7boz+SQ0xFFsnRp849QN5sJoJEtOl/xnzbR58bLdxq57ne5Z708ozxXDT36lQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/grid-store": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/grid-store/-/grid-store-2.7.3.tgz",
|
||||
"integrity": "sha512-YVmLnFLq7P3ACAos0EiMYHLQbnmjaukmUaMoraTG8vhFsI5BwVaeSFdeC9MQIjbIMlxn/uMA0eEXmZuSxHk2hA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-dom": "0.12.1",
|
||||
"@svar-ui/lib-state": "1.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/grid-store/node_modules/@svar-ui/lib-dom": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.12.1.tgz",
|
||||
"integrity": "sha512-srvDleQqR6stBBDijCgzDHMFTTffI6sv3XCpNPKmurrnE83L1MDNqezqg6j3+0dUVPd4AAEGOez2DvK6byblTQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/grid-store/node_modules/@svar-ui/lib-state": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
|
||||
"integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/lib-data-provider": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.1.tgz",
|
||||
"integrity": "sha512-5Bi9ywngcMTKpGtan0s7q8ivd84Bp0ZnRP+5bLZg6oiRB7YMyN5D5X+apc3CWDAnG4zZn3en4YwS2qc/aHIIuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-state": "1.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/lib-data-provider/node_modules/@svar-ui/lib-state": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
|
||||
"integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/lib-dom": {
|
||||
"version": "0.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.14.0.tgz",
|
||||
"integrity": "sha512-9uao5ebcQmuW0RoMu1LCesPsZ7i6G3xIvYAQzX/aeg662Yy/ETsNVy1pXoSa2q5v6ccAMjVxFrFCYReK7fzaJQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/lib-react": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-react/-/lib-react-1.3.0.tgz",
|
||||
"integrity": "sha512-jVcREYAMAXOhZB/lbMmLteGKIZoUW3rzmoXCMcFLGcHIGUH7AjFrKOAG/SJvgV0grcG9XyuRTG3jp++6NIGtuw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-dom": "^0.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/lib-react/node_modules/@svar-ui/lib-dom": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.12.2.tgz",
|
||||
"integrity": "sha512-tV9sJ7Xpedxf1dttTc8I190eqlP0UsassUw48uW3JuEQR0G3IRfmH53/X+mYEooc7bcpZDkzYukWEqJeEq9UlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/lib-state": {
|
||||
"version": "1.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.7.tgz",
|
||||
"integrity": "sha512-nxRdk+54m1RJjGT2yDH7NjxAM5KECxA39kdTZezEIj1ixykyefssB9BrgxKy2jIseYxJMtd6lPZvQ3SZXuvn2g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/react-comments": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-comments/-/react-comments-2.6.1.tgz",
|
||||
"integrity": "sha512-bdO8DHIZ/qpQNXeZzNuU2cbUsiyen+bc2/sS9h7PLzCSeeGmKwAyHeTyO06WvsVxUgQSBAfp2Eom+xFNDaQcXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/comments-locales": "2.6.1",
|
||||
"@svar-ui/core-locales": "2.6.1",
|
||||
"@svar-ui/lib-data-provider": "1.7.3",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/lib-state": "1.9.7",
|
||||
"@svar-ui/react-core": "2.6.1",
|
||||
"@svar-ui/react-menu": "2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-comments/node_modules/@svar-ui/lib-data-provider": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.3.tgz",
|
||||
"integrity": "sha512-+UAM1VybQco5N52iv6rVcDwOJ5G1qE+V+nvaoBHx41H05w4TZTFJL+UnRy99NDLrVbLoCWhoH3L8vyNmZVjTvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-state": "1.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-comments/node_modules/@svar-ui/lib-data-provider/node_modules/@svar-ui/lib-state": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
|
||||
"integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/react-core": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-core/-/react-core-2.6.1.tgz",
|
||||
"integrity": "sha512-1b0R+n4r/XLM2FC4mAxhyRYCI79HdrDbfe0nsOr/uJ0Ty+wxEfWglamCf6yJSM2tvmpGJQ388flDIXPbzep4Fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/core-locales": "2.6.1",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/lib-react": "1.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-core/node_modules/@svar-ui/lib-react": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-react/-/lib-react-1.2.2.tgz",
|
||||
"integrity": "sha512-bB+Vou2NM0JhMuFW03MsjjoV5Y3xcFfuuK48+7uzUC9Ui1oSWpCK+txOZ+dnUFJISSVjSsXuF34AzMw/2CA63w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-dom": "^0.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-core/node_modules/@svar-ui/lib-react/node_modules/@svar-ui/lib-dom": {
|
||||
"version": "0.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-dom/-/lib-dom-0.11.1.tgz",
|
||||
"integrity": "sha512-4wgerhDVttvrQ9t6OjHur34Of+VVr2VygA16HHGcWHytgdnOuGgfZb5PeBEhCJw/pO3RKfJvBHbaMIzL4IR//g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/react-editor": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-editor/-/react-editor-2.6.1.tgz",
|
||||
"integrity": "sha512-r/POft5rvYx43DGHHuC3yKJKani0i+jqKbWFH+j5IWUhWjnkEqdpcToehGzoXob7gyl7VuzGMQQFu8DzUcyNrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/editor-locales": "2.6.1",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/lib-state": "1.9.7",
|
||||
"@svar-ui/react-comments": "2.6.1",
|
||||
"@svar-ui/react-core": "2.6.1",
|
||||
"@svar-ui/react-tasklist": "2.6.1",
|
||||
"@svar-ui/react-toolbar": "2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-filter": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-filter/-/react-filter-2.6.1.tgz",
|
||||
"integrity": "sha512-GBwReaJUa7Qb7EZTJroxjzm7NIGsAEFuDoTrmJqwkcmwFB+WBukQa/2Z0mf5Ya4t4/ldt1sfOzNWlbFhMo8FTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/filter-locales": "2.6.1",
|
||||
"@svar-ui/filter-store": "2.6.1",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/lib-react": "1.3.0",
|
||||
"@svar-ui/lib-state": "1.9.7",
|
||||
"@svar-ui/react-core": "2.6.1",
|
||||
"@svar-ui/react-menu": "2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-gantt": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-gantt/-/react-gantt-2.7.3.tgz",
|
||||
"integrity": "sha512-1kLEXrfJ3fkXp5NCvMHIpsjdEphy0EC5ce2nphs8GOFlsiSp6nRau+GY0Go6ivGDPdcchPkZ8a0Q1iKk3HtyRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/core-locales": "2.6.1",
|
||||
"@svar-ui/gantt-data-provider": "2.7.2",
|
||||
"@svar-ui/gantt-locales": "2.7.2",
|
||||
"@svar-ui/gantt-store": "2.7.2",
|
||||
"@svar-ui/grid-store": "2.7.3",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/lib-react": "1.3.0",
|
||||
"@svar-ui/lib-state": "1.9.7",
|
||||
"@svar-ui/react-comments": "2.6.1",
|
||||
"@svar-ui/react-core": "2.6.1",
|
||||
"@svar-ui/react-editor": "2.6.1",
|
||||
"@svar-ui/react-filter": "2.6.1",
|
||||
"@svar-ui/react-grid": "2.7.4",
|
||||
"@svar-ui/react-menu": "2.6.1",
|
||||
"@svar-ui/react-tasklist": "2.6.1",
|
||||
"@svar-ui/react-toolbar": "2.6.1",
|
||||
"date-fns": "3.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-grid": {
|
||||
"version": "2.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-grid/-/react-grid-2.7.4.tgz",
|
||||
"integrity": "sha512-m3AbGuQNeEnniDcyBesEOg+IirwW5NasJQCfJd7jIFhUyfQ79w+crSnLbinEVS59/OKGrqkmxnLDcDwEKbu4rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/grid-data-provider": "2.7.3",
|
||||
"@svar-ui/grid-locales": "2.7.3",
|
||||
"@svar-ui/grid-store": "2.7.3",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/lib-react": "1.3.0",
|
||||
"@svar-ui/lib-state": "1.9.7",
|
||||
"@svar-ui/react-core": "2.6.1",
|
||||
"@svar-ui/react-editor": "2.6.1",
|
||||
"@svar-ui/react-filter": "2.6.1",
|
||||
"@svar-ui/react-menu": "2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-menu": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-menu/-/react-menu-2.6.1.tgz",
|
||||
"integrity": "sha512-/wu6vaLVPN2AJp8r+axIkjaSpoZOAgE46CRsdM2d0yc+hbJdeMo997LXZTGQzBziwLZttW1DDwOvL+uLIeTOug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/core-locales": "2.6.1",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/react-core": "2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-tasklist": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-tasklist/-/react-tasklist-2.6.1.tgz",
|
||||
"integrity": "sha512-U06UHYwL1J6XXMLsHrTvs/wWUXEm3ZC+Ca1hC5Ns0vvnscdE6cSRPqC1Xl2JGKu/cem0sHimEVi7SW4Q2NWczw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-data-provider": "1.7.3",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/lib-state": "1.9.7",
|
||||
"@svar-ui/react-core": "2.6.1",
|
||||
"@svar-ui/tasklist-locales": "2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-tasklist/node_modules/@svar-ui/lib-data-provider": {
|
||||
"version": "1.7.3",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-data-provider/-/lib-data-provider-1.7.3.tgz",
|
||||
"integrity": "sha512-+UAM1VybQco5N52iv6rVcDwOJ5G1qE+V+nvaoBHx41H05w4TZTFJL+UnRy99NDLrVbLoCWhoH3L8vyNmZVjTvw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/lib-state": "1.9.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/react-tasklist/node_modules/@svar-ui/lib-data-provider/node_modules/@svar-ui/lib-state": {
|
||||
"version": "1.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/lib-state/-/lib-state-1.9.6.tgz",
|
||||
"integrity": "sha512-twoHa8wZvzdxtES68s6Wnp4Xi1WivScNaFFK6sJ6WDgqVYOzJw0ZhVVrdwHQgieLITTKl/DYh/U3nuqtMetKYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@svar-ui/react-toolbar": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/react-toolbar/-/react-toolbar-2.6.1.tgz",
|
||||
"integrity": "sha512-jSxcTW8WNgpSgkxicGyu76cKjVuwwxOvdDXJqQpLnxIIdN/RNeWuY8ak4sD0IqhCOp7AicgfPg2t+K4YUp+LMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@svar-ui/core-locales": "2.6.1",
|
||||
"@svar-ui/lib-dom": "0.14.0",
|
||||
"@svar-ui/react-core": "2.6.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@svar-ui/tasklist-locales": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@svar-ui/tasklist-locales/-/tasklist-locales-2.6.1.tgz",
|
||||
"integrity": "sha512-ewGvdmALpEJR3CpzlNX8aDL2JPmeyF1EweRBoI0jQmp7bIpYDIlpTWStmGwDEwQ5SgvUTJylFyIj6baPf2RBlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.12.7",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.7.tgz",
|
||||
@@ -5926,6 +6306,16 @@
|
||||
"integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
|
||||
"integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"@chakra-ui/react": "^3.22.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@svar-ui/react-gantt": "^2.7.3",
|
||||
"axios": "^1.18.1",
|
||||
"framer-motion": "^12.23.0",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
+51
-54
@@ -1,7 +1,7 @@
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useEffect } from 'react';
|
||||
import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||
import { useRecipe, Grid, GridItem, useDialog, Flex, Heading } from "@chakra-ui/react";
|
||||
import {useEffect} from 'react';
|
||||
import {Routes, Route, Navigate, Outlet} from 'react-router-dom';
|
||||
import {useRecipe, Grid, GridItem, useDialog, Flex, Heading} from "@chakra-ui/react";
|
||||
|
||||
// --- NEW AUTH IMPORTS ---
|
||||
import useAuthStore from './store/authStore';
|
||||
@@ -18,42 +18,10 @@ import Volcano from "@/pages/volcano/Volcano.jsx";
|
||||
import GlobalMap from "./pages/maps/GlobalMap.jsx";
|
||||
import Admin from "./pages/admin/Admin.jsx";
|
||||
import MyDashboard from "./pages/home/tabs/MyDashboard.jsx";
|
||||
import Settings from "./pages/auth/Settings.jsx";
|
||||
import WebAdmin from "./pages/auth/WebAdmin.jsx";
|
||||
|
||||
function AppFoundation() {
|
||||
const recipe = useRecipe({ key: "dashboard" });
|
||||
const styles = recipe();
|
||||
const dialog = useDialog();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogPopup dialog={dialog} />
|
||||
<Grid css={styles}
|
||||
templateRows="1fr 11fr"
|
||||
templateColumns="auto 1fr"
|
||||
height="100vh"
|
||||
width="100vw"
|
||||
>
|
||||
<GridItem rowSpan={1} colSpan={2}>
|
||||
<Header />
|
||||
</GridItem>
|
||||
<GridItem colSpan={1}>
|
||||
<Sidebar />
|
||||
</GridItem>
|
||||
<GridItem colSpan={1} width="100%" overflow="auto">
|
||||
<Outlet />
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const Placeholder = ({ name }) => (
|
||||
<Flex width="100%" height="100%" align="center" justify="center">
|
||||
<Heading color="gray.500" _dark={{ color: "gray.200" }} size="lg">{name} Dashboard - Coming Soon</Heading>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
function App() {
|
||||
export default function App() {
|
||||
// 1. Pull the initialization function from Zustand
|
||||
const checkAuth = useAuthStore((state) => state.checkAuth);
|
||||
|
||||
@@ -65,34 +33,63 @@ function App() {
|
||||
return (
|
||||
<Routes>
|
||||
{/* --- Public Routes --- */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/login" element={<Login/>}/>
|
||||
|
||||
{/* --- SECURE ROUTES --- */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
{/* --- Secure Routes --- */}
|
||||
<Route element={<ProtectedRoute/>}>
|
||||
|
||||
{/* --- Non-Public Routes (Now completely locked down) --- */}
|
||||
<Route element={<AppFoundation />}>
|
||||
<Route element={<AppFoundation/>}>
|
||||
|
||||
<Route path="/home" element={<Home />}>
|
||||
<Route index element={<MyDashboard />} />
|
||||
<Route path="gas" element={<Placeholder name="Gas" />} />
|
||||
<Route path="seismic" element={<Placeholder name="Seismic" />} />
|
||||
<Route path="remote" element={<Placeholder name="Remote Sensing" />} />
|
||||
<Route path="daily" element={<Placeholder name="Daily Activity" />} />
|
||||
<Route path="/home" element={<Home/>}>
|
||||
<Route index element={<MyDashboard/>}/>
|
||||
<Route path="gas" element={<Placeholder name="Gas"/>}/>
|
||||
<Route path="seismic" element={<Placeholder name="Seismic"/>}/>
|
||||
<Route path="remote" element={<Placeholder name="Remote Sensing"/>}/>
|
||||
<Route path="daily" element={<Placeholder name="Daily Activity"/>}/>
|
||||
</Route>
|
||||
|
||||
<Route path="/global-map" element={<GlobalMap />} />
|
||||
<Route path="/regional-map" element={<RegionalMap />} />
|
||||
<Route path="/volcano" element={<Volcano />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/global-map" element={<GlobalMap/>}/>
|
||||
<Route path="/regional-map" element={<RegionalMap/>}/>
|
||||
<Route path="/volcano" element={<Volcano/>}/>
|
||||
<Route path="/admin" element={<Admin/>}/>
|
||||
</Route>
|
||||
|
||||
</Route>
|
||||
|
||||
{/* --- Redirect Routes --- */}
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
<Route path="/" element={<Navigate to="/home" replace/>}/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
// The fundamental layout of the webpage
|
||||
function AppFoundation() {
|
||||
const recipe = useRecipe({key: "dashboard"});
|
||||
const styles = recipe();
|
||||
const dialog = useDialog();
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogPopup dialog={dialog}/>
|
||||
<Grid css={styles} templateRows="1fr 11fr" templateColumns="auto 1fr">
|
||||
<GridItem rowSpan={2} colSpan={1}>
|
||||
<Sidebar/>
|
||||
</GridItem>
|
||||
<GridItem rowSpan={1} colSpan={1}>
|
||||
<Header/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={1}>
|
||||
<Outlet/>
|
||||
</GridItem>
|
||||
</Grid>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// A dummy page that acts as a placeholder for pages that aren't developed yet but are navigatable to
|
||||
const Placeholder = ({name}) => (
|
||||
<Flex width="100%" height="100%" align="center" justify="center">
|
||||
<Heading color="gray.500" _dark={{color: "gray.200"}} size="lg">{name} Dashboard - Coming Soon</Heading>
|
||||
</Flex>
|
||||
);
|
||||
@@ -60,38 +60,38 @@ export default function AlertChanges({ settings, widgetID}) {
|
||||
return sorted_entries;
|
||||
}, [apiData, thisWidget]);
|
||||
|
||||
// list of alert colors used for coloring text below
|
||||
// Alert colors are now light/dark mode responsive
|
||||
const alertColors = {
|
||||
Red: "red.400",
|
||||
Orange: "orange.400",
|
||||
Yellow: "yellow.400",
|
||||
Green: "green.400",
|
||||
Red: "alertRed",
|
||||
Orange: "alertOrange",
|
||||
Yellow: "alertYellow",
|
||||
Green: "alertGreen",
|
||||
};
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data.length === 0) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text>
|
||||
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
|
||||
<Text textAlign="center" color="widgetTextMuted">No flight level data available for the selected dates.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" gap={2} p={2} w="100%" h="100%" borderRadius="lg" bg="gray.800" overflowY="auto">
|
||||
// bg="transparent" to let the glass panel show through
|
||||
<Box display="flex" flexDirection="column" gap={2} p={2} w="100%" h="100%" bg="transparent" overflowY="auto">
|
||||
{data.map((volcano, index) => {
|
||||
if (thisWidget?.size === "large") return (
|
||||
<Collapsible.Root key={index} w="100%">
|
||||
{/* Using context to dynamically change the border radii of the triggers */}
|
||||
<Collapsible.Context>
|
||||
{(collapsible) => (
|
||||
<>
|
||||
@@ -102,57 +102,58 @@ export default function AlertChanges({ settings, widgetID}) {
|
||||
p={1} pl={3} pr={3}
|
||||
borderTopRadius="lg"
|
||||
borderBottomRadius={collapsible.open ? "0" : "lg"}
|
||||
bg="gray.600"
|
||||
bg="widgetItemBg"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Text truncate >{volcano[0]}</Text> {/* volcano name */}
|
||||
<Text truncate fontSize="xs" color="gray.300/90">{volcano[1][0]?.alert_start_date_local}</Text>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left">
|
||||
<Text truncate color="text" fontWeight="medium">{volcano[0]}</Text>
|
||||
<Text truncate fontSize="xs" color="widgetTextMuted">{volcano[1][0]?.alert_start_date_local}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{volcano[1][0]?.alert_level_name}</Text>
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">
|
||||
{volcano[1][0]?.alert_level_name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content w="100%" p={3} borderBottomRadius="lg" bg="gray.700">
|
||||
|
||||
<Collapsible.Content w="100%" p={3} borderBottomRadius="lg" bg="widgetItemExpandedBg">
|
||||
<Timeline.Root size="md" variant="subtle">
|
||||
{volcano[1]?.map((object, index) => (
|
||||
<Timeline.Item key={index} w="100%" h="100%">
|
||||
<Timeline.Connector >
|
||||
{/*<Timeline.Separator borderWidth="1px" borderColor="gray.600"/>*/}
|
||||
<Timeline.Indicator outline="none" bg="gray.200" color="gray.500" size="lg">
|
||||
<Timeline.Indicator outline="none" bg="widgetBadgeBg" color="iconColor" size="lg">
|
||||
{object?.alert_scheme_domain === "terrestrial" && <Footprints size={15} />}
|
||||
{object?.alert_scheme_domain === "aviation" && <Plane size={15} />}
|
||||
{object?.alert_scheme_domain === "maritime" && <Sailboat size={15} />}
|
||||
</Timeline.Indicator>
|
||||
<Timeline.Indicator h="50%" outline="none" bg="gray.700" color="gray.100" size="lg">
|
||||
<Timeline.Indicator h="50%" outline="none" bg="widgetBadgeBg" color="iconColor" size="lg">
|
||||
{object?.change_type === "increase" ? <TrendingUp /> : <TrendingDown />}
|
||||
</Timeline.Indicator>
|
||||
</Timeline.Connector>
|
||||
<Timeline.Content w="100%" h="100%">
|
||||
{/*<Timeline.Title>{object?.volcano}</Timeline.Title>*/}
|
||||
<Timeline.Description w="100%" h="100%" gap={1}>
|
||||
<Flex direction="column" gap={1}>
|
||||
<Text w="100%" h="100%" color="gray.200">{object?.alert_start_date_local}</Text>
|
||||
<Text w="100%" h="100%" color="widgetTextMuted">{object?.alert_start_date_local}</Text>
|
||||
<Text w="100%" h="100%"
|
||||
color={alertColors[object?.alert_level_name] || "gray.200"}
|
||||
color={alertColors[object?.alert_level_name] || "text"}
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
{object?.alert_level_name}
|
||||
</Text>
|
||||
<Flex w="100%" h="20px" direction="row" justify="left" align="center">
|
||||
<Text color="gray.300">Alert Scale: </Text>
|
||||
<Text color="widgetTextMuted">Alert Scale: </Text>
|
||||
{(Array.from({ length: Number(object?.alert_scheme_highest) - Number(object?.alert_scheme_lowest) + 1}, (v, i) => Number(object?.alert_scheme_lowest) + i))
|
||||
.map((num) => (
|
||||
<Center
|
||||
key={num}
|
||||
w="20px" h="100%"
|
||||
color="gray.300"
|
||||
bg={num === Number(object?.alert_level_rank) ? "gray.300/20" : null} // alert level rank is a string for some reason
|
||||
borderRadius={num === Number(object?.alert_level_rank) ? "xl" : null}
|
||||
color="text"
|
||||
bg={num === Number(object?.alert_level_rank) ? "widgetBadgeBg" : "transparent"}
|
||||
borderRadius={num === Number(object?.alert_level_rank) ? "xl" : "none"}
|
||||
textAlign="center"
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{num}
|
||||
</Center>
|
||||
@@ -181,30 +182,30 @@ export default function AlertChanges({ settings, widgetID}) {
|
||||
justify="space-between" align="center"
|
||||
p={1} pl={3} pr={3}
|
||||
borderRadius="lg"
|
||||
bg="gray.600"
|
||||
bg="widgetItemBg"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
|
||||
<Flex direction="row" w="100%" gap={2} align="center">
|
||||
<Flex color="gray.300">
|
||||
<Flex color="iconColor">
|
||||
{domain[0] === "aviation" && <Plane size={15}/>}
|
||||
{domain[0] === "terrestrial" && <Footprints size={15}/>}
|
||||
{domain[0] === "maritime" && <Sailboat size={15}/>}
|
||||
</Flex>
|
||||
<Text truncate>{volcano[0] || ""}</Text> {/* volcano name */}
|
||||
<Text truncate fontWeight="medium">{volcano[0] || ""}</Text>
|
||||
</Flex>
|
||||
<Text truncate w="100%" fontSize="xs" color="gray.300/90">{domain[1]?.[0]?.alert_start_date_local || ""}</Text>
|
||||
<Text truncate w="100%" fontSize="xs" color="widgetTextMuted">{domain[1]?.[0]?.alert_start_date_local || ""}</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="row" w="100%" minW={0} justify="right" gap={2} color="gray.200">
|
||||
<Flex w="25px" justify="right">
|
||||
<Flex direction="row" w="100%" minW={0} justify="right" gap={2} color="text">
|
||||
<Flex w="25px" justify="right" color="iconColor">
|
||||
{domain[1]?.[0]?.change_type === "increase" ? <TrendingUp/> : <TrendingDown/>}
|
||||
</Flex>
|
||||
<Text
|
||||
truncate
|
||||
pl={2} pr={2}
|
||||
borderRadius="lg"
|
||||
bg="gray.800/50"
|
||||
color={alertColors[domain[1]?.[0]?.alert_level_name] || "gray.100"}
|
||||
bg="widgetBadgeBg"
|
||||
color={alertColors[domain[1]?.[0]?.alert_level_name] || "text"}
|
||||
fontWeight="semibold"
|
||||
>
|
||||
{domain[1]?.[0]?.alert_level_name}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import {Flex, Button, Spinner, Text} from "@chakra-ui/react";
|
||||
import useBuildUrl from "../../hooks/useBuildUrl.js";
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import { useRef, useEffect } from 'react';
|
||||
import {useDashboardStore} from "../../store/useDashboardStore.jsx";
|
||||
|
||||
export default function CommCoorReport({ settings, widgetID }) {
|
||||
const reportRef = useRef(null);
|
||||
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
// build url, defaults to 7 days past
|
||||
const url = useBuildUrl('/api/v2/comm_coor_report', settings?.startDate, settings?.endDate);
|
||||
console.log(url);
|
||||
|
||||
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
|
||||
const {data: apiData, isLoading, error} = VdapApi(url);
|
||||
console.log(apiData);
|
||||
|
||||
const handleDownloadReport = () => {
|
||||
if (apiData.length === 0) return;
|
||||
const element = document.createElement('a');
|
||||
element.style.display = 'none';
|
||||
// Appends HTML right inside the element, after its last child
|
||||
element.insertAdjacentHTML('afterbegin', apiData?.report_html);
|
||||
|
||||
// 1. Create a blob object with text content and text/plain mime type
|
||||
const blob = new Blob([element.textContent || ""], { type: 'text/plain' });
|
||||
|
||||
// 2. Generate a temporary URL pointing to that blob
|
||||
const fileUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 3. Create a hidden anchor element
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.href = fileUrl;
|
||||
linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
|
||||
linkElement.style.display = 'none';
|
||||
|
||||
// 4. Append to body, trigger click, and remove it immediately
|
||||
document.body.appendChild(linkElement);
|
||||
linkElement.click();
|
||||
document.body.removeChild(linkElement);
|
||||
document.body.removeChild(element);
|
||||
|
||||
// 5. Free up memory by revoking the blob URL
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
}
|
||||
|
||||
const handleCopyReport = () => {
|
||||
if (apiData.length === 0) return;
|
||||
const element = document.createElement('a');
|
||||
element.style.display = 'none';
|
||||
// Appends HTML right inside the element, after its last child
|
||||
element.insertAdjacentHTML('afterbegin', apiData?.report_html);
|
||||
|
||||
navigator.clipboard.writeText(element.textContent || "")
|
||||
.then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
})
|
||||
.catch((err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
}));
|
||||
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (reportRef.current) {
|
||||
reportRef.current.innerHTML = apiData?.report_html;
|
||||
const p = reportRef.current.querySelectorAll('p');
|
||||
const b = reportRef.current.querySelectorAll('b');
|
||||
p[0].remove();
|
||||
b[0].remove();
|
||||
}
|
||||
}, [apiData]);
|
||||
|
||||
// copy data to clipboard in formatted way when user clicks on copy btn in widget settings
|
||||
useEffect(() => {
|
||||
if (settings?.copyToClipboard === true && reportRef.current) {
|
||||
navigator.clipboard.writeText(reportRef.current.innerText)
|
||||
.then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
})
|
||||
.catch((err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [settings, widgetID, updateSettings]);
|
||||
|
||||
// download report
|
||||
useEffect(() => {
|
||||
if (settings?.downloadReport === true && reportRef.current) {
|
||||
// 1. Create a blob object with text content and text/plain mime type
|
||||
const blob = new Blob([reportRef.current.innerText || ""], { type: 'text/plain' });
|
||||
|
||||
// 2. Generate a temporary URL pointing to that blob
|
||||
const fileUrl = URL.createObjectURL(blob);
|
||||
|
||||
// 3. Create a hidden anchor element
|
||||
const linkElement = document.createElement('a');
|
||||
linkElement.href = fileUrl;
|
||||
linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
|
||||
linkElement.style.display = 'none';
|
||||
|
||||
// 4. Append to body, trigger click, and remove it immediately
|
||||
document.body.appendChild(linkElement);
|
||||
linkElement.click();
|
||||
document.body.removeChild(linkElement);
|
||||
|
||||
// 5. Free up memory by revoking the blob URL
|
||||
URL.revokeObjectURL(fileUrl);
|
||||
updateSettings(widgetID, {downloadReport: false});
|
||||
}
|
||||
}, [settings, widgetID, updateSettings, apiData]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="gray.800">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (!apiData) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text textAlign="center" color="gray.400">No data</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="row" w="100%" h="100%" p={1} overflowY="auto">
|
||||
<Text ref={reportRef}></Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -5,13 +5,17 @@ import Plot from 'react-plotly.js';
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
// 1. Import your central color dictionary
|
||||
import { rawWidgetColors } from '../../theme.js';
|
||||
|
||||
export default function ExplosionEvents({ settings, widgetID }) {
|
||||
// Plotly expects 'linear' for normal axes
|
||||
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
|
||||
|
||||
// 🎨 Let's check color mode so we can make people happy with light and dark
|
||||
const { colorMode } = useColorMode();
|
||||
const isDark = colorMode === 'dark';
|
||||
// Assign the active palette based on the current mode
|
||||
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
|
||||
|
||||
// Generate url with input dates, default to 7 day range
|
||||
const url = useBuildUrl('/api/v2/explosion_events', settings?.startDate, settings?.endDate);
|
||||
@@ -53,7 +57,7 @@ export default function ExplosionEvents({ settings, widgetID }) {
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
|
||||
line: { width: .5, color: plotColors.text },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
@@ -62,49 +66,37 @@ export default function ExplosionEvents({ settings, widgetID }) {
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Number of Explosions: %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData]);
|
||||
}, [apiData, plotColors]);
|
||||
|
||||
// 2. Build the Layout (Styling and Grid)
|
||||
const layout = useMemo(() => {
|
||||
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#d1d5db' }, // gray.300
|
||||
font: { color: plotColors.muted },
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
// title: {
|
||||
// text: '7 Day Consolidated Flight Levels',
|
||||
// font: { size: 16, color: 'white' },
|
||||
// x: 0.05
|
||||
// },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#4b5563',
|
||||
tickformat: '%b %d<br>%Y',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.grid,
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Number of Explosions',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: plotYAxisType,
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#ffffff',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.text,
|
||||
automargin: true,
|
||||
},
|
||||
// legend: {
|
||||
// orientation: 'h',
|
||||
// y: -0.2,
|
||||
// x: 0.5,
|
||||
// xanchor: 'center'
|
||||
// },
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
@@ -115,37 +107,37 @@ export default function ExplosionEvents({ settings, widgetID }) {
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: 'white', width: 1 }
|
||||
line: { color: plotColors.text, width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, [apiData, plotYAxisType]);
|
||||
}, [plotYAxisType, plotColors]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (plotTraces.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No data available for current filter.</Text>
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
|
||||
<Text color="var(--chakra-colors-widgetTextMuted)">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={plotYAxisType}
|
||||
key={`${plotYAxisType}-${colorMode}`}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
useResizeHandler={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
|
||||
@@ -1,106 +1,40 @@
|
||||
import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react";
|
||||
import {useEffect, useMemo } from 'react';
|
||||
import {useEffect, useMemo, useCallback } from 'react';
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
export default function IvansAlerts({ settings, widgetID }) {
|
||||
console.log("IvansAlerts widget ID: ", widgetID);
|
||||
console.log("Ivans settings: ", settings, "Ivans widgetID: ", widgetID);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
// find this specific widgets object in the state list of active/displayed widgets and extract its uuid
|
||||
const thisWidget = useDashboardStore(useCallback((state) =>
|
||||
state.widgets.find((widget) => widget.id === widgetID), [widgetID]
|
||||
));
|
||||
|
||||
// build url, default is 7 days past
|
||||
const url = useBuildUrl('/api/v2/comm_coor_ivans', settings?.startDate, settings?.endDate);
|
||||
|
||||
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
|
||||
const {data: apiData, isLoading, error} = VdapApi(url);
|
||||
const {data, isLoading, error} = VdapApi(url);
|
||||
|
||||
// TEST DATA
|
||||
// const apiData = useMemo(() => {
|
||||
// return ([
|
||||
// {
|
||||
// id: 277,
|
||||
// volcano_number: 268010,
|
||||
// report_num: "2026-01",
|
||||
// report_date: "2026-05-08T22:05:44Z",
|
||||
// preparer: "hwright",
|
||||
// reviewer: "dramsey",
|
||||
// send_time: "2026-05-08T22:05:52Z",
|
||||
// volcano_name: "Dukono",
|
||||
// summary: "At 07:41 local time on 8 May 2026, an explosion of persistently active Dukono volcano occurred, producing an ash plume that rose over 15 km (52,000 feet) above sea level and ejecting ballistic blocks of lava beyond the crater rim. The eruption killed three tourists who were hiking within the official exclusion zone; other hikers were rescued and returned to safety.",
|
||||
// files: [
|
||||
// {
|
||||
// name: "277-268010-Dukono-2026-01.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/277-268010-Dukono-2026-01.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 279,
|
||||
// volcano_number: 250030,
|
||||
// report_num: "2026-01",
|
||||
// report_date: "2026-05-15T05:11:53Z",
|
||||
// preparer: "charpel",
|
||||
// reviewer: "jlwnstrn",
|
||||
// send_time: "2026-05-15T05:12:01Z",
|
||||
// volcano_name: "Central Bismarck Sea",
|
||||
// summary: "A M5.4 earthquake occurred on 14 May 2026 at 17:13 (UTC) during the ongoing eruption of the submarine Central Bismarck Sea volcano that began 8 May 2026. The volcano's current activity does not threaten any populated areas, but today’s earthquake indicates that eruptive activity will continue or increase.",
|
||||
// files: [
|
||||
// {
|
||||
// name: "279-250030-Central Bismarck Sea-2026-01.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/279-250030-Central Bismarck Sea-2026-01.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 275,
|
||||
// volcano_number: 273030,
|
||||
// report_num: "2026-02",
|
||||
// report_date: "2026-05-03T05:28:12Z",
|
||||
// preparer: "dramsey",
|
||||
// reviewer: "hwright",
|
||||
// send_time: "2026-05-03T05:28:20Z",
|
||||
// volcano_name: "Mayon",
|
||||
// summary: "At around 5:30 PM local time on May 2, 2026, a pyroclastic flow (cloud of searing ash and rock particles) from erupting Mayon Volcano traveled around 4 km (~2.5 miles) down the southwestern slope of the volcano and produced an ash plume that drifted to the west-southwest. At least 52 villages in various towns in Albay province were heavily affected by thick ash fall, reducing visibility to near zero and making travel extremely difficult. There were no reports of casualties and the volcano alert level remained at Level 3 (on a 0-5 level scale).",
|
||||
// files: [
|
||||
// {
|
||||
// name: "275-273030-Mayon-2026-02.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
||||
// type: "pdf"
|
||||
// },
|
||||
// {
|
||||
// name: "275-273030-Mayon-2026-02.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ])
|
||||
// }, []);
|
||||
// sort the apiData
|
||||
const some_data = useMemo(() => {
|
||||
if (data?.length === 0) return [];
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (apiData?.length === 0) return [];
|
||||
let sorted;
|
||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...data].sort(ascTimeVolcanoSort);
|
||||
else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...data].sort(descTimeVolcanoSort);
|
||||
else if (sorts?.timeSort === "asc") return [...data].sort(ascTimeSort);
|
||||
else return [...data].sort(descTimeSort);
|
||||
|
||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeVolcanoSort);
|
||||
} else if (sorts?.volcanoSort && sorts?.timeSort === "desc") {
|
||||
sorted = [...apiData].sort(descTimeVolcanoSort);
|
||||
} else if (sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeSort);
|
||||
} else {
|
||||
sorted = [...apiData].sort(descTimeSort);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [apiData, sorts]);
|
||||
}, [data, settings]);
|
||||
|
||||
// copy data to clipboard in formatted way when user clicks on copy btn in widget settings
|
||||
useEffect(() => {
|
||||
if(isCopy) {
|
||||
const dataText = data.map((item) => {
|
||||
if (settings?.copyToClipboard === true) {
|
||||
const dataText = some_data.map((item) => {
|
||||
return `${item.volcano_name}\n` +
|
||||
`Report Number: ${item.report_num}\n` +
|
||||
`Send Time: ${item.send_time}\n` +
|
||||
@@ -116,23 +50,24 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [isCopy, data, widgetID, updateSettings]);
|
||||
}, [settings, some_data, widgetID, updateSettings]);
|
||||
|
||||
// Cleaned hardcoded grays for transparent so glass shell shows through
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data.length === 0) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text>
|
||||
if (some_data.length === 0) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
|
||||
<Text textAlign="center" color="widgetTextMuted">No flight level data available for the selected dates.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -144,55 +79,61 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
p={2} gap={2}
|
||||
overflow="auto"
|
||||
borderRadius="xl"
|
||||
bg="gray.800"
|
||||
bg="transparent" // Let the global widgetRecipe glass shell show through
|
||||
>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
{some_data.map((item, index) => {
|
||||
if (thisWidget?.size === "large") return (
|
||||
<Collapsible.Root key={index} w="100%">
|
||||
{/* Using context to dynamically change the border radii of the triggers */}
|
||||
<Collapsible.Context>
|
||||
{(collapsible) => (
|
||||
<>
|
||||
<Collapsible.Trigger w="100%">
|
||||
<Flex direction="row"
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%" h="100%"
|
||||
justify="space-between" align="center"
|
||||
p={1} pl={3} pr={3}
|
||||
gap={2}
|
||||
borderTopRadius="lg"
|
||||
borderBottomRadius={collapsible.open ? "0" : "lg"}
|
||||
bg="gray.700"
|
||||
bg="widgetItemBg"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
|
||||
<Text truncate>{item.volcano_name}</Text>
|
||||
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text>
|
||||
<Text truncate fontSize="xs" color="widgetTextMuted">{item.send_time}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100">{item.report_num}</Text>
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">{item.report_num}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content w="100%" h="auto">
|
||||
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
|
||||
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
|
||||
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
|
||||
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
|
||||
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Preparer: {item?.preparer}</Text>
|
||||
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
|
||||
<Flex direction="column" gap={2} bg="widgetItemBg" borderRadius="lg" p={2} align="end">
|
||||
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
|
||||
<Text w="fit" fontSize="sm" color="text" pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md">Preparer: {item?.preparer}</Text>
|
||||
</Flex>
|
||||
{item?.files?.map((file, index) => {
|
||||
return (
|
||||
<Box w="100%" h="100%" key={index} p={1}>
|
||||
<br />
|
||||
<Flex mb={2}>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
<Flex mb={2} justify="space-between">
|
||||
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
|
||||
<Box borderRadius="lg" bg="widgetBadgeBg" color="text" pl={1} pr={1} fontSize={13} asChild>
|
||||
<a href={`${file?.url}#view=FitV&toolbar=1`} target="_blank" rel="noopener noreferrer">
|
||||
Open
|
||||
</a>
|
||||
</Box>
|
||||
</Flex>
|
||||
<object
|
||||
data={`${file?.url}#view=FitH&toolbar=1`}
|
||||
type="application/pdf"
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
>
|
||||
<Flex w="100%" h="100%" p={4} color="gray.400">
|
||||
<Text mb={2} color="fg.muted">
|
||||
<Flex w="100%" h="100%" p={4} color="widgetTextMuted">
|
||||
<Text mb={2}>
|
||||
Your browser cannot display this PDF directly.
|
||||
</Text>
|
||||
<a href={file?.url} target="_blank">
|
||||
@@ -209,6 +150,25 @@ export default function IvansAlerts({ settings, widgetID }) {
|
||||
</Collapsible.Context>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
else if (thisWidget?.size === "small") return (
|
||||
<Flex direction="row"
|
||||
key={index} // Added missing map key
|
||||
w="100%"
|
||||
justify="space-between" align="center"
|
||||
p={1} pl={3} pr={3}
|
||||
gap={2}
|
||||
borderRadius="lg"
|
||||
bg="widgetItemBg"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
|
||||
<Text truncate>{item.volcano_name}</Text>
|
||||
<Text truncate fontSize="xs" color="widgetTextMuted">{item.send_time}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">{item.report_num}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Flex, Spinner, Text } from "@chakra-ui/react";
|
||||
import { useColorMode } from "@/components/ui/color-mode.jsx";
|
||||
import Plot from 'react-plotly.js';
|
||||
import {Box, Flex, Spinner, Text} from "@chakra-ui/react";
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
// 1. Import your central color dictionary
|
||||
import { rawWidgetColors } from '../../theme.js';
|
||||
|
||||
export default function PlumeEvents({ settings, widgetID}) {
|
||||
const { colorMode } = useColorMode();
|
||||
// 2. Assign the active palette based on the current mode
|
||||
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
|
||||
|
||||
// Build url, defaults to 7 days past
|
||||
const url = useBuildUrl('/api/v2/plume_events', settings?.startDate, settings?.endDate);
|
||||
|
||||
@@ -44,7 +52,7 @@ export default function PlumeEvents({ settings, widgetID}) {
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
|
||||
line: { width: .5, color: plotColors.text },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
@@ -53,88 +61,77 @@ export default function PlumeEvents({ settings, widgetID}) {
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Plume Height (m): %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData]);
|
||||
}, [apiData, plotColors]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#d1d5db' }, // gray.300
|
||||
font: { color: plotColors.muted },
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
// title: {
|
||||
// text: '7 Day Consolidated Flight Levels',
|
||||
// font: { size: 16, color: 'white' },
|
||||
// x: 0.05
|
||||
// },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#4b5563',
|
||||
tickformat: '%b %d<br>%Y',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.grid,
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Plume Height',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'linear',
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#ffffff',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.text,
|
||||
automargin: true,
|
||||
},
|
||||
// legend: {
|
||||
// orientation: 'h',
|
||||
// y: -0.2,
|
||||
// x: 0.5,
|
||||
// xanchor: 'center'
|
||||
// },
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: 0,
|
||||
x1: 1,
|
||||
xref: 'paper', // Spans entire chart width
|
||||
xref: 'paper',
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: 'white', width: 1 }
|
||||
line: { color: plotColors.text, width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
}, [plotColors]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (plotTraces.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No data available for current filter.</Text>
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
|
||||
<Text color="widgetTextMuted">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={colorMode}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
useResizeHandler={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react";
|
||||
import {useEffect, useMemo } from 'react';
|
||||
import {useEffect, useMemo, useCallback} from 'react';
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import VdapApi from '@/hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
export default function RemoteSensing({ settings, widgetID }) {
|
||||
console.log("RS widget ID: ", widgetID);
|
||||
console.log("RS settings: ", settings, "RS ID: ", widgetID);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
// Find this specific widgets object in the state list of active & displayed widgets
|
||||
const thisWidget = useDashboardStore(useCallback((state) =>
|
||||
state.widgets.find((widget) => widget.id === widgetID), [widgetID]
|
||||
));
|
||||
|
||||
// build url, defaults to 7 days past
|
||||
const url = useBuildUrl('/api/v2/remote_sensing', settings?.startDate, settings?.endDate);
|
||||
@@ -16,100 +19,21 @@ export default function RemoteSensing({ settings, widgetID }) {
|
||||
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
|
||||
const {data: apiData, isLoading, error} = VdapApi(url);
|
||||
|
||||
// TEST DATA
|
||||
// const apiData = useMemo(() => {
|
||||
// return ([
|
||||
// {
|
||||
// id: 1848,
|
||||
// volcano_name: "Lewotolok",
|
||||
// country: "Indonesia",
|
||||
// report_time: "2026-01-13T20:39:39Z",
|
||||
// summary: "Dear friends,\n\nOur latest satellite view from 13 January (~0900 local time) shows that the lava has began to flow over the crater rim onto the northwest flank (see attached). The lava flow spreads into three lobes from the single source vent on the northwest flank of the active cone. The longest portion of the flow was about 530 meters from the vent with the toe draping about 30-40 meters over the rim of the main crater just north of the May 2024 lava flow. A narrow deposit of volcanoclastic debris extends about 300 meters below the toe of the lava flow (white dashed outline on sketch).\n\nPlease let us know if you have any questions.\nBest wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: [
|
||||
// {
|
||||
// name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// type: "image"
|
||||
// },
|
||||
// {
|
||||
// name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// type: "image"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 1847,
|
||||
// volcano_name: "Mayon",
|
||||
// country: "Philippines",
|
||||
// report_time: "2026-01-13T17:56:46Z",
|
||||
// summary: "Dear PHIVOLCS friends,\nOur most recent satellite data from 13 January (1500 local) show the new lava flow extending southeast from the summit and splitting into two lobes (see attached pdf). The total length of the longest lobe was about 1 kilometer. Compared to data from 12 January, the longest part of the flow advanced almost 200 meters over 11 hours while the shorter section advanced about 80 meters over the same time. Debris falling from the front of both lava flow lobes extend another several hundred meters below each lobe. We continue to watch the activity at Mayon volcano and will share anything that might help. \n\nPlease let us know if you have any questions, requests, or comments.\n\nBest wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: [
|
||||
// {
|
||||
// name: "Mayon-RP-images-20260113_ICEYE_lava.pdf",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1847-Mayon-RP-images-20260113_ICEYE_lava.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 1889,
|
||||
// volcano_name: "Bagana",
|
||||
// country: "Papua New Guinea",
|
||||
// report_time: "2026-06-24T21:28:58Z",
|
||||
// summary: "Hi Steve and RVO,\nI'm now back in the office after some time away and have looked closely at the recent data for Bagana Volcano. \nSatellite data from 15 June show that an increase in lava effusion caused the upper part of the active lava flow at the summit to inflate and likely contributed to the 19-20 June collapse. By 16 June, the inflated area was about 260-meters-long by 140-meters-wide. The northeast edge of the inflated section of the lava flow was the source of rockfalls down the northeast flank where some of the larger blocks tumbled over 1.3 kilometer from the flow. The 22 June data that Julie mentioned shows that all of the inflated area was removed by the collapse (~36,000 square meter area of the upper flow removed). The collapse PDC deposits scoured a deep gorge and removed a broad area of vegetation near the lake. The distal portion of the collapse deposits formed a small delta extending into the lake and possibly raising the lake level slightly based on overall shore outline changes.\n\nI've attached a sketch map showing the features described above. Please let us know if you have any questions.\n\nBest wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: [
|
||||
// {
|
||||
// name: "Bagana-PG-sketch-20260622_collapse+lava.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_collapse+lava.jpg",
|
||||
// type: "image"
|
||||
// },
|
||||
// {
|
||||
// name: "Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg",
|
||||
// type: "image"
|
||||
// },
|
||||
// {
|
||||
// name: "Bagana_22_Jun_2026_collapse2.kml",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana_22_Jun_2026_collapse2.kml",
|
||||
// type: "download"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 1845,
|
||||
// volcano_name: "Lewotolok",
|
||||
// country: "Indonesia",
|
||||
// report_time: "2026-01-07T21:42:17Z",
|
||||
// summary: "Dear friends,\nThe increased activity at Lewotolok Volcano over the past week reminded me that we have not sent you a Remote Sensing Summary since June 2025. Over the two days from 3 to 5 January 2026, explosions from the central cone intensified, doubling the area impacted by proximal ballistics and tephra to over 20 hectares, while increased effusion has produced a new ~20,000 m² lava flow on the west–northwest flank that extends ~270 meters from the vent. So far, all recent lava flows have been confined to the summit crater.\n\nPlease let us know if you have any questions or additions.\n\nBest Wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: []
|
||||
// },
|
||||
// ])
|
||||
// }, []);
|
||||
|
||||
// sort apiData
|
||||
const data = useMemo(() => {
|
||||
if (apiData?.length === 0) return [];
|
||||
let sorted;
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeVolcanoSort);
|
||||
} else if (sorts?.volcanoSort && sorts?.timeSort === "desc") {
|
||||
sorted = [...apiData].sort(descTimeVolcanoSort);
|
||||
} else if (sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeSort);
|
||||
} else {
|
||||
sorted = [...apiData].sort(descTimeSort);
|
||||
}
|
||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") return [...apiData].sort(ascTimeVolcanoSort);
|
||||
else if (sorts?.volcanoSort && sorts?.timeSort === "desc") return [...apiData].sort(descTimeVolcanoSort);
|
||||
else if (sorts?.timeSort === "asc") return [...apiData].sort(ascTimeSort);
|
||||
else return [...apiData].sort(descTimeSort);
|
||||
|
||||
return sorted;
|
||||
}, [apiData, sorts]);
|
||||
}, [apiData, settings]);
|
||||
|
||||
// check if user wants to copy all RS
|
||||
useEffect(() => {
|
||||
if(isCopy) {
|
||||
if(settings?.copyToClipboard === true) {
|
||||
const dataText = data.map((item) => {
|
||||
return `${item.country}, ${item.volcano_name}\n` +
|
||||
`Report Time: ${item.report_time}\n` +
|
||||
@@ -125,69 +49,56 @@ export default function RemoteSensing({ settings, widgetID }) {
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [isCopy, data, widgetID, updateSettings]);
|
||||
}, [settings, data, widgetID, updateSettings]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="md" bg="gray.800">
|
||||
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" p={4} borderRadius="md" bg="gray.800">
|
||||
<Flex w="100%" h="100%" align="center" justify="center" p={4} bg="transparent">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No Remote Sensing data available this week.</Text>
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
|
||||
<Text color="widgetTextMuted">No Remote Sensing data available this week.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column"
|
||||
w="100%" h="100%"
|
||||
pos="relative"
|
||||
justify="top" align="center"
|
||||
p={2} gap={2}
|
||||
overflow="auto"
|
||||
borderRadius="xl"
|
||||
bg="gray.800"
|
||||
>
|
||||
<Flex direction="column" w="100%" h="100%" pos="relative" justify="top" align="center" p={2} gap={2} overflow="auto" bg="transparent">
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
if (thisWidget?.size === "large" || thisWidget?.size === "medium") return (
|
||||
<Collapsible.Root key={index} w="100%">
|
||||
{/* Using context to dynamically change the border radii of the triggers */}
|
||||
<Collapsible.Context>
|
||||
{(collapsible) => (
|
||||
<>
|
||||
<Collapsible.Trigger w="100%" overflow="hidden">
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%" h="100%"
|
||||
justify="space-between" align="center"
|
||||
gap={2}
|
||||
p={1} pl={3} pr={3}
|
||||
borderTopRadius="lg"
|
||||
borderBottomRadius={collapsible.open ? "0" : "lg"}
|
||||
bg="gray.700"
|
||||
<Flex direction="row" w="100%" h="100%" justify="space-between" align="center" gap={2} p={1} pl={3} pr={3}
|
||||
borderTopRadius="lg" borderBottomRadius={collapsible.open ? "0" : "lg"} bg="widgetItemBg"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Text truncate >{item?.volcano_name}</Text>
|
||||
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
|
||||
<Text truncate>{item?.volcano_name}</Text>
|
||||
<Text truncate fontSize="xs" color="widgetTextMuted">{item?.country}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontSize="sm">{item?.report_time}</Text>
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontSize="sm" fontWeight="semibold">
|
||||
{item?.report_time}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content w="100%" h="auto">
|
||||
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
|
||||
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
|
||||
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
|
||||
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
|
||||
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Source: {item?.sender}</Text>
|
||||
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
|
||||
<Flex direction="column" gap={2} bg="widgetItemBg" borderRadius="lg" p={2} align="end">
|
||||
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
|
||||
<Text w="fit" fontSize="sm" color="text" pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md">Source: {item?.sender}</Text>
|
||||
</Flex>
|
||||
{item?.files?.map((file, index) => {
|
||||
|
||||
@@ -196,15 +107,15 @@ export default function RemoteSensing({ settings, widgetID }) {
|
||||
<Box w="100%" h="100%" key={index} p={1}>
|
||||
<br />
|
||||
<Flex dir="row" justify="space-between" align="center" mb={2}>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PDF</Text>
|
||||
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
|
||||
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="widgetTextMuted">PDF</Text>
|
||||
</Flex>
|
||||
<object
|
||||
data={`${file?.url}#view=FitH&toolbar=1`}
|
||||
type="application/pdf"
|
||||
style={{width: '100%', height: '100%', display: 'block'}}
|
||||
>
|
||||
<Flex w="100%" h="100%" p={4} color="gray.400">
|
||||
<Flex w="100%" h="100%" p={4} color="widgetTextMuted">
|
||||
<Text mb={2} color="fg.muted">
|
||||
Your browser cannot display this PDF directly.
|
||||
</Text>
|
||||
@@ -221,25 +132,26 @@ export default function RemoteSensing({ settings, widgetID }) {
|
||||
<Box key={index} w="100%" h="100%" p={1}>
|
||||
<br />
|
||||
<Flex dir="row" justify="space-between" align="center" mb={2}>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PNG</Text>
|
||||
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
|
||||
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="widgetTextMuted">PNG</Text>
|
||||
</Flex>
|
||||
<a href={file?.url} target="_blank" rel="noopener noreferrer">
|
||||
<img src={file?.url} alt={file?.name} />
|
||||
</a>
|
||||
<br />
|
||||
</Box>
|
||||
);
|
||||
|
||||
// DOWNLOADABLE LINK
|
||||
else if (file?.type === "download") return (
|
||||
<Flex key={index} dir="row" w="100%" h="100%" p={1} align="center" justify="space-between">
|
||||
<>
|
||||
<br />
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
<Flex key={index} dir="row" w="100%" h="100%" p={1} align="center" justify="space-between">
|
||||
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
|
||||
<Button asChild variant="surface" size="xxs" fontSize="xs" pl={2} pr={2}>
|
||||
<a href={file?.url} target="_blank" rel="noopener noreferrer">Download</a>
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
@@ -249,12 +161,30 @@ export default function RemoteSensing({ settings, widgetID }) {
|
||||
</Collapsible.Context>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
else if (thisWidget?.size === "small") return (
|
||||
<Flex key={index} direction="row" w="100%" justify="space-between" align="center" gap={2} p={1} pl={3} pr={3} borderRadius="lg" bg="widgetItemBg">
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
|
||||
<Text truncate>{item?.volcano_name}</Text>
|
||||
<Text truncate fontSize="xs" color="widgetTextMuted">{item?.country}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text
|
||||
truncate
|
||||
pl={2} pr={2}
|
||||
borderRadius="lg"
|
||||
bg="widgetBadgeBg" color="text"
|
||||
fontSize="sm" fontWeight="semibold"
|
||||
>
|
||||
{item?.report_time}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ascTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
import {Box, Flex, Spinner, Text} from "@chakra-ui/react";
|
||||
import { Box, Flex, Spinner, Text } from "@chakra-ui/react";
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
// 1. Import Chakra color mode and your central color dictionary
|
||||
import { useColorMode } from "@/components/ui/color-mode.jsx";
|
||||
import { rawWidgetColors } from '../../theme.js';
|
||||
|
||||
export default function SeismicEvents({ settings, widgetID}) {
|
||||
const { colorMode } = useColorMode();
|
||||
// 2. Assign the active palette based on the current mode
|
||||
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
|
||||
|
||||
// build url, defaults to 7 days past
|
||||
const url = useBuildUrl('/api/v2/seismic_events', settings?.startDate, settings?.endDate);
|
||||
|
||||
@@ -44,7 +52,7 @@ export default function SeismicEvents({ settings, widgetID}) {
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
|
||||
line: { width: .5, color: plotColors.text },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
@@ -53,48 +61,36 @@ export default function SeismicEvents({ settings, widgetID}) {
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Number of Seismic Events: %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData]);
|
||||
}, [apiData, plotColors]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#d1d5db' }, // gray.300
|
||||
font: { color: plotColors.muted },
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
// title: {
|
||||
// text: '',
|
||||
// font: { size: 16, color: 'white' },
|
||||
// x: 0.05
|
||||
// },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#4b5563',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.grid,
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Seismic Count',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'linear',
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#ffffff',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.text,
|
||||
automargin: true,
|
||||
},
|
||||
// legend: {
|
||||
// orientation: 'h',
|
||||
// y: -0.2,
|
||||
// x: 0.5,
|
||||
// xanchor: 'center'
|
||||
// },
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
@@ -105,33 +101,34 @@ export default function SeismicEvents({ settings, widgetID}) {
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: 'white', width: 1 }
|
||||
line: { color: plotColors.text, width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
}, [plotColors]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (plotTraces.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No data available for current filter.</Text>
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
|
||||
<Text color="var(--chakra-colors-widgetTextMuted)">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={colorMode}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Gantt, Willow } from "@svar-ui/react-gantt";
|
||||
import "@svar-ui/react-gantt/all.css";
|
||||
import { useMemo } from "react";
|
||||
import {Avatar, Flex, Text} from "@chakra-ui/react";
|
||||
|
||||
export default function TravelGanttChart() {
|
||||
|
||||
const upcoming_travel = useMemo(() => [
|
||||
{
|
||||
id: 20,
|
||||
text: "Guatemala",
|
||||
start: new Date(2026, 9, 21),
|
||||
end: new Date(2026, 9, 29),
|
||||
duration: 8,
|
||||
participants: ["Chris Hight", "Angie Diefenbach"],
|
||||
type: "travel",
|
||||
},
|
||||
{
|
||||
id: 47,
|
||||
text: "Venuatu",
|
||||
start: new Date(2026, 10, 12),
|
||||
end: new Date(2026, 10, 15),
|
||||
duration: 3,
|
||||
participants: ["Chris Hight"],
|
||||
type: "travel",
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
text: "Costa Rica",
|
||||
start: new Date(2027, 1, 1),
|
||||
end: new Date(2027, 1, 10),
|
||||
duration: 9,
|
||||
participants: ["Chris Hight"],
|
||||
type: "travel",
|
||||
},
|
||||
], []);
|
||||
|
||||
const columns = [
|
||||
{ id: "start", header: "Start Date", width: 100, height: 200, align: "left" },
|
||||
{ id: "duration", header: "Duration", width: 100, height: 200, align: "left" },
|
||||
{ id: "participants", header: "Participants", width: 150, height: 200, align: "center", cell: AvatarCell, flexgrow: 1 },
|
||||
]
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", width: "100%" }}>
|
||||
<Willow>
|
||||
<Gantt tasks={upcoming_travel} cellWidth={20} columns={columns} />
|
||||
</Willow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// assume each user has access to the full list of VDAP users
|
||||
|
||||
const users = [
|
||||
{
|
||||
fullname: "Chris Hight",
|
||||
firstname: "Chris",
|
||||
lastname: "Hight",
|
||||
avatar: undefined
|
||||
},
|
||||
{
|
||||
fullname: "Angie Diefenbach",
|
||||
firstname: "Chris",
|
||||
lastname: "Hight",
|
||||
avatar: undefined
|
||||
},
|
||||
{
|
||||
fullname: "Venessa Kuchenik",
|
||||
firstname: "Venessa",
|
||||
lastname: "Kuchenik",
|
||||
avatar: undefined
|
||||
},
|
||||
]
|
||||
|
||||
function AvatarCell({ row }) {
|
||||
const travelers = row.participants.map((r) => {
|
||||
return users.find((u) => u.fullname === r);
|
||||
});
|
||||
|
||||
return (
|
||||
<Flex direction="column" alignItems="left" justifyContent="space-between">
|
||||
{travelers.map((traveler) => (
|
||||
<Avatar.Root shape="full">
|
||||
<Avatar.Image src={traveler.avatar} />
|
||||
<Avatar.Fallback name={traveler.fullname} />
|
||||
<Text>{traveler.fullname}</Text>
|
||||
</Avatar.Root>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
|
||||
import Plot from 'react-plotly.js';
|
||||
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import { useColorMode } from "@/components/ui/color-mode.jsx";
|
||||
|
||||
// Import central color dictionary
|
||||
import { rawWidgetColors } from '../../theme.js'; // Adjust path if needed
|
||||
|
||||
export default function VaaActivity7Day({ settings, widgetID }) {
|
||||
console.log("3. Chart received settings:", settings);
|
||||
// Plotly expects 'linear' for normal axes
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
// 2. Assign the active palette based on the current mode
|
||||
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
|
||||
|
||||
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
|
||||
|
||||
// Clean, single-line data fetching
|
||||
@@ -17,13 +24,10 @@ export default function VaaActivity7Day({ settings, widgetID }) {
|
||||
|
||||
// Array of distinct Plotly shapes
|
||||
const Symbolway = [
|
||||
'circle', 'square', 'diamond',
|
||||
'triangle-up', 'triangle-down',
|
||||
'star', 'hexagram', 'pentagon',
|
||||
'hexagon', 'octagon', 'cross', 'x'
|
||||
'circle', 'square', 'diamond', 'triangle-up', 'triangle-down',
|
||||
'star', 'hexagram', 'pentagon', 'hexagon', 'octagon', 'cross', 'x'
|
||||
];
|
||||
|
||||
// Sort data descending by flight level (matching Observable behavior)
|
||||
const sortedData = [...apiData].sort((a, b) => b.obs_fl - a.obs_fl);
|
||||
|
||||
// Efficiently group into Plotly traces by volcano
|
||||
@@ -44,7 +48,7 @@ export default function VaaActivity7Day({ settings, widgetID }) {
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
|
||||
line: { width: 0.5, color: plotColors.text },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean Observable tooltip
|
||||
@@ -53,90 +57,78 @@ export default function VaaActivity7Day({ settings, widgetID }) {
|
||||
'Date: %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Flight Level: %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData]);
|
||||
}, [apiData, plotColors]);
|
||||
|
||||
// 2. Build the Layout (Styling and Grid)
|
||||
const layout = useMemo(() => {
|
||||
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
paper_bgcolor: 'transparent',
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#d1d5db' }, // gray.300
|
||||
font: { color: plotColors.muted },
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
// title: {
|
||||
// text: '7 Day Consolidated Flight Levels',
|
||||
// font: { size: 16, color: 'white' },
|
||||
// x: 0.05
|
||||
// },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y %H:%M', // Multiline ticks from Observable
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#4b5563',
|
||||
tickformat: '%b %d<br>%Y %H:%M',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.grid,
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Flight Level',
|
||||
font: { color: 'white', size: 12 }
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: plotYAxisType,
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#ffffff',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.text,
|
||||
automargin: true,
|
||||
},
|
||||
// legend: {
|
||||
// orientation: 'h',
|
||||
// y: -0.2,
|
||||
// x: 0.5,
|
||||
// xanchor: 'center'
|
||||
// },
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: 0,
|
||||
x1: 1,
|
||||
xref: 'paper', // Spans entire chart width
|
||||
xref: 'paper',
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: 'white', width: 1 }
|
||||
line: { color: plotColors.text, width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, [apiData, plotYAxisType]);
|
||||
}, [plotYAxisType, plotColors]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
||||
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (plotTraces.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No flight level data available.</Text>
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
|
||||
<Text color="widgetTextMuted">No flight level data available.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="gray.800" borderRadius="md" p={2} overflow="hidden">
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={plotYAxisType}
|
||||
key={`${plotYAxisType}-${colorMode}`}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
useResizeHandler={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useRef, useMemo, useEffect } from "react";
|
||||
import {MapContainer, useMap, TileLayer, Polygon, Tooltip} from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import {Flex, Spinner, Text} from "@chakra-ui/react";
|
||||
import L from 'leaflet';
|
||||
|
||||
export default function VaaMap({ settings, widgetID}) {
|
||||
const mapRef = useRef(null);
|
||||
|
||||
// TEST DATA
|
||||
const mock_data = useMemo( () =>
|
||||
[
|
||||
{
|
||||
"vaa_datetime": "2026-08-18T18:01:00Z",
|
||||
"vnum": 342090,
|
||||
"volc_name": "Fuego",
|
||||
"volc_elev_m": 3763,
|
||||
"vaac_location": "Washington",
|
||||
"obs_va_datetime": "2026-08-18T17:30:00Z",
|
||||
"obs_va_epoch": 1787074200000,
|
||||
"aviation_color_code": null,
|
||||
"obs_fl": "140",
|
||||
"obs_fl_dir": "SW",
|
||||
"obs_h_asl_m": 4267.0,
|
||||
"obs_h_avs_m": 504.0,
|
||||
"projections":
|
||||
{
|
||||
"six": "25/2030Z SFC/FL160 N1446 W09202 - N1428 W09052 - N1423 W09200 - N1446 W09202",
|
||||
"twelve": "26/0230Z SFC/FL160 N1444 W09203 - N1428 W09052 - N1422 W09201 - N1444 W09203",
|
||||
"eighteen": "26/0830Z SFC/FL160 N1441 W09204 - N1428 W09052 - N1419 W09202 - N1441 W09204"
|
||||
}
|
||||
},
|
||||
{
|
||||
"vaa_datetime": "2026-08-25T01:01:00Z",
|
||||
"vnum": 282080,
|
||||
"volc_name": "Sabancaya",
|
||||
"volc_elev_m": 1117,
|
||||
"vaac_location": "Tokyo",
|
||||
"obs_va_datetime": "2026-08-25T00:50:00Z",
|
||||
"obs_va_epoch": 1787619000000,
|
||||
"aviation_color_code": null,
|
||||
"obs_fl": "180",
|
||||
"obs_fl_dir": null,
|
||||
"obs_h_asl_m": 5486,
|
||||
"obs_h_avs_m": 4369,
|
||||
"projections":
|
||||
{
|
||||
"six": "14/0200Z SFC/FL250 S1532 W07036 - S1552 W07029 - S1613 W07035 - S1546 W07150 - S1532 W07036 ",
|
||||
"twelve": "14/0800Z SFC/FL250 S1513 W07045 - S1527 W07033 - S1551 W07029 - S1544 W07150 - S1513 W07045 ",
|
||||
"eighteen": "14/1400Z SFC/FL250 S1511 W07044 - S1531 W07028 - S1550 W07027 - S1546 W07151 - S1511 W07044 "
|
||||
},
|
||||
},
|
||||
]
|
||||
, []);
|
||||
|
||||
const {data, isLoading, error} = useMemo(() => {
|
||||
const data = settings?.data?.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime));
|
||||
const isLoading = settings?.isLoading;
|
||||
const error = settings?.error;
|
||||
return {data, isLoading, error};
|
||||
}, [settings.data, settings.isLoading, settings.error]);
|
||||
|
||||
// convert Degree & Minutes (DM) to Decimal Degrees (DD)
|
||||
const polygons = useMemo(() => {
|
||||
if (!mock_data) return []; // TODO: replace with real data
|
||||
return mock_data.map((vaa) => { // TODO: replace with real data
|
||||
const six_hrs = convertDMtoDD(vaa.projections.six);
|
||||
const twelve_hrs = convertDMtoDD(vaa.projections.twelve);
|
||||
const eighteen_hrs = convertDMtoDD(vaa.projections.eighteen);
|
||||
return {...vaa, projections: [six_hrs, twelve_hrs, eighteen_hrs]};
|
||||
})
|
||||
}, [mock_data]);
|
||||
|
||||
const lineColors= {
|
||||
0: { color: 'red' },
|
||||
1: { color: 'purple' },
|
||||
2: { fillColor: 'blue' },
|
||||
}
|
||||
|
||||
const hourCode = {
|
||||
0: "+6 hours",
|
||||
1: "+12 hours",
|
||||
2: "+18 hours",
|
||||
}
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data?.length === 0) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text textAlign="center" color="gray.400">No VAA data available.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
ref={mapRef}
|
||||
style={{height: "100%", width: "100%"}}
|
||||
maxBounds={[[-90, -180], [90, 180]]}
|
||||
minZoom={2} // smallest zoom allowed before empty edges start appearing
|
||||
>
|
||||
<TileLayer
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution='© OpenStreetMap contributors'
|
||||
noWrap
|
||||
/>
|
||||
{polygons?.map((polygon) => {
|
||||
return polygon?.projections?.map((coordinates, index) =>
|
||||
<Polygon key={index} pathOptions={lineColors[index]} positions={coordinates}>
|
||||
<Tooltip sticky>
|
||||
<Flex direction="column">
|
||||
<Flex direction="row" justify="space-between" gap={3} bg="gray.300" borderRadius="sm">
|
||||
<Text pl={1} fontWeight="bold">{polygon?.volc_name} </Text>
|
||||
<Text pr={1} fontWeight="semibold">{hourCode[index]}</Text>
|
||||
</Flex>
|
||||
<Flex direction="row" justify="space-between" gap={3}>
|
||||
<Text pl={1} fontWeight="semibold">VAA Datetime:</Text>
|
||||
<Text pr={1}>{polygon?.vaa_datetime}</Text>
|
||||
</Flex>
|
||||
<Flex direction="row" justify="space-between" gap={3}>
|
||||
<Text pl={1} fontWeight="semibold">VAAC Location:</Text>
|
||||
<Text pr={1}>{polygon?.vaac_location}</Text>
|
||||
</Flex>
|
||||
<Flex direction="row" justify="space-between" gap={3}>
|
||||
<Text pl={1} fontWeight="semibold">Height From Vent:</Text>
|
||||
<Text pr={1}>{polygon?.obs_h_avs_m} (m)</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
</Polygon>
|
||||
)
|
||||
})}
|
||||
<ZoomToPolygon polygons={polygons} settings={settings} />
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function convertDMtoDD(DMString) {
|
||||
const cleaned_DMString = DMString.trim().replace(/-/g, "").split(/\s+/);
|
||||
|
||||
const coordinates = cleaned_DMString.slice(2).map((coordinate) => {
|
||||
const direction = coordinate[0];
|
||||
if (direction === "N" || direction === "S") { // 4 digits long
|
||||
const lat = Number(coordinate.slice(1, 3)) + Number((Number(coordinate.slice(3, 5)) / 60).toFixed(5));
|
||||
return direction === "S" ? -lat : lat; // south has negative coordinates
|
||||
} else if (direction === "E" || direction === "W") { // 5 digits long
|
||||
const long = Number(coordinate.slice(1, 4)) + Number((Number(coordinate.slice(4, 6)) / 60).toFixed(5));
|
||||
return direction === "W" ? -long : long; // west has negative coordinates
|
||||
}
|
||||
});
|
||||
|
||||
// pair up lat and long into their own array pairs
|
||||
let polygon = [];
|
||||
for (let i = 0; i < coordinates.length; i+=2) polygon.push([coordinates[i], coordinates[i+1]]);
|
||||
return polygon;
|
||||
}
|
||||
|
||||
function ZoomToPolygon({ polygons, settings }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
let targetBounds;
|
||||
let default_vaa_object;
|
||||
const selectedVAA = settings?.selected;
|
||||
const defaultVAA = settings?.defaultVAA;
|
||||
|
||||
// selectedVAA and defualtVAA are filled in dynamically so they may not always be defined, defualt to hardcoded CVO coordinates
|
||||
if (!selectedVAA && !defaultVAA) {
|
||||
targetBounds = [settings.position];
|
||||
// console.log("zooming to cvo");
|
||||
}
|
||||
|
||||
// if defaultVAA is defined and user hasn't selected a vaa to pan to, then use defaultVAA coordinates
|
||||
else if (!selectedVAA) {
|
||||
// console.log("zooming to", defaultVAA);
|
||||
default_vaa_object = polygons.find((vaa) => vaa.volc_name === defaultVAA);
|
||||
// if for whatever reason defaultVAA isn't found in list of vaa data, default to hardcoded CVO coordinates
|
||||
default_vaa_object ?
|
||||
targetBounds = default_vaa_object?.projections?.[0] :
|
||||
targetBounds = [settings?.position];
|
||||
}
|
||||
|
||||
else if (selectedVAA) {
|
||||
// console.log("zooming to", selectedVAA);
|
||||
const vaa_object = polygons.find((vaa) => vaa.volc_name === selectedVAA);
|
||||
// if selected VAA is not found (say user just started typing in search) will default to defaultVAA, unless defualtVAA is also undefined
|
||||
vaa_object ?
|
||||
targetBounds = vaa_object?.projections?.[0] :
|
||||
(default_vaa_object ?
|
||||
targetBounds = default_vaa_object?.projections?.[0] :
|
||||
targetBounds = [settings?.position]);
|
||||
}
|
||||
|
||||
if (targetBounds) {
|
||||
try {
|
||||
const bounds = L.latLngBounds(targetBounds);
|
||||
if (bounds.isValid()) map.fitBounds(bounds, {padding: [50, 50], animate: true, maxZoom: 8});
|
||||
} catch (err) {
|
||||
console.warn('Invalid bounds structure passed to fitBounds:', targetBounds, err);
|
||||
}
|
||||
}
|
||||
}, [map, polygons, settings.position, settings.selected, settings.defaultVAA]);
|
||||
}
|
||||
@@ -4,21 +4,23 @@ import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
console.log("currentSettings:", currentSettings);
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" gap={2} pb={2}>
|
||||
<Flex flexDirection="column" justifyContent="space-between" align="center" gap={2} pb={2}>
|
||||
{/* SLIDER DATE RANGE SELECTION */}
|
||||
<Slider.Root
|
||||
width="80%" h="100%"
|
||||
w="90%" h="100%"
|
||||
min={1} max={14}
|
||||
defaultValue={[7]}
|
||||
defaultValue={[1]}
|
||||
step={1}
|
||||
onValueChangeEnd={(e) => {
|
||||
console.log("change end", e);
|
||||
const day = new Date(Date.now() - (e.value[0])*86400000).toLocaleDateString('en-CA');
|
||||
console.log("new start: ", day);
|
||||
updateSettings(widgetId, {...currentSettings, startDate: day});
|
||||
}}
|
||||
>
|
||||
<Slider.Label>{currentSettings?.endDate} to {currentSettings?.startDate}</Slider.Label>
|
||||
<Slider.Label>{currentSettings?.startDate} to {currentSettings?.endDate}</Slider.Label>
|
||||
<Slider.Control>
|
||||
<Slider.Track>
|
||||
<Slider.Range />
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function CommCoorReportSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
updateSettings(widgetId, {copyToClipboard: true});
|
||||
}
|
||||
|
||||
const handleDownloadReport = () => {
|
||||
updateSettings(widgetId, {downloadReport: true});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" gap={2} overflowY="auto">
|
||||
{/* COPY TO CLIPBOARD */}
|
||||
<Button w="fit" size="xs" onClick={handleCopyToClipboard}>Copy Report</Button>
|
||||
|
||||
{/* DOWNLOAD REPORT */}
|
||||
<Button w="fit" size="xs" onClick={handleDownloadReport}>Download Report</Button>
|
||||
|
||||
{/* SELECTING DATE RANGE */}
|
||||
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.startDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, startDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
|
||||
else setStartError("");
|
||||
}}
|
||||
/>
|
||||
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.endDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, endDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
|
||||
else setEndError("");
|
||||
}}
|
||||
/>
|
||||
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {useMemo, useEffect, useState} from "react";
|
||||
import VdapApi from "@/hooks/useVdapApi.js";
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import {Combobox, createListCollection, Portal} from "@chakra-ui/react";
|
||||
|
||||
export default function VaaMapSettings({widgetId, currentSettings}) {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
const url = useBuildUrl("api/v2/comm_coor_vaa", currentSettings?.startDate, currentSettings?.endDate);
|
||||
const {data, isLoading, error} = VdapApi(url);
|
||||
|
||||
// retrieving only the latest VAA for each volcano (resultant has no order)
|
||||
const latest_vaas = useMemo(() => {
|
||||
if (!data || !Array.isArray(data)) return undefined;
|
||||
// retrieves last occurance of vaa, so the vaa's need to stay in rev-chrono order here to work
|
||||
return [...new Map(data.map(item => [item['volc_name'], item])).values()];
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latest_vaas) updateSettings(widgetId, {isLoading: isLoading, error: error});
|
||||
else updateSettings(widgetId, {data: latest_vaas, isLoading: isLoading, error: error});
|
||||
}, [updateSettings, widgetId, latest_vaas, isLoading, error]);
|
||||
|
||||
// find volc_name of last issued vaa, map will default to this ash polygon
|
||||
const default_vaa = useMemo(() => {
|
||||
if (!latest_vaas || latest_vaas.length === 0) return undefined;
|
||||
const chrono_order = latest_vaas.sort((a, b) => new Date(b.vaa_datetime) - new Date(a.vaa_datetime));
|
||||
// console.log(chrono_order);
|
||||
// console.log(chrono_order[0].volc_name);
|
||||
return chrono_order[0].volc_name;
|
||||
}, [latest_vaas]);
|
||||
|
||||
// set default vaa in settings
|
||||
useEffect(() => {
|
||||
if (default_vaa) updateSettings(widgetId, {defaultVAA: default_vaa});
|
||||
}, [default_vaa, updateSettings, widgetId]);
|
||||
|
||||
|
||||
// filtering based on what user types in search bar
|
||||
const filtered_vaas = useMemo(() => {
|
||||
if (!inputValue.trim()) return latest_vaas;
|
||||
return latest_vaas?.filter((item) => item.volc_name.toLowerCase().includes(inputValue.toLowerCase()));
|
||||
}, [latest_vaas, inputValue]);
|
||||
|
||||
const vaa_collection = useMemo(() => {
|
||||
return createListCollection({
|
||||
items: filtered_vaas?.sort((a, b) => a.volc_name.localeCompare(b.volc_name)),
|
||||
itemToString: (item) => item.volc_name,
|
||||
itemToValue: (item) => item.volc_name,
|
||||
});
|
||||
}, [filtered_vaas]);
|
||||
|
||||
// update state w/ selected vaa so map zooms to polygon
|
||||
useEffect(() => {
|
||||
updateSettings(widgetId, {selected: inputValue});
|
||||
}, [updateSettings, widgetId, inputValue]);
|
||||
|
||||
|
||||
return (
|
||||
<Combobox.Root openOnClick collection={vaa_collection} onInputValueChange={(e) => setInputValue(e.inputValue)}>
|
||||
<Combobox.Label>Select a Volcano</Combobox.Label>
|
||||
<Combobox.Control>
|
||||
<Combobox.Input placeholder="Search recent VAA's by volcano name"/>
|
||||
<Combobox.IndicatorGroup>
|
||||
<Combobox.ClearTrigger />
|
||||
</Combobox.IndicatorGroup>
|
||||
</Combobox.Control>
|
||||
|
||||
<Portal>
|
||||
<Combobox.Positioner>
|
||||
<Combobox.Content>
|
||||
<Combobox.Empty>No VAA's found</Combobox.Empty>
|
||||
{vaa_collection.items.map((vaa) =>
|
||||
<Combobox.Item key={vaa?.volc_name} item={vaa}>
|
||||
{vaa?.volc_name}
|
||||
</Combobox.Item>
|
||||
)}
|
||||
</Combobox.Content>
|
||||
</Combobox.Positioner>
|
||||
</Portal>
|
||||
</Combobox.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import {useState, useMemo, useRef, useEffect, useCallback} from "react";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Box,
|
||||
Text,
|
||||
Flex,
|
||||
@@ -10,25 +9,64 @@ import {
|
||||
Tabs,
|
||||
Badge,
|
||||
Menu,
|
||||
HStack
|
||||
HStack,
|
||||
useRecipe,
|
||||
useSlotRecipe
|
||||
} from "@chakra-ui/react";
|
||||
import { Settings, Plus, LayoutGrid, Move, Save, X } from 'lucide-react';
|
||||
import { useDashboardStore } from "../../store/useDashboardStore.jsx";
|
||||
import { WIDGET_REGISTRY } from "../../constants/widgetRegistry.jsx";
|
||||
import {Settings, LayoutGrid, Move, Save, X, ChevronRight, ChevronLeft} from 'lucide-react';
|
||||
import {useDashboardStore} from "../../store/useDashboardStore.jsx";
|
||||
import {WIDGET_REGISTRY} from "../../constants/widgetRegistry.jsx";
|
||||
|
||||
// 🚨 NEW: Tiny helper to convert size keys into abbreviations
|
||||
const getSizeAbbreviation = (key) => {
|
||||
const mapping = {small: "S", medium: "M", large: "L", xlarge: "XL"};
|
||||
return mapping[key.toLowerCase()] || key.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
export function SettingsButton() {
|
||||
// 1. ☁Store connections
|
||||
const isEditing = useDashboardStore(state => state.isEditing);
|
||||
const enterEditMode = useDashboardStore(state => state.enterEditMode);
|
||||
|
||||
// This now handles both local UI lock AND database saving!
|
||||
const saveEdit = useDashboardStore(state => state.saveEdit);
|
||||
|
||||
const cancelEdit = useDashboardStore(state => state.cancelEdit);
|
||||
const addWidget = useDashboardStore(state => state.addWidget);
|
||||
|
||||
const glassTabRecipe = useRecipe({key: "glassTab"});
|
||||
const tabStyles = glassTabRecipe();
|
||||
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
// Pull the glass container styles for the dropdown and modal
|
||||
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
|
||||
const popoverStyles = popoverRecipe();
|
||||
|
||||
const dialogRecipe = useSlotRecipe({ key: "glassDialog" });
|
||||
const dialogStyles = dialogRecipe();
|
||||
|
||||
const [isLibraryOpen, setIsLibraryOpen] = useState(false);
|
||||
|
||||
// ⬅️ ➡️ This is the callbacks and scroll control to add the chevron arrows in the tab list
|
||||
const tabsRef = useRef(null);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
if (tabsRef.current) {
|
||||
const {scrollLeft, scrollWidth, clientWidth} = tabsRef.current;
|
||||
setCanScrollLeft(scrollLeft > 0);
|
||||
// We use -1 here to account for sub-pixel rendering in some browsers
|
||||
setCanScrollRight(Math.ceil(scrollLeft + clientWidth) < scrollWidth - 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLibraryOpen) {
|
||||
// Small timeout ensures the DOM has rendered the tabs before measuring
|
||||
setTimeout(checkScroll, 50);
|
||||
window.addEventListener('resize', checkScroll);
|
||||
return () => window.removeEventListener('resize', checkScroll);
|
||||
}
|
||||
}, [isLibraryOpen, checkScroll]);
|
||||
|
||||
// Smart Add Handler
|
||||
const handleAddWidget = (widgetId, sizeKey) => {
|
||||
enterEditMode();
|
||||
@@ -39,7 +77,7 @@ export function SettingsButton() {
|
||||
return Object.entries(WIDGET_REGISTRY).reduce((acc, [id, widget]) => {
|
||||
const cat = widget.category || 'General';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push({ id, ...widget });
|
||||
acc[cat].push({id, ...widget});
|
||||
return acc;
|
||||
}, {});
|
||||
}, []);
|
||||
@@ -55,94 +93,172 @@ export function SettingsButton() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* THE STATIC DROPDOWN MENU */}
|
||||
<Menu.Root positioning={{ getAnchorRect }}>
|
||||
<Menu.Trigger asChild>
|
||||
<IconButton
|
||||
ref={menuRef}
|
||||
aria-label="Settings"
|
||||
bg="vdap.darkGreen"
|
||||
color="white"
|
||||
size="sm"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: "vdap.darkGreenHover" }}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</IconButton>
|
||||
</Menu.Trigger>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content bg="white" boxShadow="lg" borderRadius="md" p={1} zIndex="dropdown">
|
||||
{isEditing ? (
|
||||
<>
|
||||
{/* WIRE DIRECTLY TO saveEdit */}
|
||||
<Menu.Item onClick={saveEdit} color="green.600" fontWeight="bold" cursor="pointer" _hover={{ bg: "green.50" }}>
|
||||
<Save size={16} style={{ marginRight: '8px' }} /> Save Layout
|
||||
</Menu.Item>
|
||||
// 📝 EDIT MODE: The 3-Pill Layout
|
||||
<HStack>
|
||||
{/* 1. The Add Widget Button */}
|
||||
<Box as="button" css={tabStyles} data-state="open" onClick={() => setIsLibraryOpen(true)}
|
||||
color={{base: "blue.500", _dark: "blue.300"}} _hover={{bg: "rgba(66, 153, 225, 0.1)"}}
|
||||
>
|
||||
<LayoutGrid size={20} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Widget Library</Box>
|
||||
</Box>
|
||||
|
||||
<Menu.Item onClick={cancelEdit} color="red.500" cursor="pointer" _hover={{ bg: "red.50" }}>
|
||||
<X size={16} style={{ marginRight: '8px' }} /> Discard Changes
|
||||
</Menu.Item>
|
||||
</>
|
||||
{/* 2. The Save Button */}
|
||||
<Box as="button" css={tabStyles} data-state="open" onClick={saveEdit} color={{base: "green.600", _dark: "green.400"}}
|
||||
_hover={{bg: "rgba(72, 187, 120, 0.1)"}}
|
||||
>
|
||||
<Save size={20} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Save Layout</Box>
|
||||
</Box>
|
||||
|
||||
{/* 3. The Discard Button */}
|
||||
<Box as="button" css={tabStyles} data-state="open" onClick={cancelEdit} color={{base: "red.500", _dark: "red.400"}}
|
||||
_hover={{bg: "rgba(245, 101, 101, 0.1)"}}
|
||||
>
|
||||
<X size={20} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Discard</Box>
|
||||
</Box>
|
||||
</HStack>
|
||||
) : (
|
||||
<Menu.Item onClick={enterEditMode} cursor="pointer" _hover={{ bg: "gray.100" }}>
|
||||
<Move size={16} style={{ marginRight: '8px' }} /> Edit Layout
|
||||
// 👀 VIEW MODE: The Static Dropdown Menu
|
||||
<Menu.Root positioning={{getAnchorRect}}>
|
||||
<Menu.Trigger asChild>
|
||||
<Box ref={menuRef} as="button" css={tabStyles}>
|
||||
{/* settings icon size set to 24 to match tabs. flexShrink 0 is CRITICAL */}
|
||||
<Settings size={24} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Dashboard Settings</Box>
|
||||
</Box>
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Positioner>
|
||||
<Menu.Content css={popoverStyles.content} zIndex="dropdown" minW="200px">
|
||||
<Menu.Item
|
||||
onClick={enterEditMode}
|
||||
cursor="pointer"
|
||||
_hover={{WebkitTextStroke: "0.5px currentColor"}}
|
||||
>
|
||||
<Move size={16} style={{marginRight: '8px'}}/> Edit Layout
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Separator my={1} borderColor="gray.200" />
|
||||
<Menu.Separator my={1} borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}/>
|
||||
|
||||
<Menu.Item onClick={() => setIsLibraryOpen(true)} cursor="pointer" _hover={{ bg: "gray.100" }}>
|
||||
<LayoutGrid size={16} style={{ marginRight: '8px' }} /> Widget Library
|
||||
<Menu.Item
|
||||
onClick={() => setIsLibraryOpen(true)}
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
bg: {base: "rgba(0, 0, 0, 0.05)", _dark: "rgba(255, 255, 255, 0.1)"},
|
||||
WebkitTextStroke: "0.5px currentColor"
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={16} style={{marginRight: '8px'}}/> Widget Library
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Menu.Root>
|
||||
)}
|
||||
|
||||
{/* THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
|
||||
<Dialog.Root open={isLibraryOpen} onOpenChange={(e) => setIsLibraryOpen(e.open)} size="xl" placement="center">
|
||||
<Dialog.Backdrop bg="blackAlpha.600" />
|
||||
{/* 📚📚📚 THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
|
||||
<Dialog.Root
|
||||
open={isLibraryOpen}
|
||||
onOpenChange={(e) => setIsLibraryOpen(e.open)}
|
||||
closeOnInteractOutside={true}
|
||||
closeOnEscapeKeyDown={true}
|
||||
size="xl"
|
||||
placement="center"
|
||||
>
|
||||
{/* Soft blur can be added to the background behind the modal */}
|
||||
{/*<Dialog.Backdrop bg="blackAlpha.100" backdropFilter="blur(2px)"/>*/}
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content bg="gray.50" borderRadius="xl" overflow="hidden" boxShadow="xl" maxW="800px">
|
||||
<Dialog.Header bg="vdap.darkGreen" color="white" py={4}>
|
||||
<Dialog.Title fontSize="xl" fontWeight="bold">Add Dashboard Widgets</Dialog.Title>
|
||||
<Dialog.Content css={dialogStyles.content}>
|
||||
|
||||
<Dialog.Header css={dialogStyles.header}>
|
||||
<Dialog.Title>Widgets Shop</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body p={0}>
|
||||
<Tabs.Root defaultValue={categories[0]} variant="enclosed" colorScheme="blue">
|
||||
<Tabs.List bg="white" px={4} pt={4} borderBottom="1px solid" borderColor="gray.200">
|
||||
<Dialog.Body css={dialogStyles.body}>
|
||||
<Tabs.Root defaultValue={categories[0]} variant="unstyled">
|
||||
{/* HORIZONTAL SCROLLABLE MENU OF WIDGET CATEGORIES */}
|
||||
<Box>
|
||||
{/* left glass crescent */}
|
||||
{canScrollLeft && (
|
||||
<Flex onClick={() => tabsRef.current.scrollBy({left: -200, behavior: 'smooth'})}>
|
||||
<ChevronLeft size={18} style={{ marginLeft: "-30px" }}/> {/* Shifted slightly left to optically center it in the crescent mass */}
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* the categories */}
|
||||
<Tabs.List ref={tabsRef} onScroll={checkScroll}
|
||||
// For desktop users (not on trackpad) to scroll
|
||||
onWheel={(e) => { if (e.currentTarget) e.currentTarget.scrollLeft += e.deltaY; }}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<Tabs.Trigger key={cat} value={cat} pb={2}>{cat}</Tabs.Trigger>
|
||||
<Tabs.Trigger key={cat} value={cat}>
|
||||
{cat}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{/* right glass crescent */}
|
||||
{canScrollRight && (
|
||||
<Flex onClick={() => tabsRef.current.scrollBy({left: 200, behavior: 'smooth'})}>
|
||||
{/* Shifted slightly right to optically center it in the crescent mass */}
|
||||
<ChevronRight size={18} style={{ marginRight: "-30px" }}/>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* THE WIDGET OPTIONS W/IN EACH CATEGORY */}
|
||||
{categories.map(cat => (
|
||||
<Tabs.Content key={cat} value={cat} p={6}>
|
||||
<Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}>
|
||||
{categorizedWidgets[cat].map(widget => (
|
||||
<Flex key={widget.id} direction="column" bg="white" p={4} borderRadius="md" border="1px solid" borderColor="gray.200" boxShadow="sm">
|
||||
<Flex
|
||||
key={widget.id}
|
||||
direction="column"
|
||||
bg={{base: "rgba(255, 255, 255, 0.6)", _dark: "rgba(0, 0, 0, 0.2)"}}
|
||||
p={4}
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor={{base: "rgba(255, 255, 255, 0.9)", _dark: "whiteAlpha.200"}}
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Text fontWeight="bold" fontSize="md" mb={1}>{widget.name}</Text>
|
||||
<Text fontSize="sm" color="gray.500" mb={4} flex="1">{widget.description}</Text>
|
||||
|
||||
<Box borderTop="1px solid" borderColor="gray.100" pt={3}>
|
||||
<Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase" mb={2}>Add to Grid:</Text>
|
||||
<Box borderTop="1px solid" borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}
|
||||
pt={3}>
|
||||
<Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase"
|
||||
mb={2}>
|
||||
Add to Dashboard:
|
||||
</Text>
|
||||
<HStack gap={2} flexWrap="wrap">
|
||||
{Object.keys(widget.allowedSizes).map(sizeKey => (
|
||||
<Badge
|
||||
<Box
|
||||
key={sizeKey}
|
||||
as="button"
|
||||
css={tabStyles}
|
||||
onClick={() => handleAddWidget(widget.id, sizeKey)}
|
||||
colorScheme="gray"
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: "blue.50", color: "blue.600", transform: "scale(1.05)" }}
|
||||
transition="all 0.2s"
|
||||
color={{base: "blue.600", _dark: "blue.300"}}
|
||||
>
|
||||
<Plus size={12} style={{ display: 'inline', marginRight: '4px' }}/>
|
||||
{sizeKey.charAt(0).toUpperCase() + sizeKey.slice(1)}
|
||||
</Badge>
|
||||
{/* THE ABBREVIATION ICON: Always visible */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
w="24px"
|
||||
h="24px"
|
||||
flexShrink={0}
|
||||
borderRadius="full"
|
||||
fontWeight="black"
|
||||
fontSize="xs"
|
||||
bg='transparent'
|
||||
>
|
||||
{getSizeAbbreviation(sizeKey)}
|
||||
</Flex>
|
||||
|
||||
{/* THE EXPANDED TEXT: Only visible on hover */}
|
||||
<Box as="span">Add Widget</Box>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
@@ -153,7 +269,8 @@ export function SettingsButton() {
|
||||
))}
|
||||
</Tabs.Root>
|
||||
</Dialog.Body>
|
||||
<Dialog.CloseTrigger position="absolute" top={3} right={3} color="white" />
|
||||
<Dialog.CloseTrigger position="absolute" top={3} right={3}
|
||||
color={{base: "gray.500", _dark: "whiteAlpha.800"}} _hover={{color: "red.400"}}/>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import Widget from "./Widget.jsx";
|
||||
import { Fieldset, NativeSelect, For, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
import { Asterisk } from 'lucide-react';
|
||||
|
||||
export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
|
||||
const { register, handleSubmit, reset, formState: {errors} } = useForm();
|
||||
const [isSubmit, setIsSubmit] = useState(false);
|
||||
|
||||
const onSubmit = (data) => {
|
||||
setIsSubmit(true);
|
||||
reset();
|
||||
}
|
||||
|
||||
// Prop Forwarding
|
||||
const widgetProps = { id, isEditable, isDelete, DeleteWidget, isSubmit, setIsSubmit }
|
||||
|
||||
return (
|
||||
<Widget title="Alert Level" {...widgetProps} >
|
||||
{!isSubmit ? (
|
||||
// DISPLAYING INPUT FIElDS AND LABELS
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
width="100%"
|
||||
p={3}
|
||||
display="flex"
|
||||
flexDir="column"
|
||||
>
|
||||
<Fieldset.Root size="md" maxW="md">
|
||||
<Fieldset.Content>
|
||||
<Field.Root>
|
||||
<Field.Label fontWeight="semibold">Volcano</Field.Label>
|
||||
<Input name="volcanoname" />
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root>
|
||||
<Field.Label fontWeight="semibold">Country</Field.Label>
|
||||
<NativeSelect.Root>
|
||||
<NativeSelect.Field name="country">
|
||||
<For each={["United Kingdom", "Canada", "United States"]}>
|
||||
{(item) => (
|
||||
<option key={item} value={item}>
|
||||
{item}
|
||||
</option>
|
||||
)}
|
||||
</For>
|
||||
</NativeSelect.Field>
|
||||
<NativeSelect.Indicator />
|
||||
</NativeSelect.Root>
|
||||
</Field.Root>
|
||||
</Fieldset.Content>
|
||||
|
||||
<Button type="submit" size="sm">Submit</Button>
|
||||
</Fieldset.Root>
|
||||
</Box>
|
||||
):(
|
||||
// DISPLAYING ALERT LEVEL DATA
|
||||
<Box p={1} mt={1} ml="auto" mr="auto" width="200px">
|
||||
<Box
|
||||
bg="bg"
|
||||
shadow="md"
|
||||
borderRadius="md"
|
||||
p={8}
|
||||
width="200px"
|
||||
height="10px"
|
||||
textAlign="center"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text fontsize="2xl" fontWeight="bold" color="gray.700">Alert Level Data Coming Soon.</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Widget>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Button } from "@chakra-ui/react";
|
||||
|
||||
export function CancelButton ({onCancel}) {
|
||||
return (
|
||||
<Button onClick={onCancel} borderRadius="md">Cancel</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
// DEPRECATED
|
||||
|
||||
// import GlobalMap from "./views/GlobalMap";
|
||||
// import RegionalMap from "./views/RegionalMap";
|
||||
// import Volcano from "./views/Volcano";
|
||||
// import Admin from "./views/Admin";
|
||||
// import Account from "./views/Account"
|
||||
// import Home from "./views/Home";
|
||||
// import { DASHBOARD_VIEWS, ADMIN_VIEWS, VOLCANO_VIEWS } from "@/constants/viewKeys";
|
||||
// import { chakra } from "@chakra-ui/react";
|
||||
// import Personal from "./views/Personal";
|
||||
// import Security from "./views/Security";
|
||||
//
|
||||
// export default function Canvas(props) {
|
||||
// const { view } = props;
|
||||
//
|
||||
// return (
|
||||
// <chakra.div width="100%" height="100%">
|
||||
// {/* Check if view is a tab inside the Home Header */}
|
||||
// { DASHBOARD_VIEWS.includes(view) && (
|
||||
// <Home {...props}/>
|
||||
// )}
|
||||
// {/* Check if view is a tab inside the Volcano Header */}
|
||||
// { VOLCANO_VIEWS.includes(view) && (
|
||||
// <Volcano {...props}/>
|
||||
// )}
|
||||
// {/* Check if view is a tab inside the Admin Header */}
|
||||
// { ADMIN_VIEWS.includes(view) && (
|
||||
// <Admin {...props}/>
|
||||
// )}
|
||||
// {view === "global" && <GlobalMap />}
|
||||
// {view === "regional" && <RegionalMap />}
|
||||
// {view === "account" && <Account />}
|
||||
// {view === "settings" && <Personal />}
|
||||
// {view === "personal" && <Personal />}
|
||||
// {view === "security" && <Security />}
|
||||
// </chakra.div>
|
||||
// );
|
||||
// }
|
||||
@@ -1,27 +0,0 @@
|
||||
import { MoveDiagonal2 } from 'lucide-react';
|
||||
import React from "react";
|
||||
|
||||
const CustomResizeHandle = React.forwardRef((props, ref) => {
|
||||
const { handleAxis, isVisable, ...restProps } = props;
|
||||
if (!isVisable) return null;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...restProps}
|
||||
className={`resize-hande-${handleAxis}`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
cursor: "pointer",
|
||||
width: "20px",
|
||||
height: "20px",
|
||||
zIndex: "1",
|
||||
}}
|
||||
>
|
||||
<MoveDiagonal2 />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default CustomResizeHandle;
|
||||
@@ -1,95 +0,0 @@
|
||||
// DEPRECATED
|
||||
|
||||
// import Header from './Header/Header.jsx';
|
||||
// import Sidebar from './Sidebar/Sidebar.jsx';
|
||||
// import Canvas from './Canvas/Canvas.jsx';
|
||||
// import { Grid, GridItem, useRecipe, useDialog } from '@chakra-ui/react';
|
||||
// import { useState } from 'react';
|
||||
// import DialogPopup from './Canvas/CanvasComponents/DialogPopup.jsx';
|
||||
//
|
||||
// export default function Dashboard() {
|
||||
// const recipe = useRecipe({ key: "dashboard" });
|
||||
// const styles = recipe();
|
||||
//
|
||||
// const [darkMode, setDarkMode] = useState(false);
|
||||
// const [isEditable, setIsEditable] = useState(false); // editable=true means static=false (vice versa)
|
||||
// const [isDelete, setIsDelete] = useState(false);
|
||||
// const [originalLayout, setOriginalLayout] = useState([]);
|
||||
// const [originalWidgets, setOriginalWidgets] = useState([]);
|
||||
// const [view, setView] = useState("mydashboard");
|
||||
// const [layout, setLayout] = useState([]);
|
||||
// const [widgetArray, setWidgetArray] = useState([]);
|
||||
// const dialog = useDialog();
|
||||
//
|
||||
// const beginEditIfNeeded = () => {
|
||||
// if ( !isEditable ) {
|
||||
// setOriginalLayout(layout);
|
||||
// setOriginalWidgets(widgetArray);
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// const onCancel = () => {
|
||||
// console.log("canceling changes...");
|
||||
// console.log(originalWidgets);
|
||||
// if (originalLayout.length) {
|
||||
// setLayout(originalLayout.map(item => ({
|
||||
// ...item,
|
||||
// static: true
|
||||
// })));
|
||||
// };
|
||||
// setWidgetArray(originalWidgets);
|
||||
// setIsEditable(false);
|
||||
// setIsDelete(false);
|
||||
// setOriginalLayout([]);
|
||||
// setOriginalWidgets([]);
|
||||
// }
|
||||
//
|
||||
// // Prevent navigation while in Edit mode without saving or cancelling
|
||||
// const handleViewChange = (nextView) => {
|
||||
// console.log("1. handling view change")
|
||||
// if (view === "mydashboard" && isEditable) {
|
||||
// console.log("2. currently editing");
|
||||
// dialog.setOpen(true);
|
||||
// return;
|
||||
//
|
||||
// // const confirmed = window.confirm(
|
||||
// // "You have unsaved changes. Discard them and continue?"
|
||||
// // );
|
||||
// // if (!confirmed) return;
|
||||
// // onCancel();
|
||||
// }
|
||||
// setView(nextView);
|
||||
// };
|
||||
//
|
||||
// const onLayoutChange = (newLayout) => setLayout(newLayout);
|
||||
//
|
||||
// // Prop Forwarding
|
||||
// const canvasProps = { view, layout, setLayout, setOriginalLayout, onLayoutChange, onChangeView: handleViewChange,
|
||||
// onCancel, isEditable, setIsEditable, isDelete, setIsDelete, widgetArray, setWidgetArray, originalWidgets, setOriginalWidgets, beginEditIfNeeded
|
||||
// };
|
||||
// const sidebarProps = { view, onChangeView: handleViewChange, darkMode, setDarkMode };
|
||||
// const headerProps = { onChangeView: handleViewChange };
|
||||
//
|
||||
// return (
|
||||
// <>
|
||||
// <DialogPopup dialog={dialog} />
|
||||
// <Grid css={styles}
|
||||
// templateRows="1fr 11fr"
|
||||
// // templateColumns="1fr 20fr"
|
||||
// templateColumns="auto 1fr"
|
||||
// height="100vh"
|
||||
// width="100vw"
|
||||
// >
|
||||
// <GridItem rowSpan={1} colSpan={2}>
|
||||
// <Header {...headerProps}/>
|
||||
// </GridItem>
|
||||
// <GridItem colSpan={1}>
|
||||
// <Sidebar {...sidebarProps}/>
|
||||
// </GridItem>
|
||||
// <GridItem colSpan={1} width="100%" overflow="auto">
|
||||
// <Canvas {...canvasProps}/>
|
||||
// </GridItem>
|
||||
// </Grid>
|
||||
// </>
|
||||
// );
|
||||
// }
|
||||
@@ -1,9 +0,0 @@
|
||||
import { Button } from "@chakra-ui/react";
|
||||
|
||||
export default function DeleteAllButton ({onDeleteAll}) {
|
||||
return (
|
||||
<Button onClick={onDeleteAll} color="#FFFFFF" bg="#EF4444" _hover={{ bg: "bg.error", color: "#EF4444"}}>
|
||||
Delete All
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Box, Menu, Portal } from '@chakra-ui/react'
|
||||
import AlertLevel from "@/components/deprecated/AlertLevelWidget.jsx";
|
||||
|
||||
export default function FirstWidgetButton({ appendWidget }) {
|
||||
const triggerItem = (
|
||||
<>
|
||||
<Menu.Trigger asChild>
|
||||
<Box
|
||||
p={6}
|
||||
m="auto"
|
||||
mt={6}
|
||||
align="center"
|
||||
textAlign="center"
|
||||
justify="center"
|
||||
width="400px"
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
border="2px dashed"
|
||||
borderColor="gray.300"
|
||||
bg="gray.50"
|
||||
cursor="pointer"
|
||||
_hover = {{
|
||||
bg: "gray.100",
|
||||
borderColor: "gray.400",
|
||||
boxShadow: "lg",
|
||||
}}
|
||||
>
|
||||
Add Your First Widget
|
||||
</Box>
|
||||
</Menu.Trigger>
|
||||
</>
|
||||
);
|
||||
const positioning = { placement: "bottom-middle" };
|
||||
// const WidgetMenuProps = { appendWidget, triggerItem, positioning };
|
||||
return (
|
||||
// <WidgetMenu {...WidgetMenuProps} />
|
||||
<Menu.Root positioning={positioning}>
|
||||
{triggerItem}
|
||||
<Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
value="AlertLevel"
|
||||
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
|
||||
>
|
||||
Alert Level
|
||||
</Menu.Item>
|
||||
<Menu.Item value="CO2">CO2</Menu.Item>
|
||||
<Menu.Item value="Ivans">IVANS</Menu.Item>
|
||||
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
|
||||
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Portal>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Button } from "@chakra-ui/react";
|
||||
|
||||
export function SaveButton ({onSave}) {
|
||||
return (
|
||||
<Button onClick={onSave} borderRadius="md" bg="primary" _hover={{bg: "primaryLt"}}>Save</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useSlotRecipe, chakra, Flex, CloseButton, Text, IconButton } from "@chakra-ui/react"
|
||||
import { Undo } from 'lucide-react';
|
||||
|
||||
export default function Widget(props) {
|
||||
// deconstructing props
|
||||
const { id, title, isEditable, isDelete, DeleteWidget, isSubmit, setIsSubmit, children } = props;
|
||||
|
||||
const recipe = useSlotRecipe({ key: "widget" });
|
||||
const styles = recipe();
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" w="100%" h="auto">
|
||||
{/* HEADER */}
|
||||
<Flex css={styles.header} cursor={isEditable ? "move":"default"} flexDirection="row" w="100%" h="auto" justifyContent="space-between" alignItems="center">
|
||||
<Text className="widget-handle" w="100%" display="flex" alignItems="center" height="32px" fontWeight="bold">{title}</Text>
|
||||
{isDelete ? (
|
||||
<CloseButton onClick={() => DeleteWidget(id)} size="xs" _hover={{bg: "tomato", color: "white"}}/>
|
||||
):(
|
||||
isSubmit ? (
|
||||
<IconButton onClick={() => setIsSubmit(false)} size="xs" bg="base300" cursor="pointer"><Undo fill="text" /></IconButton>
|
||||
):null
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* CHILDREN */}
|
||||
<Flex w="100%" h="auto">
|
||||
{children}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Menu, Portal } from "@chakra-ui/react";
|
||||
import AlertLevel from "./AlertLevelWidget.jsx";
|
||||
|
||||
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
|
||||
return (
|
||||
<Menu.Root positioning={positioning}>
|
||||
{triggerItem}
|
||||
<Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
value="AlertLevel"
|
||||
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
|
||||
>
|
||||
Alert Level
|
||||
</Menu.Item>
|
||||
<Menu.Item value="CO2">CO2</Menu.Item>
|
||||
<Menu.Item value="Ivans">IVANS</Menu.Item>
|
||||
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
|
||||
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Portal>
|
||||
</Menu.Root>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react'
|
||||
import * as React from 'react'
|
||||
|
||||
export const Tooltip = React.forwardRef(function Tooltip(props, ref) {
|
||||
const {
|
||||
showArrow,
|
||||
children,
|
||||
disabled,
|
||||
portalled = true,
|
||||
content,
|
||||
contentProps,
|
||||
portalRef,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
if (disabled) return children
|
||||
|
||||
return (
|
||||
<ChakraTooltip.Root {...rest}>
|
||||
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
|
||||
<Portal disabled={!portalled} container={portalRef}>
|
||||
<ChakraTooltip.Positioner>
|
||||
<ChakraTooltip.Content ref={ref} {...contentProps}>
|
||||
{showArrow && (
|
||||
<ChakraTooltip.Arrow>
|
||||
<ChakraTooltip.ArrowTip />
|
||||
</ChakraTooltip.Arrow>
|
||||
)}
|
||||
{content}
|
||||
</ChakraTooltip.Content>
|
||||
</ChakraTooltip.Positioner>
|
||||
</Portal>
|
||||
</ChakraTooltip.Root>
|
||||
)
|
||||
})
|
||||
@@ -1,37 +1,34 @@
|
||||
import { useRecipe, Button, Stack, Text, Flex } from "@chakra-ui/react"
|
||||
import {useSlotRecipe, Text, Flex} from "@chakra-ui/react"
|
||||
import UserAvatarMenu from "./UserAvatarMenu.jsx";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import useAuthStore from "@/store/authStore";
|
||||
import {SearchBar} from "@/components/dashboard/SearchBar.jsx";
|
||||
|
||||
// Import the asset properly so Vite can bundle it for any server
|
||||
// import defaultAvatar from "@/assets/user_profile.svg";
|
||||
|
||||
// Accept a 'user' prop now, fallback to placeholder if empty
|
||||
export default function Header() {
|
||||
const recipe = useRecipe({ key: "header" });
|
||||
const recipe = useSlotRecipe({key: "header"});
|
||||
const styles = recipe();
|
||||
|
||||
const { user } = useAuthStore();
|
||||
const {user} = useAuthStore();
|
||||
console.log(user);
|
||||
const displayName = user ? (user.fullName || user.username) : "Loading...";
|
||||
const displayName = user ? (user.firstName || user.username) : "Loading...";
|
||||
const avatarname = user ? (user.fullName || user.username) : "Loading...";
|
||||
|
||||
const displayEmail = user?.email || "LDAP User";
|
||||
|
||||
return (
|
||||
<Flex css={styles} justify="space-between" align="center" width="100%">
|
||||
{/* Home Link Logo */}
|
||||
<Button as={NavLink} to="/home" color="color" variant="plain" textStyle="5xl" fontWeight="bold">
|
||||
VDAP
|
||||
</Button>
|
||||
<Flex css={styles.root}>
|
||||
<Flex css={styles.search_bar}>
|
||||
<SearchBar placeholder="Enter a volcano to search"/>
|
||||
</Flex>
|
||||
|
||||
{/* User Profile Section */}
|
||||
<Flex align="center" gap={3} pr={6}>
|
||||
<Stack gap="0" textAlign="right" cursor="default">
|
||||
<Text color="color" fontWeight="bold" textStyle="lg">{displayName}</Text>
|
||||
<Text color="fg.muted" textStyle="sm">{displayEmail}</Text>
|
||||
</Stack>
|
||||
<Flex css={styles.user_info}>
|
||||
<Text>{displayName}</Text>
|
||||
<UserAvatarMenu
|
||||
name={displayName}
|
||||
name={avatarname}
|
||||
// avatar={user.avatar}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -1,74 +1,57 @@
|
||||
import { useSlotRecipe, Box, Button, IconButton } from "@chakra-ui/react";
|
||||
import { ColorModeButton } from "@/components/ui/color-mode.jsx";
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import Tooltip from "../ui/Tooltip.jsx";
|
||||
import { Mountain, House, Earth, MapPin, ShieldUser, Menu } from 'lucide-react';
|
||||
import {Mountain, House, Earth, MapPin, ShieldUser, PanelLeftClose, PanelLeftOpen} from 'lucide-react';
|
||||
import {useSlotRecipe, Box, Button, IconButton, Text, Flex} from "@chakra-ui/react";
|
||||
import {ColorModeButton} from "@/components/ui/color-mode.jsx";
|
||||
import {NavLink} from "react-router-dom";
|
||||
import {useState} from "react";
|
||||
|
||||
const SIDEBAR_DEFAULT_BUTTONS = [
|
||||
{ icon: House, name: "Home", url: "/home" },
|
||||
{ icon: Earth, name: "Global Map", url: "/global-map" },
|
||||
{ icon: MapPin, name: "Regional Map", url: "/regional-map" },
|
||||
{ icon: Mountain, name: "Volcano", url: "/volcano" },
|
||||
{ icon: ShieldUser, name: "Admin", url: "/admin" } // TODO remove in the future as default should not have admin
|
||||
{icon: House, name: "Home", url: "/home"},
|
||||
{icon: Earth, name: "Global Map", url: "/global-map"},
|
||||
{icon: MapPin, name: "Regional Map", url: "/regional-map"},
|
||||
{icon: Mountain, name: "Volcano", url: "/volcano"},
|
||||
{icon: ShieldUser, name: "Admin", url: "/admin"} // TODO remove in the future as default should not have admin
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const recipe = useSlotRecipe({ key: "sidebar" });
|
||||
const styles = recipe();
|
||||
|
||||
// TODO we will need to call buttons here by authorized user, for now we update the visible buttons as simply the default buttons
|
||||
const visibleButtons = SIDEBAR_DEFAULT_BUTTONS;
|
||||
const recipe = useSlotRecipe({key: "sidebar"});
|
||||
const styles = recipe({state: isOpen ? "open" : "closed"});
|
||||
const visibleButtons = SIDEBAR_DEFAULT_BUTTONS; // TODO: call btns here according to users authorization
|
||||
|
||||
const toggleSidebar = () => setIsOpen((prev) => !prev);
|
||||
|
||||
return (
|
||||
<Box css={styles.root} width={isOpen ? "210px" : "71px"} transition="width 0.4s ease" position="relative" display="flex" flexDirection="column" gap={2}
|
||||
>
|
||||
{/* Menu Trigger */}
|
||||
<IconButton aria-label="Toggle Sidebar" variant="plain" size="md" onClick={toggleSidebar} width="54px" alignSelf="flex-start"
|
||||
>
|
||||
<Menu />
|
||||
</IconButton>
|
||||
<Box css={styles.root}>
|
||||
{/* Logo Navigates to Home Page */}
|
||||
<Button as={NavLink} to="/home" variant="plain" css={styles.logo}>
|
||||
<Flex>
|
||||
<Text>🌋</Text>
|
||||
<Text css={styles.logo_text}>VDAP</Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
{visibleButtons.map((button) => {
|
||||
const Icon = button.icon;
|
||||
return (
|
||||
<Tooltip key={button.name} content={button.name} sidebarIsOpen={isOpen}>
|
||||
<Button
|
||||
as={NavLink}
|
||||
to={button.url}
|
||||
css={styles.buttons}
|
||||
justifyContent="flex-start"
|
||||
width="100%"
|
||||
overflow="hidden" // Prevents text popout on collapse
|
||||
>
|
||||
<Icon style={{ flexShrink: 0 }} /> {/* Prevents icon from squishing */}
|
||||
|
||||
{/* Cleaned conditional transition styling */}
|
||||
<Box
|
||||
as="span"
|
||||
ml={isOpen ? 3 : 0}
|
||||
opacity={isOpen ? 1 : 0}
|
||||
visibility={isOpen ? "visible" : "hidden"} // Prevents phantom width calculations and overflow into canvas
|
||||
transition="opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease"
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<Button as={NavLink} to={button.url} css={styles.buttons}> {/* Navigation buttons are [icon text] */}
|
||||
<Icon/>
|
||||
<Box as="span" css={styles.button_text}>
|
||||
{button.name}
|
||||
</Box>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Theme Toggler */}
|
||||
<ColorModeButton
|
||||
position="absolute"
|
||||
bottom="4"
|
||||
left="4"
|
||||
/>
|
||||
{/* Collapse Sidebar and Theme Footer Buttons */}
|
||||
<Flex css={styles.footer}>
|
||||
<IconButton aria-label="Toggle Sidebar" variant="plain" onClick={toggleSidebar} css={styles.collapse_button}>
|
||||
{isOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||
</IconButton>
|
||||
<Flex css={styles.color_mode_button}>
|
||||
<ColorModeButton />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import { lazy } from 'react';
|
||||
// w: 4 = one-third screen width
|
||||
// w: 3 = one-quarter screen width
|
||||
|
||||
// ONE DAY DATE RANGE
|
||||
const getToday = () => (new Date()).toLocaleDateString('en-CA')
|
||||
|
||||
// ONE WEEK DATE RANGE
|
||||
const getStartDate = () => (new Date(Date.now() - 604800000)).toLocaleDateString('en-CA'); // 7 days = 604800000 = 7*24*60*60*1000
|
||||
const getEndDate = () => (new Date()).toLocaleDateString('en-CA'); // 'en-CA' formats the date as YYYY-MM-DD
|
||||
|
||||
@@ -159,7 +163,55 @@ export const WIDGET_REGISTRY = {
|
||||
description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
|
||||
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 12 }
|
||||
medium: { w: 3, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
"comm_coor_report": {
|
||||
name: "Communication Coordinator Report",
|
||||
category: "Daily Volcanic Activity",
|
||||
description: "Generated Report",
|
||||
component: lazy(() => import('../components/Widgets/CommCoorReport.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/CommCoorReportSettings.jsx')),
|
||||
defaultSettings: {
|
||||
copyToClipboard: false,
|
||||
downloadReport: false,
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 3, h: 12 },
|
||||
}
|
||||
},
|
||||
|
||||
"vaa_map": {
|
||||
name: "VAA Polygon Projections Map",
|
||||
category: "Maps",
|
||||
description: "By selecting a volcano, the map will take you to it and show a polygon of the ash in 8, 12, and 18 hours",
|
||||
component: lazy(() => import('../components/Widgets/VaaMap.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')),
|
||||
defaultSettings: {
|
||||
position: [45.61142, -122.49630], // CVO's coordinates
|
||||
startDate: getToday(),
|
||||
endDate: getToday(),
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: undefined,
|
||||
selected: undefined,
|
||||
defaultVAA: undefined,
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
"travel_gantt_chart": {
|
||||
name: "Travel Gantt Chart",
|
||||
category: "Communications",
|
||||
description: "View upcoming travels in a gantt chart",
|
||||
component: lazy(() => import('../components/Widgets/TravelGanttChart.jsx')),
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
:root {
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
/*font-family: Georgia, 'Times New Roman', Times, serif;*/
|
||||
font-family: "Roboto", sans-serif;
|
||||
}
|
||||
|
||||
.wx-theme {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -26,11 +26,11 @@ export default function useWidgetApi(endpoint) {
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const responseData = Array.isArray(json) ? json : json.data;
|
||||
const responseData = Array.isArray(json) ? json : (json?.data ?? json);
|
||||
setData(responseData || []);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch from ${endpoint}:`, err);
|
||||
console.error(`Failed to parse data from ${endpoint}:`, err);
|
||||
setError("Failed to load widget data. Is the Django API running?");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Flex, Text, Input, Field, Button, Separator, NativeSelect, Image } from '@chakra-ui/react';
|
||||
import useAuthStore from "../../store/authStore.js";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuthStore();
|
||||
const { register, handleSubmit, errors } = useForm();
|
||||
const [value, setValue] = useState(undefined);
|
||||
const updateProfile = useAuthStore((state) => state.updateProfile);
|
||||
|
||||
const onSubmit = (data, event) => {
|
||||
// TODO: check for identical usernames
|
||||
event.preventDefault();
|
||||
console.log(data);
|
||||
if (data.username && data.username.length !== "") updateProfile(data);
|
||||
}
|
||||
|
||||
const ivansRequest = () => {
|
||||
console.log(user.username, "requested ivans permissions");
|
||||
// TODO: log users request in database here which will then display request in web-admin page
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Flex direction="column" w="100%" h="100%" p="15rem" pt="3rem" gap="5rem">
|
||||
|
||||
{/* CONTENT */}
|
||||
<Flex direction="column" w="100%" h="100%" gap={4}>
|
||||
{/* header */}
|
||||
<Text fontWeight="semibold" fontSize="xl">Settings</Text>
|
||||
|
||||
<Separator borderColor="gray.300"/>
|
||||
|
||||
{/* personal info */}
|
||||
<Flex direction="column" w="100%" gap={4}>
|
||||
<Text fontWeight="semibold" fontSize="lg">Personal Information</Text>
|
||||
{/* profile picture & name */}
|
||||
<Flex direction="row" w="100%" align="center" justify="space-between" gap={5}>
|
||||
<Flex direction="row" w="100%" align="center" gap={5}>
|
||||
<Image borderRadius="full" boxSize="100px" src="https://i.pravatar.cc/300?img=10" />
|
||||
<Flex direction="column">
|
||||
<Text fontWeight="semibold">Name</Text>
|
||||
<Text pl={2}>{user.firstName || "first name"}, {user.lastName || "last name"}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Button variant="subtle" size="sm">Update photo</Button>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="row" w="100%" gap={5}>
|
||||
<Field.Root>
|
||||
<Field.Label fontWeight="semibold">Username</Field.Label>
|
||||
<Input {...register("username")} placeholder={user.username} variant="subtle" />
|
||||
</Field.Root>
|
||||
|
||||
<Field.Root>
|
||||
<Field.Label fontWeight="semibold">Email</Field.Label>
|
||||
<Input {...register("email")} type="email" placeholder={user.email || "example@usgs.gov"} variant="subtle" />
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="row" w="100%" gap={5}>
|
||||
<Field.Root pb={8}>
|
||||
<Field.Label fontWeight="semibold">Descipline</Field.Label>
|
||||
<NativeSelect.Root size="sm" variant="subtle">
|
||||
<NativeSelect.Field
|
||||
w="100%"
|
||||
h="2.5rem"
|
||||
placeholder="Select option"
|
||||
{...register("discipline")}
|
||||
>
|
||||
<option value="RS">Remote Sensing</option>
|
||||
<option value="CS">Computer Science</option>
|
||||
<option value="Engineering">Engineering</option>
|
||||
<option value="HR">Human Resources (HR)</option>
|
||||
</NativeSelect.Field>
|
||||
<NativeSelect.Indicator />
|
||||
</NativeSelect.Root>
|
||||
<Field.ErrorText></Field.ErrorText>
|
||||
</Field.Root>
|
||||
<Flex w="100%"></Flex> {/* filler so that discipline dropdown sizes correctly */}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Separator borderColor="gray.300"/>
|
||||
|
||||
{/* permission requests */}
|
||||
<Flex direction="column" gap={2}>
|
||||
<Text fontWeight="semibold" fontSize="lg">Permissions</Text>
|
||||
<Flex direction="row" w="100%" align="center" justify="space-between" pl={2}>
|
||||
<Text>Request access to IVANS</Text>
|
||||
<Button variant="subtle" size="sm" onClick={ivansRequest}>IVANS Request</Button>
|
||||
</Flex>
|
||||
<Flex direction="row" w="100%" align="center" justify="space-between" pl={2}>
|
||||
<Text>Request access to Remote Sensing</Text>
|
||||
<Button variant="subtle" size="sm" onClick={ivansRequest}>Remote Sensing Request</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Separator borderColor="gray.300" />
|
||||
|
||||
{/* themes */}
|
||||
<Flex direction="column">
|
||||
<Text fontWeight="semibold" fontSize="lg">Theme</Text>
|
||||
<Flex direction="row" align="center" justify="space-between" pl={2}>
|
||||
<Text fontSize="md">Appearance</Text>
|
||||
<NativeSelect.Root size="sm" w="10rem" variant="subtle">
|
||||
<NativeSelect.Field
|
||||
placeholder="Select option"
|
||||
{...register("theme")}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
>
|
||||
<option value="Light">Light</option>
|
||||
<option value="Dark">Dark</option>
|
||||
</NativeSelect.Field>
|
||||
<NativeSelect.Indicator />
|
||||
</NativeSelect.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* SAVE-ALL PREFERENCES BUTTON */}
|
||||
{/* TODO: grey out button until changes are detected */}
|
||||
<Button type="submit" w="150px" variant="subtle" borderRadius="lg" alignSelf="center">
|
||||
Save Changes
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import {Flex, Separator, Text, Table, IconButton, NativeSelect, Button} from "@chakra-ui/react";
|
||||
import { useMemo } from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { Check, X, PenLine } from 'lucide-react';
|
||||
import VdapApi from "../../hooks/useVdapApi.js";
|
||||
import useBuildUrl from "../../hooks/useBuildUrl.js";
|
||||
|
||||
export default function WebAdmin() {
|
||||
// TODO: add below api endpoint
|
||||
// const {data: users, isLoading, error} = VdapApi('/api/v2/vdap_web_users');
|
||||
|
||||
const users = useMemo(() => [
|
||||
{
|
||||
"username": "vkuchenik",
|
||||
"firstName": "Venessa",
|
||||
"lastName": "Kuchenik",
|
||||
"permissions": {
|
||||
"ivans": 2, // 2 => requested permission
|
||||
"rs": 0, // 0 => no permissions and not requested
|
||||
},
|
||||
"team": "Computer Science",
|
||||
"group": "Super User",
|
||||
},
|
||||
{
|
||||
"username": "amack",
|
||||
"firstName": "Adeiline",
|
||||
"lastName": "Mack",
|
||||
"permissions": {
|
||||
"ivans": 2, // 0 => no permissions and not requested
|
||||
"rs": 2, // 1 => has permission
|
||||
},
|
||||
"team": "Remote Sensing",
|
||||
"group": "Member",
|
||||
}
|
||||
], []);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
errors,
|
||||
formState: { isDirty }
|
||||
} = useForm({
|
||||
defaultValues: {users: users}
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "users" // Matches the key in defaultValues
|
||||
})
|
||||
|
||||
const grant = (user, category) => {
|
||||
// 1. post data via api
|
||||
// 2. fetch the new data via api again to force re-render
|
||||
console.log(user.username, "was granted", category, "permission");
|
||||
}
|
||||
|
||||
const deny = (user, category) => {
|
||||
console.log(user.username, "was denied", category, "permission");
|
||||
}
|
||||
|
||||
const users_req_ivans = useMemo(() => {
|
||||
return users.filter(user => user.permissions.ivans === 2);
|
||||
}, [users]);
|
||||
|
||||
const users_req_rs = useMemo(() => {
|
||||
return users.filter(user => user.permissions.rs === 2);
|
||||
}, [users]);
|
||||
|
||||
const onSubmit = (data, event) => {
|
||||
event.preventDefault();
|
||||
console.log(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Flex direction="column" w="100%" h="100%" p="15rem" pt="3rem" gap="3rem">
|
||||
|
||||
{/* CONTENT */}
|
||||
<Flex direction="column" w="100%" gap={4}>
|
||||
{/* Header */}
|
||||
<Text fontSize="xl" fontWeight="bold">Web Admin Panel</Text>
|
||||
|
||||
<Separator borderColor="gray.300"/>
|
||||
|
||||
{/* Permission Requests */}
|
||||
<Flex direction="column" w="100%" gap={2} pb={4}>
|
||||
<Text fontSize="lg" fontWeight="bold">Incoming Permission Requests</Text>
|
||||
<Flex direction="row" w="100%" gap={5}>
|
||||
{/* ivans requests table */}
|
||||
<Flex direction="column" w="100%" gap={2}>
|
||||
<Text fontSize="sm" fontWeight="bold">IVANS</Text>
|
||||
<Table.Root w="100%" size="sm" variant="outline" borderRadius="lg">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader w="100%" fontWeight="bold">User</Table.ColumnHeader>
|
||||
<Table.ColumnHeader fontWeight="bold" textAlign="left">Permission</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{users_req_ivans.map((user) => (
|
||||
<Table.Row key={user.username}>
|
||||
<Table.Cell w="100%">{user.lastName}, {user.firstName}</Table.Cell>
|
||||
<Table.Cell >
|
||||
<Flex w="100%" gap={1} justify="left">
|
||||
<IconButton borderRadius="xl" size="xs" variant="subtle" onClick={() => grant(user, "ivans")}>
|
||||
<Check />
|
||||
</IconButton>
|
||||
<IconButton borderRadius="xl" size="xs" variant="subtle" onClick={() => deny(user, "ivans")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Flex>
|
||||
{/* rs requests table */}
|
||||
<Flex direction="column" w="100%" gap={2}>
|
||||
<Text fontSize="sm" fontWeight="bold">Remote Sensing</Text>
|
||||
<Table.Root w="100%" size="sm" variant="outline" borderRadius="lg">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader w="100%" fontWeight="bold">User</Table.ColumnHeader>
|
||||
<Table.ColumnHeader fontWeight="bold" textAlign="left">Permission</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{users_req_rs.map((user) => (
|
||||
<Table.Row key={user.username}>
|
||||
<Table.Cell>{user.lastName}, {user.firstName}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex w="100%" gap={1} justify="left">
|
||||
<IconButton borderRadius="xl" size="xs" variant="subtle" onClick={() => grant(user, "rs")}>
|
||||
<Check />
|
||||
</IconButton>
|
||||
<IconButton borderRadius="xl" size="xs" variant="subtle" onClick={() => deny(user, "rs")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Separator borderColor="gray.300"/>
|
||||
|
||||
{/* Group Assignment */}
|
||||
<Flex direction="column" w="100%" gap={2} pb={4}>
|
||||
<Text fontWeight="bold" fontSize="lg">Groups</Text>
|
||||
<Table.Root w="50%" size="sm" variant="outline" borderRadius="lg">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader fontWeight="bold">User</Table.ColumnHeader>
|
||||
<Table.ColumnHeader fontWeight="bold" textAlign="left">Group</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{users.map((user, index) => (
|
||||
<Table.Row key={user.username}>
|
||||
<Table.Cell w="100%">{user.lastName}, {user.firstName}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex w="100%" justify="left">
|
||||
<NativeSelect.Root size="sm" w="175px" variant="subtle">
|
||||
<NativeSelect.Field
|
||||
defaultValue={user.group}
|
||||
{...register(`users.${index}.group`)}
|
||||
>
|
||||
<option value="Super User">Super User</option>
|
||||
<option value="Member">Member</option>
|
||||
</NativeSelect.Field>
|
||||
<NativeSelect.Indicator />
|
||||
</NativeSelect.Root>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Flex>
|
||||
|
||||
<Separator borderColor="gray.300"/>
|
||||
|
||||
{/* Team Assignment */}
|
||||
<Flex direction="column" w="100%" gap={2} pb={4}>
|
||||
<Text fontWeight="bold" fontSize="lg">Teams</Text>
|
||||
<Table.Root w="50%" size="sm" variant="outline" borderRadius="lg">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader fontWeight="bold" w="100%">User</Table.ColumnHeader>
|
||||
<Table.ColumnHeader fontWeight="bold" textAlign="left">Team</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{users.map((user, index) => {
|
||||
console.log("user in team: ", user.team);
|
||||
return(
|
||||
<Table.Row key={user.username}>
|
||||
<Table.Cell>
|
||||
{user.lastName}, {user.firstName}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Flex w="100%" justify="right">
|
||||
<NativeSelect.Root size="sm" w="175px" variant="subtle">
|
||||
<NativeSelect.Field
|
||||
defaultValue={user.team}
|
||||
{...register(`users.${index}.team`)}
|
||||
>
|
||||
<option value="Computer Science">Computer Science</option>
|
||||
<option value="Remote Sensing">Remote Sensing</option>
|
||||
</NativeSelect.Field>
|
||||
<NativeSelect.Indicator />
|
||||
</NativeSelect.Root>
|
||||
</Flex>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)})}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
<Button type="submit" w="100px" variant="subtle" alignSelf="center">
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,62 +1,59 @@
|
||||
import { HStack, Flex, Box } from "@chakra-ui/react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { SearchBar } from "../../components/dashboard/SearchBar.jsx";
|
||||
import { SettingsButton } from "../../components/dashboard/SettingsButton.jsx";
|
||||
import {HStack, Flex, Box, useRecipe, useSlotRecipe} from "@chakra-ui/react";
|
||||
import {NavLink, Outlet, useLocation} from "react-router-dom";
|
||||
import {SearchBar} from "../../components/dashboard/SearchBar.jsx";
|
||||
import {SettingsButton} from "../../components/dashboard/SettingsButton.jsx";
|
||||
|
||||
// 1. Import your icons (Swap these for whichever you prefer!)
|
||||
import {LayoutDashboard, CalendarDays, Cloud, Satellite, Activity} from "lucide-react";
|
||||
|
||||
// 2. Define the tabs array
|
||||
const HOME_TABS = [
|
||||
{name: "My Dashboard", url: "/home", icon: LayoutDashboard, exact: true},
|
||||
{name: "Daily Activity", url: "/home/daily", icon: CalendarDays},
|
||||
{name: "Gas", url: "/home/gas", icon: Cloud},
|
||||
{name: "Remote Sensing", url: "/home/remote", icon: Satellite},
|
||||
{name: "Seismic", url: "/home/seismic", icon: Activity},
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const location = useLocation();
|
||||
const isPersonalDashboard = location.pathname === "/home" || location.pathname === "/home/";
|
||||
const glassTabsRecipe = useRecipe({key: "glassTab"});
|
||||
const homeRecipe = useSlotRecipe({key: "home"});
|
||||
const glassTabsStyle = glassTabsRecipe();
|
||||
const homeStyle = homeRecipe();
|
||||
|
||||
// 1. CSS making this look like how we want it, TODO turn to theme?
|
||||
const tabStyles = {
|
||||
fontSize: "lg",
|
||||
fontWeight: "bold",
|
||||
color: "fg.muted",
|
||||
px: 2, // Horizontal padding
|
||||
// py: 2, // Vertical padding
|
||||
borderBottom: "1px solid transparent",
|
||||
transition: "all 0.2s",
|
||||
_hover: { color: "fg" },
|
||||
|
||||
"&.active": { // Target the active class applied by React Router NavLink
|
||||
color: "fg",
|
||||
borderColor: "fg",
|
||||
}
|
||||
};
|
||||
const location= useLocation();
|
||||
const isPersonalDashboard= location.pathname === "/home" || location.pathname === "/home/";
|
||||
|
||||
return (
|
||||
<Flex direction="column" height="100%" width="100%">
|
||||
<Flex css={homeStyle.root}>
|
||||
<Flex as="header" css={homeStyle.header}>
|
||||
{/* TABS CONTAINER */}
|
||||
<Flex>
|
||||
{HOME_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Box key={tab.name} as={NavLink} to={tab.url} end={tab.exact} css={glassTabsStyle}> {/* CRITICAL: 'end' prop forces exact match so "/home" deactivates on sub-routes*/}
|
||||
{/* flexShrink: 0 ensures the icon never squishes during the gooey animation */}
|
||||
<Icon size={24} style={{flexShrink: 0}}/>
|
||||
<Box as="span">{tab.name}</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 2. 🛠️ SUB HEADER: Matches your original padding and background */}
|
||||
<Flex as="header" position="sticky" top="0" zIndex="dropdown" bg="base200" p={5} align="center" justify="space-between" width="100%">
|
||||
|
||||
{/* TABS CONTAINER - Replicates the Tabs.List line container */}
|
||||
<Flex borderBottom="1px solid" borderColor="fg.muted" gap={2}>
|
||||
<Box as={NavLink} to="/home" end css={tabStyles}>My Dashboard</Box>
|
||||
<Box as={NavLink} to="/home/daily" css={tabStyles}>Daily Activity</Box>
|
||||
<Box as={NavLink} to="/home/gas" css={tabStyles}>Gas</Box>
|
||||
<Box as={NavLink} to="/home/remote" css={tabStyles}>Remote Sensing</Box>
|
||||
<Box as={NavLink} to="/home/seismic" css={tabStyles}>Seismic</Box>
|
||||
{/* The Settings button is now inline with the tabs and only renders on the personal dashboard */}
|
||||
{isPersonalDashboard && <SettingsButton/>}
|
||||
</Flex>
|
||||
|
||||
{/* EDIT MODE BUTTONS & CONTROLS */}
|
||||
{/* Leaving this HStack here in case you plan to put the SearchBar or other global tools back on the right side */}
|
||||
<HStack width="258px" justifyContent="flex-end">
|
||||
{isPersonalDashboard ? (
|
||||
<>
|
||||
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
|
||||
{/* This will eventually trigger your Zustand store's edit mode! */}
|
||||
<SettingsButton />
|
||||
</>
|
||||
) : (
|
||||
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
|
||||
)}
|
||||
{/* Future global controls go here */}
|
||||
</HStack>
|
||||
|
||||
</Flex>
|
||||
|
||||
{/* 3. 🛠️ CANVAS AREA */}
|
||||
<Flex flex="1" overflow="auto">
|
||||
<Outlet />
|
||||
{/* TABS CONTENT */}
|
||||
<Flex css={homeStyle.body}>
|
||||
<Outlet/>
|
||||
</Flex>
|
||||
|
||||
</Flex>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Box, Text, Flex, IconButton, Spinner, Popover} from "@chakra-ui/react";
|
||||
import {Box, Text, Flex, IconButton, Spinner, Popover, useSlotRecipe} from "@chakra-ui/react";
|
||||
import {X, MoreVertical} from "lucide-react";
|
||||
import {Responsive, WidthProvider} from "react-grid-layout";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import {Suspense, useEffect, useRef} from 'react';
|
||||
import {useColorModeValue} from "@/components/ui/color-mode";
|
||||
|
||||
// 1. Import database messenger
|
||||
import useAuthStore from "@/store/authStore";
|
||||
|
||||
@@ -13,13 +13,8 @@ import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx';
|
||||
// React-Grid-Layout requires this wrapper to automatically calculate screen width
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
export default function MyDashboard() {
|
||||
// 🌗 Let's adjust for our light and dark mode
|
||||
const frameBg = useColorModeValue("gray.100", "gray.800");
|
||||
const titleColor = useColorModeValue("gray.800", "gray.100");
|
||||
const iconColor = useColorModeValue("gray.600", "gray.400");
|
||||
const iconHoverBg = useColorModeValue("gray.100", "gray.700");
|
||||
|
||||
export default function MyDashboard() {
|
||||
// ☁️ Database Connection
|
||||
const {user} = useAuthStore();
|
||||
|
||||
@@ -33,24 +28,26 @@ export default function MyDashboard() {
|
||||
// 🔒 Safety lock to prevent infinite re-renders
|
||||
const hasHydrated = useRef(false);
|
||||
|
||||
// ==========================================
|
||||
// INITIAL LOAD: Grab data from Django
|
||||
// ==========================================
|
||||
// 1. Pull the global slot recipes
|
||||
const widgetRecipe = useSlotRecipe({ key: "widget" });
|
||||
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
|
||||
|
||||
// 2. Pass the editing state directly into the widget recipe!
|
||||
const widgetStyles = widgetRecipe({ mode: isEditing ? "editing" : undefined });
|
||||
const popoverStyles = popoverRecipe();
|
||||
|
||||
// 3. Populate widgets
|
||||
useEffect(() => {
|
||||
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
|
||||
const savedLayout = user.preferences.dashboard_layout;
|
||||
|
||||
// Safety check: Only inject if it's actually an array of widgets.
|
||||
// (Prevents a crash if MariaDB returns a default empty object {})
|
||||
if (Array.isArray(savedLayout)) {
|
||||
setWidgets(savedLayout);
|
||||
}
|
||||
|
||||
if (Array.isArray(savedLayout)) setWidgets(savedLayout);
|
||||
hasHydrated.current = true;
|
||||
}
|
||||
}, [user, setWidgets]);
|
||||
|
||||
|
||||
// 🛠️ Format the data for RGL
|
||||
const rglLayout = widgets.map(w => ({
|
||||
i: w.id,
|
||||
@@ -58,10 +55,10 @@ export default function MyDashboard() {
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
|
||||
isResizable: false // The magic lock! No resizing allowed globally.
|
||||
}));
|
||||
|
||||
// 3. THE COMPONENT MAPPER
|
||||
// 4. The component mapper
|
||||
const renderWidget = (widget) => {
|
||||
const registryEntry = WIDGET_REGISTRY[widget.type];
|
||||
|
||||
@@ -72,38 +69,24 @@ export default function MyDashboard() {
|
||||
const SettingsUI = registryEntry.settingsComponent;
|
||||
|
||||
return (
|
||||
// The "Frame" that holds both the Title and the Chart
|
||||
<Flex direction="column" height="100%" width="100%" bg={frameBg}>
|
||||
|
||||
{/* THE TITLE BAR */}
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
px={3}
|
||||
py={2}
|
||||
>
|
||||
<Text color={titleColor} fontWeight="semibold" fontSize="md" isTruncated>
|
||||
<>
|
||||
<Box css={widgetStyles.header}>
|
||||
<Text>
|
||||
{registryEntry.name}
|
||||
</Text>
|
||||
|
||||
{/* THE SETTINGS ELLIPSIS */}
|
||||
{SettingsUI ? (
|
||||
{/* WIDGET SETTINGS BUTTON (ELLIPSES) */}
|
||||
{SettingsUI && (
|
||||
<Popover.Root positioning={{placement: "bottom-end"}}>
|
||||
<Popover.Trigger asChild>
|
||||
<IconButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
_hover={{color: "white", bg: "gray.700"}}
|
||||
aria-label="Widget Settings"
|
||||
>
|
||||
<IconButton size="xs" variant="ghost" aria-label="Widget Settings">
|
||||
<MoreVertical size={16}/>
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
|
||||
{/* NEW: The Positioner lifts the content out of the Flexbox flow */}
|
||||
<Popover.Positioner zIndex={10}>
|
||||
<Popover.Content bg="gray.50" p={4} boxShadow="xl" borderRadius="md">
|
||||
<Popover.Positioner zIndex={1}>
|
||||
<Popover.Content css={popoverStyles.content}>
|
||||
<Suspense fallback={<Spinner size="sm"/>}>
|
||||
<SettingsUI
|
||||
widgetId={widget.id}
|
||||
@@ -113,25 +96,19 @@ export default function MyDashboard() {
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Popover.Root>
|
||||
) : (
|
||||
<Box w="20px" h="20px"/>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* THE CHART CANVAS */}
|
||||
<Box flex="1" overflow="hidden" position="relative" p={1}>
|
||||
{/* Pass the settings object into the actual chart component */}
|
||||
{/* Pass widget ID to allow updating settings in widget component rather than just in settings component */}
|
||||
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
|
||||
</Box>
|
||||
|
||||
</Flex>
|
||||
<Box css={widgetStyles.body}>
|
||||
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback if someone asks for a widget that doesn't exist
|
||||
return (
|
||||
<Flex width="100%" height="100%" align="center" justify="center" direction="column" bg="gray.800">
|
||||
<Flex css={widgetStyles.body} align="center" justify="center" direction="column">
|
||||
<Text fontWeight="bold" color="red.400">Error</Text>
|
||||
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
|
||||
</Flex>
|
||||
@@ -148,36 +125,31 @@ export default function MyDashboard() {
|
||||
rowHeight={30}
|
||||
isDraggable={isEditing} // Only allows dragging when Settings tools toggle this
|
||||
isResizable={false} // Double lock-down
|
||||
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
|
||||
compactType="vertical" // Auto-packs widgets to the top
|
||||
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
|
||||
>
|
||||
{/* 3. 🎨 Paint the Widgets */}
|
||||
{/* 🎨 Paint the Widgets */}
|
||||
{widgets.map(widget => (
|
||||
<Box
|
||||
key={widget.id}
|
||||
bg="white"
|
||||
borderRadius="md"
|
||||
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
|
||||
border={isEditing ? "2px dashed gray" : "1px solid"}
|
||||
borderColor={isEditing ? "gray.400" : "gray.200"}
|
||||
overflow="hidden"
|
||||
cursor={isEditing ? "grab" : "default"}
|
||||
position="relative" // 3. REQUIRED so the absolute button stays inside this box
|
||||
css={widgetStyles.root}
|
||||
position="relative" // REQUIRED so the absolute button stays inside this box
|
||||
>
|
||||
{/* 4. THE DELETE BUTTON */}
|
||||
{/* THE DELETE BUTTON */}
|
||||
{isEditing && (
|
||||
<IconButton
|
||||
aria-label="Remove widget"
|
||||
position="absolute"
|
||||
top={2}
|
||||
right={2}
|
||||
size="xs"
|
||||
bg="white"
|
||||
color={iconColor}
|
||||
color="black"
|
||||
border="1px solid"
|
||||
borderRadius="xl"
|
||||
borderColor="gray.200"
|
||||
_hover={{bg: "red.50", color: "red.500", borderColor: "red.200"}}
|
||||
zIndex={2}
|
||||
aria-label="Remove widget"
|
||||
size="xs"
|
||||
onClick={() => removeWidget(widget.id)}
|
||||
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
|
||||
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
|
||||
@@ -186,15 +158,14 @@ export default function MyDashboard() {
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
|
||||
{/* SUSPENSE BOUNDARY wrapping the render function */}
|
||||
<Suspense fallback={
|
||||
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
|
||||
<Flex css={widgetStyles.body} align="center" justify="center">
|
||||
<Spinner size="md" color="blue.400" thickness="3px"/>
|
||||
</Flex>
|
||||
}>
|
||||
{renderWidget(widget)}
|
||||
</Suspense>
|
||||
|
||||
</Box>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
|
||||
@@ -86,7 +86,22 @@ const useAuthStore = create((set) => ({
|
||||
} catch (error) {
|
||||
console.error("Failed to save layout to database", error);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// updateProfile: async (newProfile) => {
|
||||
// try {
|
||||
// set((state) => ({
|
||||
// user: {
|
||||
// ...state.user,
|
||||
// username: newProfile.username,
|
||||
// }
|
||||
// }));
|
||||
//
|
||||
// await api.post('profile/update/', { profile: newProfile });
|
||||
// } catch (error) {
|
||||
// console.error("Failed to update profile", error);
|
||||
// }
|
||||
// }
|
||||
}));
|
||||
|
||||
export default useAuthStore;
|
||||
+522
-100
@@ -1,101 +1,533 @@
|
||||
import { defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe } from "@chakra-ui/react";
|
||||
import {defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe} from "@chakra-ui/react";
|
||||
|
||||
// RECIPES
|
||||
const headerRecipe = defineRecipe({
|
||||
// REUSABLE GLASS SURFACE
|
||||
const glassSurface = {
|
||||
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "rgba(255, 255, 255, 0.04)"},
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid",
|
||||
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
|
||||
boxShadow: "sm",
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
RECIPES
|
||||
========================================================================== */
|
||||
|
||||
// 1. Header
|
||||
const headerRecipe = defineSlotRecipe({
|
||||
slots: ["root", "search_bar", "user_info"],
|
||||
base: {
|
||||
bg: "{base200}",
|
||||
root: {
|
||||
bg: "transparent",
|
||||
color: "text",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderBottom: "1px solid",
|
||||
borderBottomColor:"{border}",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}
|
||||
});
|
||||
pl: 3,
|
||||
},
|
||||
search_bar: {
|
||||
width: "100%",
|
||||
// input element in search bar component
|
||||
"& input": {
|
||||
width: "410px",
|
||||
},
|
||||
},
|
||||
user_info: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
pr: 6,
|
||||
|
||||
const dashboardRecipe = defineRecipe({
|
||||
base: {
|
||||
bg: "{base200}"
|
||||
}
|
||||
});
|
||||
|
||||
const canvasRecipe = defineRecipe({
|
||||
base: {
|
||||
bg: "{base200}",
|
||||
zIndex: "-1",
|
||||
color: "{text}"
|
||||
}
|
||||
// The Text component
|
||||
"& p": {
|
||||
fontWeight: "600",
|
||||
fontSize: "md",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Sidebar
|
||||
const sidebarRecipe = defineSlotRecipe({
|
||||
slots: ["root", "close_button", "buttons", "dark_mode_button"],
|
||||
slots: [
|
||||
"root",
|
||||
"logo",
|
||||
"logo_text",
|
||||
"footer",
|
||||
"button_text",
|
||||
"buttons",
|
||||
"collapse_button",
|
||||
"color_mode_button",
|
||||
],
|
||||
base: {
|
||||
root: {
|
||||
bg: "{base200}",
|
||||
borderRight: "1px solid",
|
||||
borderColor: "{border}",
|
||||
// 1. Semi-transparent backgrounds
|
||||
bg: {
|
||||
base: "linear-gradient(to bottom right, #97282880 20%, #EB7F3160 60%, #FCAD3840 100%)",
|
||||
_dark: "linear-gradient(to bottom right, #9900003C 20%, #FF66003C 60%, #FFC0003C 100%)"
|
||||
},
|
||||
|
||||
// 2. The glass blur effect
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
|
||||
// 3. Subtle semi-transparent borders
|
||||
border: "1px solid",
|
||||
borderColor: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"},
|
||||
|
||||
// CRITICAL: Remove the right border so it doesn't draw a line through your cutout
|
||||
borderRight: "none",
|
||||
|
||||
borderRadius: "2xl",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
height: "calc(100% - 16px)",
|
||||
margin: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
justifyContent: "start",
|
||||
alignItems: "start",
|
||||
gap: 2,
|
||||
p: 2,
|
||||
alignItems: "stretch",
|
||||
gap: 4,
|
||||
transition: "width 0.5s ease",
|
||||
|
||||
// CRITICAL: Adjust padding and overflow to allow the button to touch the right edge
|
||||
pl: 2, // Keep left padding
|
||||
py: 2, // Keep top/bottom padding
|
||||
pr: 0, // Remove right padding
|
||||
pt: 4, // Added top padding to give the logo room to breathe
|
||||
overflow: "visible", // Allows the curved corner patches to render outside the button
|
||||
zIndex: 10,
|
||||
},
|
||||
buttons: {
|
||||
position: "relative", // Required to anchor the corner patches
|
||||
bg: "transparent",
|
||||
_hover: {bg: "{primaryLt}", color: "{textInverted}"},
|
||||
"&.active": {bg: "{primary}", color: "{textInverted}"},
|
||||
fontSize: "xl",
|
||||
|
||||
justifyContent: "flex-start",
|
||||
transition: "background-color 0.3s ease, color 0.3s ease",
|
||||
|
||||
"& svg": {
|
||||
transition: "stroke-width 0.4s ease",
|
||||
flexShrink: "0",
|
||||
},
|
||||
|
||||
_hover: {
|
||||
bg: "rgba(255, 255, 255, 0.2)",
|
||||
color: "{text}",
|
||||
"& svg": {
|
||||
strokeWidth: "3",
|
||||
},
|
||||
"& span": {
|
||||
fontWeight: "700",
|
||||
},
|
||||
},
|
||||
|
||||
fontSize: "l",
|
||||
color: {base: "{text}"},
|
||||
variant: "ghost",
|
||||
width: "100%",
|
||||
|
||||
// Flat on the right, rounded on the left
|
||||
borderRightRadius: "0",
|
||||
borderLeftRadius: "4xl",
|
||||
|
||||
"&.active": {
|
||||
bg: "{MainBackground}",
|
||||
color: "{textInverted}",
|
||||
|
||||
_hover: {
|
||||
bg: "{MainBackground}",
|
||||
color: "{textInverted}",
|
||||
"& svg": { strokeWidth: "2" },
|
||||
"& span": { fontWeight: "500" },
|
||||
},
|
||||
|
||||
// The Top Inverted Curve
|
||||
_before: {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
right: "-1px",
|
||||
top: "-25px",
|
||||
width: "25px",
|
||||
height: "25px",
|
||||
bg: "inherit",
|
||||
WebkitMaskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
|
||||
maskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
|
||||
// The Bottom Inverted Curve
|
||||
_after: {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
right: "-1px",
|
||||
bottom: "-25px",
|
||||
width: "25px",
|
||||
height: "25px",
|
||||
bg: "inherit",
|
||||
WebkitMaskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
|
||||
maskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
logo: {
|
||||
width: "100%",
|
||||
justifyContent: "flex-start",
|
||||
ml: 2,
|
||||
textStyle: "4xl",
|
||||
fontWeight: "bold",
|
||||
overflow: "hidden",
|
||||
px: 0,
|
||||
},
|
||||
logo_text: {
|
||||
transition: "opacity 0.2s ease, visibility 0.2s ease, margin 0.2s ease",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "clip",
|
||||
},
|
||||
footer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
width: "100%",
|
||||
mt: "auto",
|
||||
pl: 2,
|
||||
pr: 2,
|
||||
},
|
||||
button_text: {
|
||||
transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "clip",
|
||||
fontWeight: "500",
|
||||
},
|
||||
collapse_button: {
|
||||
"& svg": {flexShrink: "0"},
|
||||
},
|
||||
color_mode_button: {
|
||||
"& button": {
|
||||
_hover: {
|
||||
bg: "transparent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
state: {
|
||||
open: {
|
||||
root: {width: "190px"},
|
||||
button_text: {ml: 3, opacity: 1, visibility: "visible"},
|
||||
logo_text: {ml: 3, opacity: 1, visibility: "visible", transition: "opacity 0.5s ease, visibility 0.5s ease, margin 0.5s ease"},
|
||||
color_mode_button: {
|
||||
"& button": {
|
||||
position: "static",
|
||||
},
|
||||
// the css below is equivalent to chakra's 'truncate' prop
|
||||
// it makes sure the icon doesn't jump out of the bounds of the sidebar while it's transitioning from closed to open
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "clip",
|
||||
},
|
||||
},
|
||||
closed: {
|
||||
root: {width: "71px"},
|
||||
button_text: {opacity: 0, visibility: "hidden"},
|
||||
logo_text: {opacity: 0, visibility: "hidden", transition: "opacity 0.2s ease, visibility 0.2s ease, margin 0.2s ease"},
|
||||
color_mode_button: {"& button": {position: "relative", bottom: "35px", right: "37px",}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const widgetRecipe = defineSlotRecipe({
|
||||
slots: ["root", "header", "canvas"],
|
||||
// 3. Dashboard Shell
|
||||
const dashboardRecipe = defineRecipe({
|
||||
base: {
|
||||
root: {
|
||||
flexDirection: "column",
|
||||
gap: "1",
|
||||
position: "relative",
|
||||
bg: "base100",
|
||||
borderRadius: "md",
|
||||
p: "1",
|
||||
paddingBottom: "2",
|
||||
border: "1px solid",
|
||||
borderColor: "{base300}",
|
||||
textAlign: "center",
|
||||
height: "100%",
|
||||
position: "fixed"
|
||||
},
|
||||
header: {
|
||||
flexShrink: "0",
|
||||
bg: "{base300}",
|
||||
color: "{text}",
|
||||
cursor: "move",
|
||||
borderRadius: "sm",
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "left",
|
||||
pl: "2"
|
||||
},
|
||||
canvas: {
|
||||
flex: "1 1 auto",
|
||||
bg: "transparent",
|
||||
color: "{text}",
|
||||
cursor: "default",
|
||||
borderRadius: "md",
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
bg: "{MainBackground}",
|
||||
|
||||
// selects the third div in the grid layout (the canvas)
|
||||
"& > div:nth-of-type(3)": {
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Home Page Tabs
|
||||
const homeRecipe = defineSlotRecipe({
|
||||
slots: ["root", "header", "body"],
|
||||
base: {
|
||||
root: {
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
},
|
||||
header: {
|
||||
// Flex contianer as a header that contains all the tabs
|
||||
width: "100%",
|
||||
align: "center",
|
||||
justify: "space-between",
|
||||
top: "0",
|
||||
p: 5,
|
||||
position: "sticky",
|
||||
zIndex: "dropdown",
|
||||
bg: "transparent",
|
||||
|
||||
// THE TABS
|
||||
// Flex container renders as div for all the buttons within the header
|
||||
"& > div": {
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Dashboard Widget Card
|
||||
const widgetRecipe = defineSlotRecipe({
|
||||
slots: ["root", "header", "body"],
|
||||
base: {
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
...glassSurface,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: "3xl",
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
px: 3,
|
||||
pt: 2,
|
||||
pb: 0,
|
||||
"& > p": {
|
||||
color: "titleColor",
|
||||
fontWeight: "500",
|
||||
fontSize: "16px",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "clip",
|
||||
},
|
||||
"& > button": {
|
||||
borderRadius: "xl",
|
||||
_hover: {color: "white", bg: "gray.500"},
|
||||
},
|
||||
},
|
||||
body: {flex: "1", overflow: "hidden", position: "relative", px: 1, pb: 1, pt: 0},
|
||||
},
|
||||
variants: {
|
||||
mode: {
|
||||
editing: {
|
||||
root: {
|
||||
boxShadow: "outline",
|
||||
borderStyle: "dashed",
|
||||
borderWidth: "2px",
|
||||
borderColor: "orange.300",
|
||||
cursor: "grab",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. TRUE POPOVER: Compact floating card for 3-dots menus
|
||||
const glassPopoverRecipe = defineSlotRecipe({
|
||||
slots: ["content", "arrow", "positioner"],
|
||||
base: {
|
||||
content: {
|
||||
...glassSurface,
|
||||
borderRadius: "xl",
|
||||
p: 3,
|
||||
boxShadow: "lg",
|
||||
bg: {base: "rgba(255, 255, 255, 0.85)", _dark: "rgba(20, 22, 25, 0.85)"},
|
||||
backdropFilter: "blur(15px)",
|
||||
WebkitBackdropFilter: "blur(15px)",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 7. MODAL DIALOG: Large view for the Widget Shop
|
||||
const glassDialogRecipe = defineSlotRecipe({
|
||||
slots: ["content", "header", "body", "backdrop"],
|
||||
base: {
|
||||
backdrop: {
|
||||
bg: "rgba(0, 0, 0, 0.4)",
|
||||
backdropFilter: "blur(4px)",
|
||||
WebkitBackdropFilter: "blur(4px)",
|
||||
},
|
||||
content: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
...glassSurface,
|
||||
borderRadius: "2xl",
|
||||
p: 2,
|
||||
boxShadow: "xl",
|
||||
minW: "240px",
|
||||
maxW: "60%",
|
||||
minH: "80%",
|
||||
overflow: "hidden",
|
||||
bg: {base: "rgba(255, 255, 255, 0.75)", _dark: "rgba(20, 22, 25, 0.8)"},
|
||||
backdropFilter: "blur(15px)",
|
||||
WebkitBackdropFilter: "blur(15px)",
|
||||
},
|
||||
header: {
|
||||
bg: "transparent",
|
||||
color: {base: "gray.800", _dark: "white"},
|
||||
py: 4,
|
||||
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
|
||||
"& h2": {fontSize: "xl", fontWeight: "bold"},
|
||||
},
|
||||
body: {
|
||||
p: 0,
|
||||
"& div.chakra-tabs__root > div:first-child": {
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
maxW: "100%",
|
||||
mx: 5,
|
||||
mt: 4,
|
||||
mb: 6
|
||||
},
|
||||
"& div.chakra-tabs__list": {
|
||||
bg: {base: "rgba(0, 0, 0, 0.04)", _dark: "rgba(255, 255, 255, 0.05)"},
|
||||
p: 1, borderRadius: "full", display: "flex", flexWrap: "nowrap", overflowX: "auto", maxW: "100%",
|
||||
border: "1px solid", borderColor: {base: "blackAlpha.50", _dark: "whiteAlpha.100"},
|
||||
scrollbarWidth: "none", "&::-webkit-scrollbar": {display: "none"},
|
||||
"& button.chakra-tabs__trigger": {
|
||||
px: 5, py: 2, borderRadius: "full", whiteSpace: "nowrap", flexShrink: 0,
|
||||
color: {base: "gray.500", _dark: "gray.400"},
|
||||
fontWeight: "medium", transition: "background-color 0.2s, color 0.2s, box-shadow 0.2s",
|
||||
_hover: {color: {base: "gray.800", _dark: "gray.200"}},
|
||||
_selected: {
|
||||
bg: {base: "white", _dark: "rgba(255, 255, 255, 0.12)"},
|
||||
color: {base: "blue.600", _dark: "blue.200"},
|
||||
boxShadow: "sm",
|
||||
fontWeight: "bold"
|
||||
}
|
||||
}
|
||||
},
|
||||
"& div.chakra-tabs__root > div:first-child div:has(svg)": {
|
||||
position: "absolute", top: "0", bottom: "0", w: "50px", alignItems: "center", justifyContent: "center",
|
||||
zIndex: 2, cursor: "pointer", bg: {base: "rgba(255, 255, 255, 0.7)", _dark: "rgba(30, 30, 30, 0.8)"},
|
||||
color: {base: "gray.600", _dark: "gray.300"}, backdropFilter: "blur(1px)", transition: "background-color 0.2s",
|
||||
_hover: {
|
||||
color: {base: "blue.500", _dark: "blue.300"},
|
||||
bg: {base: "rgba(255, 255, 255, 0.9)", _dark: "rgba(40, 40, 40, 0.95)"}
|
||||
},
|
||||
"&:has(.lucide-chevron-left)": {
|
||||
left: 0,
|
||||
borderTopLeftRadius: "full",
|
||||
borderBottomLeftRadius: "full",
|
||||
WebkitMaskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 19px)",
|
||||
maskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 35px)"
|
||||
},
|
||||
"&:has(.lucide-chevron-right)": {
|
||||
right: 0,
|
||||
borderTopRightRadius: "full",
|
||||
borderBottomRightRadius: "full",
|
||||
WebkitMaskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 19px)",
|
||||
maskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 35px)"
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. Pill/Glass Expanding Tab Button
|
||||
const glassTabRecipe = defineRecipe({
|
||||
base: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "40px",
|
||||
|
||||
// The Circle State (Inactive)
|
||||
maxWidth: "40px",
|
||||
minWidth: "40px",
|
||||
px: "8px",
|
||||
gap: 2,
|
||||
|
||||
// CRITICAL: Locks the shape to a perfect circle/pill
|
||||
borderRadius: "full",
|
||||
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
fontWeight: "500",
|
||||
color: "fg.muted",
|
||||
|
||||
// --- THE GLASS EFFECT (Inactive) ---
|
||||
// Uses a highly transparent white/black depending on mode
|
||||
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "rgba(255, 255, 255, 0.03)"},
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid",
|
||||
borderColor: {base: "rgba(0, 0, 0, 0.05)", _dark: "rgba(255, 255, 255, 0.08)"},
|
||||
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.02)", // Tiny shadow to lift it off the canvas
|
||||
|
||||
transition: "all 0.85s cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
|
||||
"&:hover, &[data-state='open'], &[aria-expanded='true']": {
|
||||
maxWidth: "250px",
|
||||
pr: "16px",
|
||||
// --- THE GLASS EFFECT (Hover) ---
|
||||
// Becomes slightly more opaque when you mouse over
|
||||
bg: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"},
|
||||
borderColor: {base: "rgba(0, 0, 0, 0.1)", _dark: "rgba(255, 255, 255, 0.15)"},
|
||||
color: "fg",
|
||||
textDecoration: "none",
|
||||
},
|
||||
|
||||
"&.active": {
|
||||
maxWidth: "250px",
|
||||
pr: "16px",
|
||||
|
||||
bg: "{gooeyButtonBackground}",
|
||||
borderColor: "transparent",
|
||||
color: "{textInverted}",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)", // Stronger shadow for the active tab
|
||||
|
||||
_hover: {
|
||||
bg: "{gooeyButtonBackground}",
|
||||
cursor: "default",
|
||||
textDecoration: "none",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/* ==========================================================================
|
||||
RAW WIDGET COLORS (For Canvas/Plotly libraries that reject CSS variables)
|
||||
========================================================================== */
|
||||
export const rawWidgetColors = {
|
||||
light: {
|
||||
text: "#000000",
|
||||
muted: "#4A5568", // gray.600
|
||||
grid: "rgba(0, 0, 0, 0.1)",
|
||||
},
|
||||
dark: {
|
||||
text: "#FFFFFF",
|
||||
muted: "#A0AEC0", // gray.400
|
||||
grid: "rgba(255, 255, 255, 0.15)",
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
CONFIG & SYSTEM
|
||||
========================================================================== */
|
||||
const config = defineConfig({
|
||||
globalCss: {
|
||||
".react-grid-placeholder": {
|
||||
@@ -134,36 +566,14 @@ const config = defineConfig({
|
||||
"::-webkit-scrollbar-track": {
|
||||
background: "transparent",
|
||||
},
|
||||
// '.leaflet-tile': {
|
||||
// _dark: {
|
||||
// filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
||||
// }
|
||||
// },
|
||||
'.leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container': {
|
||||
".leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container": {
|
||||
_dark: {
|
||||
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
||||
}
|
||||
},
|
||||
'.leaflet-control-attribution': {
|
||||
_dark: {
|
||||
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
||||
}
|
||||
},
|
||||
'.leaflet-container': {
|
||||
".leaflet-container": {
|
||||
fontFamily: "system-ui, sans-serif !important",
|
||||
_dark: {
|
||||
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
|
||||
}
|
||||
},
|
||||
// ".leaflet-popup-content": {
|
||||
// fontFamily: "georgia, system-ui, sans-serif !important"
|
||||
// },
|
||||
// ".leaflet-control a": {
|
||||
// fontFamily: "georgia, system-ui, sans-serif !important"
|
||||
// },
|
||||
// ".leaflet-tooltip": {
|
||||
// fontFamily: "georgia, system-ui, sans-serif !important"
|
||||
// },
|
||||
".react-grid-item": {
|
||||
transition: "none !important"
|
||||
},
|
||||
@@ -171,31 +581,43 @@ const config = defineConfig({
|
||||
theme: {
|
||||
semanticTokens: {
|
||||
colors: {
|
||||
base100: { value: {base: "#f6f9faff", _dark: "#1617199e"}},
|
||||
base200: { value: {base: "#e6ecf1ff", _dark: "#242424"}},
|
||||
base300: { value: {base: "#d3dae4ff", _dark: "#0a0b0bff"}},
|
||||
primary: { value: {base: "#2B98F8", _dark: "#2B98F8"}},
|
||||
primaryLt: { value: {base: "#98cdffff", _dark: "#98cdffff"}},
|
||||
secondaryBG: { value: {base: "#EDF1F1", _dark: "#121212"}},
|
||||
border: {value: {base: "#000000", _dark: "#cbcbcb84"}},
|
||||
text: {value: {base: "#000000", _dark: "lightgrey"}},
|
||||
textInverted: {value: "white"},
|
||||
icon: {value: {base: "#000000", _dark: "lightgrey"}},
|
||||
text: { value: { base: "#000000", _dark: "#FFFFFF" } },
|
||||
textInverted: { value: { base: "#000000", _dark: "#FFFFFF" } }, // Fixed: Properly inverted
|
||||
MainBackground: { value: { base: "#F9F9F9", _dark: "#141414" } },
|
||||
titleColor: { value: { base: "gray.800", _dark: "#FFFFFF" } },
|
||||
iconColor: { value: { base: "gray.600", _dark: "gray.300" } }, // Fixed: Visible in dark mode
|
||||
glassSeperator: { value: { base: "#0000001A", _dark: "#FFFFFF3F" } },
|
||||
gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "rgba(255, 255, 255, 0.1)"}},
|
||||
|
||||
// WIDGET SPECIFIC VALUES
|
||||
widgetItemBg: { value: { base: "rgba(0, 0, 0, 0.04)", _dark: "rgba(255, 255, 255, 0.05)" } },
|
||||
widgetItemExpandedBg: { value: { base: "rgba(0, 0, 0, 0.08)", _dark: "rgba(0, 0, 0, 0.3)" } },
|
||||
widgetBadgeBg: { value: { base: "rgba(255, 255, 255, 0.6)", _dark: "rgba(0, 0, 0, 0.5)" } },
|
||||
widgetTextMuted: { value: { base: "gray.600", _dark: "gray.400" } },
|
||||
|
||||
// ALERT COLORS
|
||||
alertRed: { value: { base: "red.600", _dark: "red.400" } },
|
||||
alertOrange: { value: { base: "orange.600", _dark: "orange.400" } },
|
||||
alertYellow: { value: { base: "yellow.600", _dark: "yellow.400" } },
|
||||
alertGreen: { value: { base: "green.600", _dark: "green.400" } },
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
fonts: {
|
||||
body: { value: "system-ui, sans-serif" },
|
||||
body: {value: "system-ui, sans-serif"},
|
||||
},
|
||||
},
|
||||
recipes: {
|
||||
header: headerRecipe,
|
||||
dashboard: dashboardRecipe,
|
||||
canvas: canvasRecipe,
|
||||
glassTab: glassTabRecipe,
|
||||
},
|
||||
slotRecipes: {
|
||||
header: headerRecipe,
|
||||
sidebar: sidebarRecipe,
|
||||
home: homeRecipe,
|
||||
widget: widgetRecipe,
|
||||
glassPopover: glassPopoverRecipe,
|
||||
glassDialog: glassDialogRecipe,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user