Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b73ce88185 | ||
|
|
b7a5ae2ae2 | ||
|
|
5798b90ab7 | ||
|
|
7b3e7d3d0c | ||
|
|
58c6a40b06 | ||
|
|
e50638ab55 | ||
|
|
189d6e2d1d | ||
|
|
d4fb170e14 | ||
|
|
872550c2c9 | ||
|
|
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 | ||
|
|
6c0664d05a | ||
|
|
43cc39fd62 | ||
|
|
75b65bfcaa | ||
|
|
7e0723a776 | ||
|
|
6304db66c2 | ||
|
|
6eb5e42111 | ||
|
|
152aacb1c3 | ||
|
|
1f2b578c91 | ||
|
|
a5f2f012dc | ||
|
|
0381bc3ce8 | ||
|
|
f2637c7469 | ||
|
|
dab9b8227e | ||
|
|
7f92ddd62b | ||
|
|
7667235fdb | ||
|
|
2bea05bf29 | ||
|
|
058b51795b | ||
|
|
0c79b409bd | ||
|
|
d9bdb17217 | ||
|
|
520731aa47 | ||
|
|
a61d3f104e | ||
|
|
5ec7734b9b | ||
|
|
abb2c2ba5c | ||
|
|
182495e11a | ||
|
|
8742b85d1c | ||
|
|
dccebbc21b | ||
|
|
1dcee8c8c3 | ||
|
|
0861ca2b94 | ||
|
|
14a4af623d | ||
|
|
999e3bb50c | ||
|
|
8525dcc71c | ||
|
|
bb1e55d796 |
@@ -24,6 +24,6 @@ export default defineConfig([globalIgnores(['dist']), {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
'no-unused-vars': 'off',
|
||||
},
|
||||
}, ...storybook.configs["flat/recommended"]])
|
||||
|
||||
@@ -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",
|
||||
|
||||
+60
-63
@@ -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,81 +18,78 @@ 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";
|
||||
|
||||
export default function App() {
|
||||
// 1. Pull the initialization function from Zustand
|
||||
const checkAuth = useAuthStore((state) => state.checkAuth);
|
||||
|
||||
// 2. Ping Django to verify the HttpOnly cookie the moment the app boots
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* --- Public Routes --- */}
|
||||
<Route path="/login" element={<Login/>}/>
|
||||
|
||||
{/* --- Secure Routes --- */}
|
||||
<Route element={<ProtectedRoute/>}>
|
||||
|
||||
{/* --- Non-Public Routes (Now completely locked down) --- */}
|
||||
<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>
|
||||
|
||||
<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/>}/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
// The fundamental layout of the webpage
|
||||
function AppFoundation() {
|
||||
const recipe = useRecipe({ key: "dashboard" });
|
||||
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"
|
||||
>
|
||||
<DialogPopup dialog={dialog}/>
|
||||
<Grid css={styles} templateRows="1fr 11fr" templateColumns="auto 1fr">
|
||||
<GridItem rowSpan={2} colSpan={1}>
|
||||
<Sidebar />
|
||||
<Sidebar/>
|
||||
</GridItem>
|
||||
<GridItem rowSpan={1} colSpan={1}>
|
||||
<Header />
|
||||
<Header/>
|
||||
</GridItem>
|
||||
<GridItem colSpan={1} width="100%" overflow="auto">
|
||||
<Outlet />
|
||||
<GridItem colSpan={1}>
|
||||
<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>
|
||||
// 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>
|
||||
);
|
||||
|
||||
function App() {
|
||||
// 1. Pull the initialization function from Zustand
|
||||
const checkAuth = useAuthStore((state) => state.checkAuth);
|
||||
|
||||
// 2. Ping Django to verify the HttpOnly cookie the moment the app boots
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* --- Public Routes --- */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
{/* --- SECURE ROUTES --- */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
|
||||
{/* --- Non-Public Routes (Now completely locked down) --- */}
|
||||
<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>
|
||||
|
||||
<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 />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
|
||||
import VdapApi from "../../hooks/useVdapApi.js";
|
||||
import useBuildUrl from "../../hooks/useBuildUrl.js";
|
||||
import {
|
||||
Footprints,
|
||||
Plane,
|
||||
Sailboat,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Collapsible,
|
||||
Flex,
|
||||
Spinner,
|
||||
Text,
|
||||
Timeline,
|
||||
Center,
|
||||
} from "@chakra-ui/react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
export default function AlertChanges({ settings, widgetID }) {
|
||||
// 0) CREATE SELF AWARENESS, by finding 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],
|
||||
),
|
||||
);
|
||||
|
||||
// 1) BUILD URL, default is 7 days past
|
||||
const url = useBuildUrl(
|
||||
"/api/v2/alert_changes",
|
||||
settings?.startDate,
|
||||
settings?.endDate,
|
||||
);
|
||||
|
||||
// 2) CALL API hook to retrieve data
|
||||
const { data: apiData, isLoading, error } = VdapApi(url);
|
||||
|
||||
// 3) SORT the data by grouping into volcanoes and sorting form newest to oldest
|
||||
// doing this 1st bc the filters might change w/ user input but the sorts (currently) wont and thus will
|
||||
// only run when the api retrieves new data
|
||||
const sortedData = useMemo(() => {
|
||||
if (apiData.length === 0) return [];
|
||||
|
||||
// .reduce() is a built-in array method used to transform an entire array into a single value (like an object)
|
||||
// here we sort the data by volcano, and return it as a single massive object w/ volcano names as keys
|
||||
const grouped = apiData.reduce((accumulator, item) => {
|
||||
if (!accumulator[item.volcano]) accumulator[item.volcano] = [];
|
||||
accumulator[item.volcano].push(item);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
// converts the 'grouped' object back into an array of [key, value] pairs
|
||||
const group_arr = Object.entries(grouped);
|
||||
|
||||
// sort data newest to oldest within each volcano
|
||||
const sorted = group_arr.map((item) => [
|
||||
item[0], // volcano name
|
||||
[...item[1]].sort( // item[1] is the array of al changes for a volcano
|
||||
(a, b) =>
|
||||
new Date(b.alert_start_date_local) -
|
||||
new Date(a.alert_start_date_local),
|
||||
),
|
||||
]);
|
||||
|
||||
// the volcanoes with the most recent issued alert level change appear at the top of the list (newest -> oldest)
|
||||
const sorted_entries = [...sorted].sort(
|
||||
(a, b) =>
|
||||
new Date(b[1][0].alert_start_date_local) -
|
||||
new Date(a[1][0].alert_start_date_local),
|
||||
);
|
||||
|
||||
// additionally, seperates out data into domains within a volcano for sm widgets,
|
||||
// to list all most recent al changes w/in a volcano w/in a domain
|
||||
if (thisWidget?.size === "small") {
|
||||
return sorted_entries.map((volcano) => {
|
||||
const domain = volcano[1].reduce((acc, curr) => {
|
||||
if (!acc[curr.alert_scheme_domain])
|
||||
acc[curr.alert_scheme_domain] = [];
|
||||
acc[curr.alert_scheme_domain].push(curr);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const domain_arr = Object.entries(domain);
|
||||
console.log("sm data: ", [volcano[0], domain_arr]);
|
||||
return [volcano[0], domain_arr];
|
||||
});
|
||||
}
|
||||
|
||||
console.log("lg data: ", sorted_entries);
|
||||
return sorted_entries;
|
||||
}, [apiData, thisWidget]);
|
||||
|
||||
// 4) FILTER according to what is set in the widgets settings
|
||||
const filteredData = useMemo(() => {
|
||||
if (sortedData.length === 0) return [];
|
||||
let filtered_arr = [];
|
||||
|
||||
// must show at least one inc or dec alert levels, otherwise no data is displayed
|
||||
if (
|
||||
!settings?.filters?.levelIncreases &&
|
||||
!settings?.filters?.levelDecreases
|
||||
)
|
||||
return [];
|
||||
|
||||
// return all data
|
||||
if (
|
||||
settings?.filters?.levelIncreases &&
|
||||
settings?.filters?.levelDecreases &&
|
||||
!settings?.filters.elevatedLevels
|
||||
)
|
||||
return sortedData;
|
||||
|
||||
// only elevated alert levels
|
||||
filtered_arr = settings?.filters.elevatedLevels
|
||||
? sortedData.map((volcano) => [
|
||||
volcano[0],
|
||||
volcano[1].filter(
|
||||
(item) =>
|
||||
item.alert_scheme_highest - Number(item.alert_level_rank) <= 1,
|
||||
),
|
||||
])
|
||||
.filter((volcano) => volcano[1].length > 0)
|
||||
: sortedData;
|
||||
|
||||
// only decreases (rm inc)
|
||||
filtered_arr = !settings?.filters.levelIncreases
|
||||
? filtered_arr.map((volcano) => [
|
||||
volcano[0],
|
||||
volcano[1].filter((item) => !(item.change_type === "increase")),
|
||||
])
|
||||
.filter((volcano) => volcano[1].length > 0)
|
||||
: filtered_arr;
|
||||
|
||||
// only increases (rm dec)
|
||||
filtered_arr = !settings?.filters?.levelDecreases
|
||||
? filtered_arr.map((volcano) => [
|
||||
volcano[0],
|
||||
volcano[1].filter((item) => !(item.change_type === "decrease")),
|
||||
])
|
||||
.filter((volcano) => volcano[1].length > 0)
|
||||
: filtered_arr;
|
||||
|
||||
console.log("filtered_arr: ", filtered_arr);
|
||||
return filtered_arr;
|
||||
}, [sortedData, settings]);
|
||||
|
||||
// Alert colors are now light/dark mode responsive
|
||||
const alertColors = {
|
||||
Red: "alertRed",
|
||||
Orange: "alertOrange",
|
||||
Yellow: "alertYellow",
|
||||
Green: "alertGreen",
|
||||
};
|
||||
|
||||
const options = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZoneName: "short"
|
||||
};
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<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" bg="transparent">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (filteredData.length === 0)
|
||||
return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
|
||||
<Text textAlign="center" color="widgetTextMuted">
|
||||
No alert level data available for the selected dates.
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
gap={2}
|
||||
p={2}
|
||||
w="100%"
|
||||
h="100%"
|
||||
bg="transparent"
|
||||
overflowY="auto"
|
||||
>
|
||||
{filteredData.map((volcano, index) => {
|
||||
if (thisWidget?.size === "large")
|
||||
return (
|
||||
<Collapsible.Root key={index} w="100%">
|
||||
<Collapsible.Context>
|
||||
{(collapsible) => (
|
||||
<Collapsible.Trigger w="100%">
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%"
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p={1}
|
||||
pl={3}
|
||||
pr={3}
|
||||
borderTopRadius="lg"
|
||||
borderBottomRadius={collapsible.open ? "0" : "lg"}
|
||||
bg="widgetItemBg"
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
w="100%"
|
||||
minW={0}
|
||||
textAlign="left"
|
||||
>
|
||||
<Text truncate color="text" fontWeight="medium">
|
||||
{volcano[0]}
|
||||
</Text>
|
||||
<Text truncate fontSize="xs" color="widgetTextMuted">
|
||||
{new Date(volcano[1][0]?.alert_start_date_local)
|
||||
.toLocaleString('en-US', options)
|
||||
}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right" gap={2}>
|
||||
<Flex w="fit" color="iconColor">
|
||||
{volcano[1][0]?.change_type === "increase" ? (
|
||||
<TrendingUp />
|
||||
) : (
|
||||
<TrendingDown />
|
||||
)}
|
||||
</Flex>
|
||||
<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.Context>
|
||||
|
||||
<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.Indicator
|
||||
outline="none"
|
||||
bg="transparent"
|
||||
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="transparent"
|
||||
color="iconColor"
|
||||
size="lg"
|
||||
>
|
||||
{object?.change_type === "increase" ? (
|
||||
<TrendingUp />
|
||||
) : (
|
||||
<TrendingDown />
|
||||
)}
|
||||
</Timeline.Indicator>
|
||||
</Timeline.Connector>
|
||||
<Timeline.Content w="100%" h="100%">
|
||||
<Timeline.Description w="100%" h="100%" gap={1}>
|
||||
<Flex direction="column" gap={1}>
|
||||
<Text
|
||||
w="100%"
|
||||
h="100%"
|
||||
color="widgetTextMuted"
|
||||
>
|
||||
{new Date(object?.alert_start_date_local)
|
||||
.toLocaleString('en-US', {...options, timeZone: 'UTC'})
|
||||
}
|
||||
</Text>
|
||||
<Text
|
||||
w="100%"
|
||||
h="100%"
|
||||
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="widgetTextMuted" mr={1}>
|
||||
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="text"
|
||||
bg={
|
||||
num === Number(object?.alert_level_rank)
|
||||
? "widgetInnerBadgeBg"
|
||||
: "transparent"
|
||||
}
|
||||
borderRadius={
|
||||
num === Number(object?.alert_level_rank)
|
||||
? "xl"
|
||||
: "none"
|
||||
}
|
||||
textAlign="center"
|
||||
fontSize="sm"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{num}
|
||||
</Center>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Timeline.Description>
|
||||
</Timeline.Content>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline.Root>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
else if (thisWidget?.size === "small")
|
||||
return volcano[1]?.map((domain, index) => (
|
||||
<Flex
|
||||
key={index}
|
||||
direction="row"
|
||||
w="100%"
|
||||
gap={2}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p={1}
|
||||
pl={3}
|
||||
pr={3}
|
||||
borderRadius="lg"
|
||||
bg="widgetItemBg"
|
||||
>
|
||||
<Flex
|
||||
direction="column"
|
||||
w="100%"
|
||||
minW={0}
|
||||
textAlign="left"
|
||||
color="text"
|
||||
>
|
||||
<Flex direction="row" w="100%" gap={2} align="center">
|
||||
<Flex color="iconColor">
|
||||
{domain[0] === "aviation" && <Plane size={15} />}
|
||||
{domain[0] === "terrestrial" && <Footprints size={15} />}
|
||||
{domain[0] === "maritime" && <Sailboat size={15} />}
|
||||
</Flex>
|
||||
<Text truncate fontWeight="medium">
|
||||
{volcano[0] || ""}
|
||||
</Text>
|
||||
</Flex>
|
||||
<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="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="widgetBadgeBg"
|
||||
color={
|
||||
alertColors[domain[1]?.[0]?.alert_level_name] || "text"
|
||||
}
|
||||
fontWeight="semibold"
|
||||
>
|
||||
{domain[1]?.[0]?.alert_level_name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
));
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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="transparent">
|
||||
<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="transparent">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (!apiData)
|
||||
return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -2,18 +2,26 @@ 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 VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
export default function ExplosionEvents({ settings }) {
|
||||
// 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);
|
||||
|
||||
// Clean, single-line data fetching
|
||||
const { data: apiData, isLoading, error } = VdapApi('/api/v2/explosion_events?start_date=2025-05-01&end_date=2025-05-30');
|
||||
const { data: apiData, isLoading, error } = VdapApi(url);
|
||||
|
||||
// 1. Build the Traces (Data parsing)
|
||||
const plotTraces = useMemo(() => {
|
||||
@@ -49,58 +57,46 @@ export default function ExplosionEvents({ settings }) {
|
||||
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
|
||||
hovertemplate:
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Number of Explosions: %{y}<extra></extra>'
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'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: [
|
||||
{
|
||||
@@ -111,40 +107,40 @@ export default function ExplosionEvents({ settings }) {
|
||||
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">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
<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}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
<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>
|
||||
<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">
|
||||
<Plot
|
||||
key={plotYAxisType}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={`${plotYAxisType}-${colorMode}`}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import {Button, Flex, Spinner, Text, Box, Collapsible, Highlight} from "@chakra-ui/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("Ivans settings: ", settings, "Ivans widgetID: ", widgetID);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
// 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, isLoading, error} = VdapApi(url);
|
||||
|
||||
// sort the apiData
|
||||
const some_data = useMemo(() => {
|
||||
if (data?.length === 0) return [];
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
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);
|
||||
|
||||
}, [data, settings]);
|
||||
|
||||
// copy data to clipboard in formatted way when user clicks on copy btn in widget settings
|
||||
useEffect(() => {
|
||||
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` +
|
||||
`Preparer: ${item.preparer}\n` +
|
||||
`Summary:\n${item.summary}\n`;
|
||||
}).join('\n\n');
|
||||
navigator.clipboard.writeText(dataText)
|
||||
.then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
})
|
||||
.catch((err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [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="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="transparent">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column"
|
||||
w="100%" h="100%"
|
||||
pos="relative"
|
||||
justify="top" align="center"
|
||||
p={2} gap={2}
|
||||
overflow="auto"
|
||||
borderRadius="xl"
|
||||
bg="transparent" // Let the global widgetRecipe glass shell show through
|
||||
>
|
||||
{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"
|
||||
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="widgetItemBg"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
|
||||
<Text truncate color="text" fontWeight="medium">{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>
|
||||
</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="widgetItemExpandedBg">
|
||||
<Flex direction="column" gap={2} borderRadius="lg" p={2} align="start">
|
||||
<Text w="fit" fontSize="sm" color="text" p={1} bg="widgetInnerBadgeBg" borderRadius="md">Preparer: {item?.preparer}</Text>
|
||||
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
|
||||
</Flex>
|
||||
{item?.files?.map((file, index) => {
|
||||
return (
|
||||
<Box w="100%" h="100%" key={index} >
|
||||
<Flex direction="row" w="100%">
|
||||
<Text w="100%" p={1} fontWeight="semibold" bg="widgetInnerBadgeBg" borderLeftRadius="lg" fontSize="sm" color="text">File: </Text>
|
||||
<a href={`${file?.url}#view=FitV&toolbar=0`} target="_blank" rel="noopener noreferrer">
|
||||
<Text truncate w="100%" p={1} bg="widgetInnerBadgeBg" borderRightRadius="lg" fontSize="sm" color="text">
|
||||
{file?.name}
|
||||
</Text>
|
||||
</a>
|
||||
</Flex>
|
||||
<object
|
||||
data={`${file?.url}#view=FitH&toolbar=1`}
|
||||
type="application/pdf"
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
>
|
||||
<Flex direction="column" w="100%" h="100%" align="center" p={4} mt={1} color="widgetTextMuted" bg="widgetInnerBadgeBg" borderRadius="lg">
|
||||
<Text mb={2} fontSize="xs" color="red.300">
|
||||
Your browser cannot display this PDF directly.
|
||||
</Text>
|
||||
<a href={file?.url} target="_blank" bg="widgetBadgeBg" rel="noopener noreferrer">
|
||||
<Text fontSize="xs" bg="widgetBadgeBg" borderRadius="md" p={1}>Download PDF Instead</Text>
|
||||
</a>
|
||||
</Flex>
|
||||
</object>
|
||||
</Box>
|
||||
)})}
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ascTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
else return ascTimeSort(a, b);
|
||||
}
|
||||
|
||||
function descTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
else return descTimeSort(a, b);
|
||||
}
|
||||
|
||||
function ascTimeSort(a, b) {
|
||||
const aTime = new Date(a.send_time);
|
||||
const bTime = new Date(b.send_time);
|
||||
|
||||
if (aTime > bTime) return 1;
|
||||
if (aTime < bTime) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function descTimeSort(a, b) {
|
||||
const aTime = new Date(a.send_time);
|
||||
const bTime = new Date(b.send_time);
|
||||
|
||||
if (aTime > bTime) return -1;
|
||||
if (aTime < bTime) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { http, HttpResponse } from 'msw'; // Import MSW
|
||||
import IvansAlerts from './IvansAlerts';
|
||||
|
||||
export default {
|
||||
title: 'Dashboard Widgets/IVANS Activity',
|
||||
component: IvansAlerts,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box
|
||||
w="100%" maxW="1800px" h="400px"
|
||||
border="2px dashed" borderColor="gray.600" p={2}
|
||||
resize="both" overflow="auto"
|
||||
>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
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: 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: 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Scenario LIVE: call our API for real
|
||||
export const LivePopulated = {};
|
||||
|
||||
// Scenario 1: Successful API Call
|
||||
export const Populated = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
// Intercept the exact URL your component is trying to fetch
|
||||
http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
|
||||
// Return a 200 OK with the mock JSON
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Scenario 2: The 500 Server Crash
|
||||
export const ServerCrash = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
|
||||
// Return a 500 Error
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Scenario 3: Empty State (API works, but no data returned)
|
||||
export const EmptyData = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
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 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);
|
||||
|
||||
// Retrieve data from api
|
||||
const { data: apiData, isLoading, error } = VdapApi(url);
|
||||
|
||||
const plotTraces = useMemo(() => {
|
||||
if (!apiData || apiData.length === 0) return [];
|
||||
|
||||
// Array of distinct Plotly shapes
|
||||
const Symbolway = [
|
||||
'circle', 'square', 'diamond',
|
||||
'triangle-up', 'triangle-down',
|
||||
'star', 'hexagram', 'pentagon',
|
||||
'hexagon', 'octagon', 'cross', 'x'
|
||||
];
|
||||
|
||||
// Sort data descending by plume height so smaller dots draw on top of larger ones
|
||||
const sortedData = [...apiData].sort((a, b) => b.h_avs_m - a.h_avs_m);
|
||||
|
||||
// Efficiently group into Plotly traces by volcano
|
||||
const groupedData = sortedData.reduce((acc, curr) => {
|
||||
const name = curr.volcano_name || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { x: [], y: [] };
|
||||
|
||||
// Map the explosion-specific keys
|
||||
acc[name].x.push(new Date(curr.ts_epoch));
|
||||
acc[name].y.push(curr.h_avs_m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(groupedData).map((volcanoName, index) => ({
|
||||
name: volcanoName,
|
||||
x: groupedData[volcanoName].x,
|
||||
y: groupedData[volcanoName].y,
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: plotColors.text },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
hovertemplate:
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Plume Height (m): %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData, plotColors]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: plotColors.muted },
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.grid,
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Plume Height',
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'linear',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.text,
|
||||
automargin: true,
|
||||
},
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: 0,
|
||||
x1: 1,
|
||||
xref: 'paper',
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: plotColors.text, width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, [plotColors]);
|
||||
|
||||
if (isLoading) return (
|
||||
<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="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="transparent">
|
||||
<Text color="widgetTextMuted">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={colorMode}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/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 RemoteSensing({ settings, widgetID }) {
|
||||
console.log("RS settings: ", settings, "RS ID: ", widgetID);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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);
|
||||
|
||||
// sort apiData
|
||||
const data = useMemo(() => {
|
||||
if (apiData?.length === 0) return [];
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
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);
|
||||
|
||||
}, [apiData, settings]);
|
||||
|
||||
// check if user wants to copy all RS
|
||||
useEffect(() => {
|
||||
if(settings?.copyToClipboard === true) {
|
||||
const dataText = data.map((item) => {
|
||||
return `${item.country}, ${item.volcano_name}\n` +
|
||||
`Report Time: ${item.report_time}\n` +
|
||||
`Sender: ${item.sender}\n` +
|
||||
`Summary:\n${item.summary}\n`;
|
||||
}).join('\n\n');
|
||||
navigator.clipboard.writeText(dataText)
|
||||
.then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
})
|
||||
.catch((err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [settings, data, widgetID, updateSettings]);
|
||||
|
||||
if (isLoading) return (
|
||||
<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} bg="transparent">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data.length === 0) return (
|
||||
<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" bg="transparent">
|
||||
{data.map((item, index) => {
|
||||
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="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>
|
||||
</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="widgetItemExpandedBg">
|
||||
<Flex direction="column" gap={2} 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) => {
|
||||
|
||||
// PDF FILE TYPE
|
||||
if (file?.type === "pdf") return (
|
||||
<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="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="widgetTextMuted">
|
||||
<Text mb={2} color="fg.muted">
|
||||
Your browser cannot display this PDF directly.
|
||||
</Text>
|
||||
<a href={file?.url} target="_blank">
|
||||
Download PDF
|
||||
</a>
|
||||
</Flex>
|
||||
</object>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// IMAGE FILE TYPE
|
||||
else if (file?.type === "image") return (
|
||||
<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="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>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// DOWNLOADABLE LINK
|
||||
else if (file?.type === "download") return (
|
||||
<>
|
||||
<br />
|
||||
<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>
|
||||
</Collapsible.Content>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
else return ascTimeSort(a, b);
|
||||
}
|
||||
|
||||
function descTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
else return descTimeSort(a, b);
|
||||
}
|
||||
|
||||
function ascTimeSort(a, b) {
|
||||
const aTime = new Date(a.report_time);
|
||||
const bTime = new Date(b.report_time);
|
||||
|
||||
if (aTime > bTime) return 1;
|
||||
if (aTime < bTime) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function descTimeSort(a, b) {
|
||||
const aTime = new Date(a.report_time);
|
||||
const bTime = new Date(b.report_time);
|
||||
|
||||
if (aTime > bTime) return -1;
|
||||
if (aTime < bTime) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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 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);
|
||||
|
||||
// retrieve data from api
|
||||
const { data: apiData, isLoading, error } = VdapApi(url);
|
||||
|
||||
const plotTraces = useMemo(() => {
|
||||
if (!apiData || apiData.length === 0) return [];
|
||||
|
||||
// Array of distinct Plotly shapes
|
||||
const Symbolway = [
|
||||
'circle', 'square', 'diamond',
|
||||
'triangle-up', 'triangle-down',
|
||||
'star', 'hexagram', 'pentagon',
|
||||
'hexagon', 'octagon', 'cross', 'x'
|
||||
];
|
||||
|
||||
// Sort data descending by seismic events count so smaller dots draw on top of larger ones
|
||||
const sortedData = [...apiData].sort((a, b) => b.number_events - a.number_events);
|
||||
|
||||
// Efficiently group into Plotly traces by volcano
|
||||
const groupedData = sortedData.reduce((acc, curr) => {
|
||||
const name = curr.volcano_name || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { x: [], y: [] };
|
||||
|
||||
// Map the seismic-specific keys
|
||||
acc[name].x.push(new Date(curr.ts_epoch));
|
||||
acc[name].y.push(curr.number_events);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(groupedData).map((volcanoName, index) => ({
|
||||
name: volcanoName,
|
||||
x: groupedData[volcanoName].x,
|
||||
y: groupedData[volcanoName].y,
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: plotColors.text },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
hovertemplate:
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Number of Seismic Events: %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData, plotColors]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: plotColors.muted },
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.grid,
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Seismic Count',
|
||||
font: { color: plotColors.text, size: 12 }
|
||||
},
|
||||
type: 'linear',
|
||||
gridcolor: plotColors.grid,
|
||||
zerolinecolor: plotColors.text,
|
||||
automargin: true,
|
||||
},
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: 0,
|
||||
x1: 1,
|
||||
xref: 'paper', // Spans entire chart width
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: plotColors.text, width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, [plotColors]);
|
||||
|
||||
if (isLoading) return (
|
||||
<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="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="transparent">
|
||||
<Text color="var(--chakra-colors-widgetTextMuted)">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={colorMode}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
// 2. Assign the active palette based on the current mode
|
||||
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
|
||||
|
||||
export default function VaaActivity7Day({ settings }) {
|
||||
console.log("3. Chart received settings:", settings);
|
||||
// Plotly expects 'linear' for normal axes
|
||||
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
|
||||
|
||||
// Clean, single-line data fetching
|
||||
@@ -17,13 +24,10 @@ export default function VaaActivity7Day({ settings }) {
|
||||
|
||||
// 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,102 +48,90 @@ export default function VaaActivity7Day({ settings }) {
|
||||
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
|
||||
hovertemplate:
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'Date: %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Flight Level: %{y}<extra></extra>'
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'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">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
<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}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
<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>
|
||||
<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">
|
||||
<Plot
|
||||
key={plotYAxisType}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
|
||||
<Plot
|
||||
key={`${plotYAxisType}-${colorMode}`}
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
Flex,
|
||||
Slider,
|
||||
Switch,
|
||||
Text,
|
||||
Field,
|
||||
Fieldset,
|
||||
Input,
|
||||
CheckboxCard,
|
||||
CheckboxGroup,
|
||||
Combobox,
|
||||
Portal,
|
||||
useFilter,
|
||||
useListCollection,
|
||||
Wrap,
|
||||
Badge, Separator, HStack, useSlotRecipe
|
||||
} from "@chakra-ui/react";
|
||||
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
export default function AlertChangeSettings({ widgetId, currentSettings }) {
|
||||
// STYLING
|
||||
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
|
||||
const popoverStyles = popoverRecipe();
|
||||
|
||||
// STATE MANAGEMENT
|
||||
const updateSettings = useDashboardStore(
|
||||
(state) => state.updateWidgetSettings,
|
||||
);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
const [selectedVolcanoes, setSelectedVolcanoes] = useState([]);
|
||||
const { startsWith } = useFilter({ sensitivity: "base" });
|
||||
|
||||
const { collection, filter, reset } = useListCollection({
|
||||
initialItems: volcanoes,
|
||||
filter: startsWith,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
// FUNCTIONS
|
||||
const handleValueChange = (details) => {
|
||||
setSelectedVolcanoes(details.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="column" pb={2} overflowY="auto">
|
||||
<Fieldset.Root>
|
||||
<Fieldset.Content>
|
||||
{/* SELECTING DATE RANGE */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Date Range Selection</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Flex direction="column" w="100%" h="100%" justify="center" gap={2} pr={1}>
|
||||
{/* start date */}
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%"
|
||||
h="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text w="50%" align="center" justify="center" color="text">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
type="date"
|
||||
css={popoverStyles.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>
|
||||
|
||||
{/* end date */}
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%"
|
||||
h="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text w="50%" align="center" justify="center" color="text">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
type="date"
|
||||
css={popoverStyles.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>
|
||||
|
||||
{/* FILTERS */}
|
||||
<CheckboxGroup w="100%">
|
||||
<Flex direction="column" w="100%" gap={2}>
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Filters</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
|
||||
{/* level increases */}
|
||||
<CheckboxCard.Root
|
||||
css={popoverStyles.checkboxcard}
|
||||
|
||||
defaultChecked={currentSettings?.filters?.levelIncreases}
|
||||
onCheckedChange={(e) => {
|
||||
updateSettings(widgetId, {
|
||||
...currentSettings,
|
||||
filters: {
|
||||
...currentSettings.filters,
|
||||
levelIncreases: e.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CheckboxCard.HiddenInput /> {/* when removed can't check/uncheck box */}
|
||||
<CheckboxCard.Control>
|
||||
<CheckboxCard.Content>
|
||||
<CheckboxCard.Label fontWeight={400}>
|
||||
Level Increases
|
||||
</CheckboxCard.Label>
|
||||
</CheckboxCard.Content>
|
||||
<CheckboxCard.Indicator css={popoverStyles.checkbox} />
|
||||
</CheckboxCard.Control>
|
||||
</CheckboxCard.Root>
|
||||
|
||||
{/* level decreases */}
|
||||
<CheckboxCard.Root
|
||||
css={popoverStyles.checkboxcard}
|
||||
|
||||
defaultChecked={currentSettings?.filters?.levelDecreases}
|
||||
onCheckedChange={(e) => {
|
||||
updateSettings(widgetId, {
|
||||
...currentSettings,
|
||||
filters: {
|
||||
...currentSettings.filters,
|
||||
levelDecreases: e.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CheckboxCard.HiddenInput />
|
||||
<CheckboxCard.Control>
|
||||
<CheckboxCard.Content>
|
||||
<CheckboxCard.Label fontWeight={400}>
|
||||
Level Decreases
|
||||
</CheckboxCard.Label>
|
||||
</CheckboxCard.Content>
|
||||
<CheckboxCard.Indicator css={popoverStyles.checkbox} />
|
||||
</CheckboxCard.Control>
|
||||
</CheckboxCard.Root>
|
||||
|
||||
{/* elevated levels */}
|
||||
<CheckboxCard.Root
|
||||
css={popoverStyles.checkboxcard}
|
||||
|
||||
defaultChecked={currentSettings?.filters?.elevatedLevels}
|
||||
onCheckedChange={(e) => {
|
||||
updateSettings(widgetId, {
|
||||
...currentSettings,
|
||||
filters: {
|
||||
...currentSettings.filters,
|
||||
elevatedLevels: e.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CheckboxCard.HiddenInput />
|
||||
<CheckboxCard.Control>
|
||||
<CheckboxCard.Content>
|
||||
<CheckboxCard.Label fontWeight={400}>
|
||||
Only Elevated Levels
|
||||
</CheckboxCard.Label>
|
||||
</CheckboxCard.Content>
|
||||
<CheckboxCard.Indicator css={popoverStyles.checkbox}/>
|
||||
</CheckboxCard.Control>
|
||||
</CheckboxCard.Root>
|
||||
</Flex>
|
||||
</CheckboxGroup>
|
||||
|
||||
{/* select volcano to filter on */}
|
||||
<Combobox.Root
|
||||
mt={1}
|
||||
variant="subtle"
|
||||
multiple
|
||||
openOnClick
|
||||
collection={collection}
|
||||
onValueChange={handleValueChange}
|
||||
onInputValueChange={(e) => filter(e.inputValue)}
|
||||
>
|
||||
|
||||
<Wrap gap="2">
|
||||
{selectedVolcanoes.map((volcano) => (
|
||||
<Badge key={volcano}>{volcano}</Badge>
|
||||
))}
|
||||
</Wrap>
|
||||
|
||||
<Combobox.Label fontSize="xs" >Select Volcano (s)</Combobox.Label>
|
||||
|
||||
<Combobox.Control>
|
||||
<Combobox.Input placeholder="Type to search" borderRadius="lg"/>
|
||||
<Combobox.IndicatorGroup>
|
||||
<Combobox.ClearTrigger />
|
||||
<Combobox.Trigger onClick={reset} />
|
||||
</Combobox.IndicatorGroup>
|
||||
</Combobox.Control>
|
||||
<Portal>
|
||||
<Combobox.Positioner>
|
||||
<Combobox.Content>
|
||||
{volcanoes.map((item) => (
|
||||
<Combobox.Item item={item} key={item.value}>
|
||||
{item.label}
|
||||
<Combobox.ItemIndicator />
|
||||
</Combobox.Item>
|
||||
))}
|
||||
<Combobox.Empty>No items found</Combobox.Empty>
|
||||
</Combobox.Content>
|
||||
</Combobox.Positioner>
|
||||
</Portal>
|
||||
</Combobox.Root>
|
||||
</Fieldset.Content>
|
||||
</Fieldset.Root>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const volcanoes = [
|
||||
{ label: "Mt. St. Helens", value: "helens" },
|
||||
{ label: "Mt. Adams", value: "adams" },
|
||||
{ label: "Rainier", value: "rainier" },
|
||||
{ label: "The Three Sisters", value: "three sisters" },
|
||||
{ label: "Fuego", value: "fuego" },
|
||||
{ label: "Laki Laki", value: "lakilaki" },
|
||||
{ label: "Lewotobi", value: "lewotobi" },
|
||||
{ label: "Karangatang", value: "karangatang" },
|
||||
{ label: "Cotopaxi", value: "cotopaxi" },
|
||||
{ label: "Merapi", value: "merapi" },
|
||||
{ label: "Marapi", value: "marapi" },
|
||||
{ label: "Kick 'Em Jenny", value: "kick em jenny"}
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
import {Button, Field, Flex, Input, Switch, Text, Fieldset, Separator, HStack, useSlotRecipe} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function CommCoorReportSettings({ widgetId, currentSettings }) {
|
||||
// STYLING
|
||||
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
|
||||
const popoverStyles = popoverRecipe();
|
||||
|
||||
// STATE MANAGEMENT
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
// FUNCTIONS
|
||||
const handleCopyToClipboard = () => {
|
||||
updateSettings(widgetId, {copyToClipboard: true});
|
||||
}
|
||||
|
||||
const handleDownloadReport = () => {
|
||||
updateSettings(widgetId, {downloadReport: true});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" gap={2} overflowY="auto">
|
||||
<Fieldset.Root>
|
||||
<Fieldset.Content>
|
||||
{/* SELECTING DATE RANGE */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Date Range Selection</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Flex direction="column" w="100%" h="100%" justify="center" gap={2} pr={1}>
|
||||
{/* start date */}
|
||||
<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="text">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
css={popoverStyles.date}
|
||||
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>
|
||||
{/* end date */}
|
||||
<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="text">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
css={popoverStyles.date}
|
||||
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>
|
||||
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Tools</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Flex direction="column" w="100%" h="100%" justify="center" gap={2}>
|
||||
{/* COPY TO CLIPBOARD */}
|
||||
<Button css={popoverStyles.button} onClick={handleCopyToClipboard}>Copy Report</Button>
|
||||
{/* DOWNLOAD REPORT */}
|
||||
<Button css={popoverStyles.button} onClick={handleDownloadReport}>Download Report</Button>
|
||||
</Flex>
|
||||
</Fieldset.Content>
|
||||
</Fieldset.Root>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Box, Text, Switch } from "@chakra-ui/react";
|
||||
import {Box, Text, Switch, Flex, Field, Input} from "@chakra-ui/react";
|
||||
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
// 1. Convert the saved string into a boolean for the toggle UI
|
||||
const isSymlog = currentSettings?.yAxisScale === 'log';
|
||||
@@ -18,20 +21,75 @@ export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3} fontSize="sm">Y-Axis Settings</Text>
|
||||
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
|
||||
{/* Y-AXIS SETTING*/}
|
||||
<Text fontWeight="bold" mb={3} fontSize="sm">Y-Axis Settings</Text>
|
||||
|
||||
<Switch.Root
|
||||
checked={isSymlog}
|
||||
onCheckedChange={handleToggle}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Use Log Scale</Switch.Label>
|
||||
</Switch.Root>
|
||||
</Box>
|
||||
<Switch.Root
|
||||
checked={isSymlog}
|
||||
onCheckedChange={handleToggle}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Use Log Scale</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
{/* DATE RANGE SELECTION */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Button,
|
||||
Field,
|
||||
Flex,
|
||||
Input,
|
||||
Switch,
|
||||
Text,
|
||||
Fieldset,
|
||||
CheckboxCard,
|
||||
Separator,
|
||||
HStack,
|
||||
useSlotRecipe
|
||||
} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "../../../store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
|
||||
// STYLING
|
||||
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
|
||||
const popoverStyles = popoverRecipe();
|
||||
|
||||
// STATE MANAGEMENT
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
// FUNCTIONS
|
||||
const handleCopyToClipboard = () => {
|
||||
updateSettings(widgetId, {copyToClipboard: true});
|
||||
}
|
||||
const handleVolcanoSort = (details) => {
|
||||
const isSort = currentSettings?.activeSorts?.volcanoSort === true;
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, volcanoSort: !isSort }});
|
||||
}
|
||||
const handleTimeSort = (details) => {
|
||||
const sort = currentSettings?.activeSorts?.timeSort === "desc" ? "asc" : "desc";
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, timeSort: sort }});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" overflowY="auto">
|
||||
<Fieldset.Root>
|
||||
<Fieldset.Content>
|
||||
{/* SELECTING DATE RANGE */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Date Range Selection</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Flex direction="column" w="100%" h="100%" justify="center" gap={2} pr={1}>
|
||||
{/* start date */}
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%"
|
||||
h="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text w="50%" align="center" justify="center" color="text">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
type="date"
|
||||
w="100%"
|
||||
borderRadius="lg"
|
||||
css={popoverStyles.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>
|
||||
|
||||
{/* end date */}
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%"
|
||||
h="100%"
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Text w="50%" align="center" justify="center" color="text">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
type="date"
|
||||
w="100%"
|
||||
borderRadius="lg"
|
||||
css={popoverStyles.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>
|
||||
|
||||
{/* SORTS */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Sorts</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
|
||||
{/* time sort */}
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.timeSort === "asc"}
|
||||
onCheckedChange={handleTimeSort}
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control css={popoverStyles.switch}>
|
||||
<Switch.Thumb bg="white"/>
|
||||
</Switch.Control>
|
||||
<Switch.Label fontWeight={400}>Oldest to Newest</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
{/* alphabetic sort */}
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.volcanoSort}
|
||||
onCheckedChange={handleVolcanoSort}
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control css={popoverStyles.switch}>
|
||||
<Switch.Thumb bg="white" />
|
||||
</Switch.Control>
|
||||
<Switch.Label fontWeight={400}>Alphabetically by Volcano</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Filters</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
|
||||
{/* elevated levels */}
|
||||
<CheckboxCard.Root
|
||||
// size="sm"
|
||||
// variant="surface"
|
||||
css={popoverStyles.checkboxcard}
|
||||
|
||||
defaultChecked={currentSettings?.filters?.elevatedLevels}
|
||||
onCheckedChange={(e) => {
|
||||
updateSettings(widgetId, {
|
||||
...currentSettings,
|
||||
filters: {
|
||||
...currentSettings.filters,
|
||||
elevatedLevels: e.checked,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<CheckboxCard.HiddenInput />
|
||||
<CheckboxCard.Control>
|
||||
<CheckboxCard.Content>
|
||||
<CheckboxCard.Label fontWeight={400}>
|
||||
Only Elevated Levels
|
||||
</CheckboxCard.Label>
|
||||
</CheckboxCard.Content>
|
||||
<CheckboxCard.Indicator css={popoverStyles.checkbox} />
|
||||
</CheckboxCard.Control>
|
||||
</CheckboxCard.Root>
|
||||
|
||||
{/* COPY TO CLIPBOARD */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Tools</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Button mt={-2} w="100%" size="xs" borderRadius="lg" variant="subtle"
|
||||
css={popoverStyles.button}
|
||||
onClick={handleCopyToClipboard}
|
||||
>
|
||||
Copy All IVANS
|
||||
</Button>
|
||||
</Fieldset.Content>
|
||||
</Fieldset.Root>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Input, Field, Text, Flex} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function PlumeEventsSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import {Button, Field, Flex, HStack, Input, Separator, Switch, Text, useSlotRecipe, Fieldset} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function RemoteSensingSettings({ widgetId, currentSettings }) {
|
||||
// STYLING
|
||||
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
|
||||
const popoverStyles = popoverRecipe();
|
||||
|
||||
// STATE MANAGEMENT
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
// FUNCTIONS
|
||||
const handleCopyToClipboard = () => {
|
||||
updateSettings(widgetId, {copyToClipboard: true});
|
||||
}
|
||||
const handleVolcanoSort = (details) => {
|
||||
const isSort = currentSettings?.activeSorts?.volcanoSort === true;
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, volcanoSort: !isSort }});
|
||||
}
|
||||
const handleTimeSort = (details) => {
|
||||
const sort = currentSettings?.activeSorts?.timeSort === "desc" ? "asc" : "desc";
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, timeSort: sort }});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" gap={2} overflowY="auto">
|
||||
<Fieldset.Root>
|
||||
<Fieldset.Content>
|
||||
{/* SELECTING DATE RANGE */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Date Range Selection</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Flex direction="column" w="100%" h="100%" justify="center" gap={2} pr={1}>
|
||||
{/* start date */}
|
||||
<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="text">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
type="date"
|
||||
css={popoverStyles.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>
|
||||
|
||||
{/* end date */}
|
||||
<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="text">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
type="date"
|
||||
css={popoverStyles.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>
|
||||
|
||||
{/* SORTING */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Sorting</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.timeSort === "asc"}
|
||||
onCheckedChange={handleTimeSort}
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control css={popoverStyles.switch}>
|
||||
<Switch.Thumb bg="white" />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Oldest to Newest</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.volcanoSort}
|
||||
onCheckedChange={handleVolcanoSort}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control css={popoverStyles.switch}>
|
||||
<Switch.Thumb bg="white"/>
|
||||
</Switch.Control>
|
||||
<Switch.Label>By Volcano</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
{/* COPYING TO CLIPBOARD */}
|
||||
<HStack>
|
||||
<Text flexShrink="0" fontWeight="semibold">Tools</Text>
|
||||
<Separator flex="1" size="sm"/> {/*line seperator*/}
|
||||
</HStack>
|
||||
<Button css={popoverStyles.button} onClick={handleCopyToClipboard}>
|
||||
Copy All RS
|
||||
</Button>
|
||||
</Fieldset.Content>
|
||||
</Fieldset.Root>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {Input, Field, Text, Flex} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SeismicEventsSettings({ widgetId, currentSettings }) {
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -31,20 +31,23 @@ export function SettingsButton() {
|
||||
const cancelEdit = useDashboardStore(state => state.cancelEdit);
|
||||
const addWidget = useDashboardStore(state => state.addWidget);
|
||||
|
||||
const recipe = useRecipe({key: "glassTab"});
|
||||
const tabStyles = recipe();
|
||||
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 panelRecipe = useSlotRecipe({key: "glassPanel"});
|
||||
const panelStyles = panelRecipe({layout: "dropdown"});
|
||||
const modalStyles = panelRecipe({layout: "popover"});
|
||||
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 [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const checkScroll = useCallback(() => {
|
||||
if (tabsRef.current) {
|
||||
@@ -92,27 +95,17 @@ export function SettingsButton() {
|
||||
<>
|
||||
{isEditing ? (
|
||||
// 📝 EDIT MODE: The 3-Pill Layout
|
||||
<HStack gap={2}>
|
||||
<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)"}}
|
||||
<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>
|
||||
|
||||
{/* 2. The Save Button */}
|
||||
<Box
|
||||
as="button"
|
||||
css={tabStyles}
|
||||
data-state="open"
|
||||
onClick={saveEdit}
|
||||
color={{base: "green.600", _dark: "green.400"}}
|
||||
<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}}/>
|
||||
@@ -120,12 +113,7 @@ export function SettingsButton() {
|
||||
</Box>
|
||||
|
||||
{/* 3. The Discard Button */}
|
||||
<Box
|
||||
as="button"
|
||||
css={tabStyles}
|
||||
data-state="open"
|
||||
onClick={cancelEdit}
|
||||
color={{base: "red.500", _dark: "red.400"}}
|
||||
<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}}/>
|
||||
@@ -137,14 +125,14 @@ export function SettingsButton() {
|
||||
<Menu.Root positioning={{getAnchorRect}}>
|
||||
<Menu.Trigger asChild>
|
||||
<Box ref={menuRef} as="button" css={tabStyles}>
|
||||
{/* size increased to 24 to match tabs. flexShrink: 0 is CRITICAL */}
|
||||
{/* 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={panelStyles.root} zIndex="dropdown" minW="200px">
|
||||
<Menu.Content css={popoverStyles.content} zIndex="dropdown" minW="200px">
|
||||
<Menu.Item
|
||||
onClick={enterEditMode}
|
||||
cursor="pointer"
|
||||
@@ -174,136 +162,53 @@ export function SettingsButton() {
|
||||
<Dialog.Root
|
||||
open={isLibraryOpen}
|
||||
onOpenChange={(e) => setIsLibraryOpen(e.open)}
|
||||
size="xl"
|
||||
placement="center"
|
||||
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 css={dialogStyles.content}>
|
||||
|
||||
<Dialog.Content css={modalStyles.root} overflow="hidden" maxW="60%" minH="80%" p={0}>
|
||||
|
||||
<Dialog.Header bg="transparent" color={{base: "gray.800", _dark: "white"}} py={4}
|
||||
borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}>
|
||||
<Dialog.Title fontSize="xl" fontWeight="bold">Widgets Shop</Dialog.Title>
|
||||
<Dialog.Header css={dialogStyles.header}>
|
||||
<Dialog.Title>Widgets Shop</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body p={0}>
|
||||
<Dialog.Body css={dialogStyles.body}>
|
||||
<Tabs.Root defaultValue={categories[0]} variant="unstyled">
|
||||
<Box position="relative" display="flex" mx="auto" maxWidth="100%" mx={5} mt={4} mb={6}>
|
||||
|
||||
{/* LEFT GLASS CRESCENT */}
|
||||
{/* HORIZONTAL SCROLLABLE MENU OF WIDGET CATEGORIES */}
|
||||
<Box>
|
||||
{/* left glass crescent */}
|
||||
{canScrollLeft && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
left="0" top="0" bottom="0"
|
||||
w="50px"
|
||||
align="center" justify="center"
|
||||
zIndex={2}
|
||||
cursor="pointer"
|
||||
onClick={() => tabsRef.current.scrollBy({left: -200, behavior: 'smooth'})}
|
||||
bg={{base: "rgba(255, 255, 255, 0.7)", _dark: "rgba(30, 30, 30, 0.8)"}}
|
||||
backdropFilter="blur(1px)"
|
||||
borderTopLeftRadius="full"
|
||||
borderBottomLeftRadius="full"
|
||||
|
||||
// THE MAGIC: Bites a transparent circle out of the flat right side!
|
||||
WebkitMaskImage="radial-gradient(circle at 100% 50%, transparent 30px, black 19px)"
|
||||
maskImage="radial-gradient(circle at 100% 50%, transparent 30px, black 35px)"
|
||||
|
||||
color={{base: "gray.600", _dark: "gray.300"}}
|
||||
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)"}
|
||||
}}
|
||||
>
|
||||
{/* Shifted slightly left to optically center it in the crescent mass */}
|
||||
<ChevronLeft size={18} style={{ marginLeft: "-30px" }}/>
|
||||
<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>
|
||||
)}
|
||||
<Tabs.List
|
||||
ref={tabsRef}
|
||||
onScroll={checkScroll}
|
||||
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"
|
||||
maxWidth="100%"
|
||||
border="1px solid"
|
||||
borderColor={{base: "blackAlpha.50", _dark: "whiteAlpha.100"}}
|
||||
css={{
|
||||
scrollbarWidth: "none",
|
||||
"&::-webkit-scrollbar": {display: "none"},
|
||||
}}
|
||||
// This is for desktop users not on trackpad to scroll the
|
||||
onWheel={(e) => {
|
||||
if (e.currentTarget) {
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
|
||||
{/* 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}
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<Tabs.Trigger key={cat} value={cat}>
|
||||
{cat}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{/* right glass crescent */}
|
||||
{canScrollRight && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
right="0" top="0" bottom="0"
|
||||
w="50px"
|
||||
align="center" justify="center"
|
||||
zIndex={2}
|
||||
cursor="pointer"
|
||||
onClick={() => tabsRef.current.scrollBy({left: 200, behavior: 'smooth'})}
|
||||
bg={{base: "rgba(255, 255, 255, 0.7)", _dark: "rgba(30, 30, 30, 0.8)"}}
|
||||
backdropFilter="blur(1px)"
|
||||
borderTopRightRadius="full"
|
||||
borderBottomRightRadius="full"
|
||||
|
||||
// THE MAGIC: Bites a transparent circle out of the flat left side!
|
||||
WebkitMaskImage="radial-gradient(circle at 0% 50%, transparent 30px, black 19px)"
|
||||
maskImage="radial-gradient(circle at 0% 50%, transparent 30px, black 35px)"
|
||||
|
||||
color={{base: "gray.600", _dark: "gray.300"}}
|
||||
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)"}
|
||||
}}
|
||||
>
|
||||
<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}>
|
||||
@@ -344,7 +249,6 @@ export function SettingsButton() {
|
||||
h="24px"
|
||||
flexShrink={0}
|
||||
borderRadius="full"
|
||||
bg={{base: "blue.50", _dark: "whiteAlpha.200"}}
|
||||
fontWeight="black"
|
||||
fontSize="xs"
|
||||
bg='transparent'
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -9,27 +8,30 @@ import {SearchBar} from "@/components/dashboard/SearchBar.jsx";
|
||||
|
||||
// Accept a 'user' prop now, fallback to placeholder if empty
|
||||
export default function Header() {
|
||||
const recipe = useRecipe({ key: "header" });
|
||||
const styles = recipe();
|
||||
const recipe = useSlotRecipe({key: "header"});
|
||||
const styles = recipe();
|
||||
|
||||
const { user } = useAuthStore();
|
||||
console.log(user);
|
||||
const displayName = user ? (user.firstName || user.username) : "Loading...";
|
||||
const avatarname = user ? (user.fullName || user.username) : "Loading...";
|
||||
const {user} = useAuthStore();
|
||||
console.log(user);
|
||||
const displayName = user ? (user.firstName || user.username) : "Loading...";
|
||||
const avatarname = user ? (user.fullName || user.username) : "Loading...";
|
||||
|
||||
const displayEmail = user?.email || "LDAP User";
|
||||
const displayEmail = user?.email || "LDAP User";
|
||||
|
||||
return (
|
||||
<Flex css={styles} >
|
||||
<SearchBar placeholder="Enter a volcano to search" size="sm" width="410px" />
|
||||
{/* User Profile Section */}
|
||||
<Flex align="center" gap={3} pr={6}>
|
||||
<Text fontWeight="bold" textStyle="md" >{displayName}</Text>
|
||||
<UserAvatarMenu
|
||||
name={avatarname}
|
||||
// avatar={user.avatar}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
return (
|
||||
<Flex css={styles.root}>
|
||||
<Flex css={styles.search_bar}>
|
||||
<SearchBar placeholder="Enter a volcano to search"/>
|
||||
</Flex>
|
||||
|
||||
{/* User Profile Section */}
|
||||
<Flex css={styles.user_info}>
|
||||
<Text>{displayName}</Text>
|
||||
<UserAvatarMenu
|
||||
name={avatarname}
|
||||
// avatar={user.avatar}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import {useSlotRecipe, Box, Button, IconButton} from "@chakra-ui/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 {useState} from "react";
|
||||
import {NavLink} from "react-router-dom";
|
||||
import Tooltip from "../ui/Tooltip.jsx";
|
||||
import {Mountain, House, Earth, MapPin, ShieldUser, Menu, PanelLeftClose, PanelLeftOpen} from 'lucide-react';
|
||||
import {useState} from "react";
|
||||
|
||||
const SIDEBAR_DEFAULT_BUTTONS = [
|
||||
{icon: House, name: "Home", url: "/home"},
|
||||
@@ -14,40 +13,28 @@ const SIDEBAR_DEFAULT_BUTTONS = [
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
|
||||
const recipe = useSlotRecipe({key: "sidebar"});
|
||||
const styles = recipe({state: isOpen ? "open" : "closed"});
|
||||
|
||||
// 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 visibleButtons = SIDEBAR_DEFAULT_BUTTONS; // TODO: call btns here according to users authorization
|
||||
|
||||
const toggleSidebar = () => setIsOpen((prev) => !prev);
|
||||
|
||||
return (
|
||||
<Box css={styles.root}>
|
||||
{/* Home Link Logo */}
|
||||
<Button
|
||||
as={NavLink}
|
||||
to="/home"
|
||||
css={styles.logo}
|
||||
variant="plain"
|
||||
>
|
||||
{isOpen ? "🌋 VDAP" : "🌋"}
|
||||
{/* 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 (
|
||||
<Button
|
||||
as={NavLink}
|
||||
to={button.url}
|
||||
css={styles.buttons}
|
||||
>
|
||||
{/* Navigation buttons are [icon text] */}
|
||||
<Button as={NavLink} to={button.url} css={styles.buttons}> {/* Navigation buttons are [icon text] */}
|
||||
<Icon/>
|
||||
<Box as="span" css={styles.button_text}>
|
||||
{button.name}
|
||||
@@ -55,21 +42,16 @@ export default function Sidebar() {
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Box css={styles.footer}>
|
||||
{/* Menu Trigger */}
|
||||
<IconButton
|
||||
aria-label="Toggle Sidebar"
|
||||
variant="plain"
|
||||
size="md"
|
||||
onClick={toggleSidebar}
|
||||
css={styles.collapse_button}
|
||||
>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Theme Toggler */}
|
||||
<ColorModeButton _hover={{ bg: "transparent" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Flex css={styles.color_mode_button}>
|
||||
<ColorModeButton />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,13 @@ 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
|
||||
|
||||
export const WIDGET_REGISTRY = {
|
||||
"vaa_activity": {
|
||||
name: "VAA 7 Day Activity",
|
||||
@@ -19,7 +26,9 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
// The baseline data every new VAA widget starts with
|
||||
defaultSettings: {
|
||||
yAxisScale: 'normal' // Options: 'normal' | 'symlog'
|
||||
// startDate: getStartDate(),
|
||||
// endDate: getEndDate(),
|
||||
yAxisScale: 'normal', // Options: 'normal' | 'symlog'
|
||||
},
|
||||
|
||||
allowedSizes: {
|
||||
@@ -41,9 +50,20 @@ export const WIDGET_REGISTRY = {
|
||||
name: "IVANS",
|
||||
category: "Communications",
|
||||
description: "Feed of recent IVANS reports and reviewer statuses.",
|
||||
component: lazy(() => import('../components/Widgets/IvansAlerts.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
copyToClipboard: false,
|
||||
activeSorts: {
|
||||
timeSort: "desc",
|
||||
volcanoSort: false
|
||||
}
|
||||
},
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 12 },
|
||||
medium: { w: 6, h: 24 }
|
||||
small: { w: 3, h: 6 },
|
||||
large: { w: 3, h: 12 },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -51,8 +71,20 @@ export const WIDGET_REGISTRY = {
|
||||
name: "Remote Sensing",
|
||||
category: "Remote Sensing",
|
||||
description: "Feed of recent remote sensing reports.",
|
||||
component: lazy(() => import('../components/Widgets/RemoteSensing.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/Settings/RemoteSensingSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
copyToClipboard: false,
|
||||
activeSorts: {
|
||||
timeSort: "desc",
|
||||
volcanoSort: false
|
||||
}
|
||||
},
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 12 },
|
||||
small: { w: 3, h: 6 },
|
||||
large: { w: 3, h: 12 },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,9 +92,21 @@ export const WIDGET_REGISTRY = {
|
||||
name: "Alert Level Changes",
|
||||
category: "Daily Alerts",
|
||||
description: "List of 7-day alert level changes with trend arrows.",
|
||||
component: lazy(() => import('../components/Widgets/AlertChanges.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/AlertChangeSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
filters: {
|
||||
levelIncreases: true,
|
||||
levelDecreases: true,
|
||||
elevatedLevels: false, // will filter to show only volcanoes in their top 2 highest alert levels
|
||||
volcanoes: 'all', // filter on what volcanoes to display, defaults to 'all' instead of listing every volc
|
||||
},
|
||||
},
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 12 },
|
||||
large: { w: 9, h: 12 }
|
||||
small: { w: 3, h: 6 },
|
||||
large: { w: 3, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -78,7 +122,39 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
// The baseline data every new VAA widget starts with
|
||||
defaultSettings: {
|
||||
yAxisScale: 'log' // Options: 'normal' | 'symlog'
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
yAxisScale: 'normal' // Options: 'normal' | 'symlog'
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
"plume_events": {
|
||||
name: "Plume Events",
|
||||
category: "Daily Volcanic Activity",
|
||||
description: "Plot of explosion heights over a weeks time.",
|
||||
component: lazy(() => import('../components/Widgets/PlumeEvents.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/PlumeEventsSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
"seismic_events": {
|
||||
name: "Seismic Events",
|
||||
category: "Daily Volcanic Activity",
|
||||
description: "Plot of number of seismic events over a weeks time.",
|
||||
component: lazy(() => import('../components/Widgets/SeismicEvents.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/SeismicEventsSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
@@ -88,11 +164,58 @@ export const WIDGET_REGISTRY = {
|
||||
"llm_al_changes_quad_plot": {
|
||||
name: "Alert Level Changes Quad Plot",
|
||||
category: "Computer Science",
|
||||
description: "Percentage of True-Positive, True-Negative, False-Positive, and False-Negative changes in all " +
|
||||
"alert levels.",
|
||||
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: {
|
||||
large: { 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%;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function useBuildUrl(base_url, start, end) {
|
||||
return useMemo(() =>{
|
||||
if (start && end)
|
||||
return `${base_url}?start_date=${start}&end_date=${end}`;
|
||||
else {
|
||||
let today = new Date();
|
||||
let sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA');
|
||||
today = today.toLocaleDateString('en-CA');
|
||||
return `${base_url}?start_date=${sevenDaysAgo}&end_date=${today}`;
|
||||
}
|
||||
}, [base_url, start, end]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -65,7 +65,7 @@ const Login = () => {
|
||||
|
||||
return (
|
||||
<Flex p={8} bg="gray.50" _dark={{ bg: "gray.900" }} flex="1" minH="100vh" align="center" justify="center">
|
||||
<Box w="full" maxW="450px" bg="white" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
|
||||
<Box w="full" maxW="450px" bg="white" borderColor="gray.200" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px">
|
||||
<Stack gap={6} align="stretch" as="form" onSubmit={handleSubmit}>
|
||||
|
||||
<Stack gap={2} textAlign="center">
|
||||
@@ -88,6 +88,7 @@ const Login = () => {
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="jdoe"
|
||||
{...fieldStyles}
|
||||
color="gray.800"
|
||||
/>
|
||||
</Field.Root>
|
||||
|
||||
@@ -102,8 +103,9 @@ const Login = () => {
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
// placeholder="••••••••" // not a necessary placeholder
|
||||
{...fieldStyles}
|
||||
color="gray.800"
|
||||
/>
|
||||
</Field.Root>
|
||||
|
||||
|
||||
@@ -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,4 +1,4 @@
|
||||
import {HStack, Flex, Box, useRecipe} from "@chakra-ui/react";
|
||||
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";
|
||||
@@ -16,30 +16,23 @@ const HOME_TABS = [
|
||||
];
|
||||
|
||||
export default function Home() {
|
||||
const recipe = useRecipe({key: "glassTab"});
|
||||
const tabStyles = recipe();
|
||||
const glassTabsRecipe = useRecipe({key: "glassTab"});
|
||||
const homeRecipe = useSlotRecipe({key: "home"});
|
||||
const glassTabsStyle = glassTabsRecipe();
|
||||
const homeStyle = homeRecipe();
|
||||
|
||||
const location = useLocation();
|
||||
const isPersonalDashboard = location.pathname === "/home" || location.pathname === "/home/";
|
||||
const location= useLocation();
|
||||
const isPersonalDashboard= location.pathname === "/home" || location.pathname === "/home/";
|
||||
|
||||
return (
|
||||
<Flex direction="column" height="100%" width="100%">
|
||||
|
||||
<Flex as="header" position="sticky" top="0" zIndex="dropdown" bg="transparent" p={5} align="center"
|
||||
justify="space-between" width="100%">
|
||||
|
||||
<Flex css={homeStyle.root}>
|
||||
<Flex as="header" css={homeStyle.header}>
|
||||
{/* TABS CONTAINER */}
|
||||
<Flex gap={2} alignItems="center">
|
||||
<Flex>
|
||||
{HOME_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Box
|
||||
key={tab.name}
|
||||
as={NavLink}
|
||||
to={tab.url}
|
||||
end={tab.exact} // CRITICAL: Forces exact match so "/home" deactivates on sub-routes
|
||||
css={tabStyles}
|
||||
>
|
||||
<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>
|
||||
@@ -49,7 +42,6 @@ export default function Home() {
|
||||
|
||||
{/* The Settings button is now inline with the tabs and only renders on the personal dashboard */}
|
||||
{isPersonalDashboard && <SettingsButton/>}
|
||||
|
||||
</Flex>
|
||||
|
||||
{/* EDIT MODE BUTTONS & CONTROLS */}
|
||||
@@ -57,10 +49,10 @@ export default function Home() {
|
||||
<HStack width="258px" justifyContent="flex-end">
|
||||
{/* Future global controls go here */}
|
||||
</HStack>
|
||||
|
||||
</Flex>
|
||||
|
||||
<Flex flex="1" overflow="auto">
|
||||
{/* TABS CONTENT */}
|
||||
<Flex css={homeStyle.body}>
|
||||
<Outlet/>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -13,8 +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() {
|
||||
|
||||
export default function MyDashboard() {
|
||||
// ☁️ Database Connection
|
||||
const {user} = useAuthStore();
|
||||
|
||||
@@ -28,25 +28,26 @@ export default function MyDashboard() {
|
||||
// 🔒 Safety lock to prevent infinite re-renders
|
||||
const hasHydrated = useRef(false);
|
||||
|
||||
// 1. Pull the new global slot recipe
|
||||
const recipe = useSlotRecipe({key: "glassPanel"});
|
||||
const styles = recipe({ layout: "widget" });
|
||||
const popoverStyles = recipe({ layout: "popover" });
|
||||
// 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,
|
||||
@@ -54,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];
|
||||
|
||||
@@ -69,30 +70,23 @@ export default function MyDashboard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box css={styles.header}>
|
||||
<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"
|
||||
borderRadius="xl"
|
||||
_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 css={popoverStyles.root}>
|
||||
<Popover.Positioner zIndex={1}>
|
||||
<Popover.Content css={popoverStyles.content}>
|
||||
<Suspense fallback={<Spinner size="sm"/>}>
|
||||
<SettingsUI
|
||||
widgetId={widget.id}
|
||||
@@ -102,13 +96,11 @@ export default function MyDashboard() {
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Popover.Root>
|
||||
) : (
|
||||
<Box w="20px" h="20px"/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box css={styles.body}>
|
||||
<Component settings={widget.settings || {}}/>
|
||||
<Box css={widgetStyles.body}>
|
||||
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
@@ -116,7 +108,7 @@ export default function MyDashboard() {
|
||||
|
||||
// Fallback if someone asks for a widget that doesn't exist
|
||||
return (
|
||||
<Flex css={styles.body} align="center" justify="center" direction="column">
|
||||
<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>
|
||||
@@ -133,30 +125,22 @@ 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}
|
||||
css={styles.root}
|
||||
// 3. Grid-specific state overrides remain inline
|
||||
boxShadow={isEditing ? "outline" : styles.root.boxShadow}
|
||||
borderStyle={isEditing ? "dashed" : "solid"}
|
||||
borderWidth={isEditing ? "2px" : "1px"}
|
||||
borderColor={isEditing ? "orange.300" : styles.root.borderColor}
|
||||
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="black"
|
||||
border="1px solid"
|
||||
@@ -164,6 +148,8 @@ export default function MyDashboard() {
|
||||
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
|
||||
@@ -172,9 +158,9 @@ export default function MyDashboard() {
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
|
||||
{/* SUSPENSE BOUNDARY wrapping the render function */}
|
||||
<Suspense fallback={
|
||||
<Flex css={styles.body} align="center" justify="center">
|
||||
<Flex css={widgetStyles.body} align="center" justify="center">
|
||||
<Spinner size="md" color="blue.400" thickness="3px"/>
|
||||
</Flex>
|
||||
}>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,109 +1,110 @@
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { WIDGET_REGISTRY } from '../constants/widgetRegistry.jsx';
|
||||
import {create} from 'zustand';
|
||||
import {v4 as uuid} from 'uuid';
|
||||
import {WIDGET_REGISTRY} from '../constants/widgetRegistry.jsx';
|
||||
|
||||
// 1. Import your database messenger
|
||||
import useAuthStore from './authStore';
|
||||
|
||||
export const useDashboardStore = create((set, get) => ({
|
||||
// ==========================================
|
||||
// 1. STATE (The Data)
|
||||
// ==========================================
|
||||
// ==========================================
|
||||
// 1. STATE (The Data)
|
||||
// ==========================================
|
||||
isEditing: false,
|
||||
widgets: [], // The master array of what is currently on the screen
|
||||
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
|
||||
|
||||
// ==========================================
|
||||
// 2. ACTIONS (The Logic)
|
||||
// ==========================================
|
||||
|
||||
enterEditMode: () => set({
|
||||
isEditing: true,
|
||||
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
|
||||
}),
|
||||
|
||||
cancelEdit: () => set({
|
||||
isEditing: false,
|
||||
widgets: [], // The master array of what is currently on the screen
|
||||
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
|
||||
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
|
||||
}),
|
||||
|
||||
// ==========================================
|
||||
// 2. ACTIONS (The Logic)
|
||||
// ==========================================
|
||||
saveEdit: async () => {
|
||||
// 1. Grab the exact current state of the grid
|
||||
const currentLayout = get().widgets;
|
||||
|
||||
enterEditMode: () => set({
|
||||
isEditing: true,
|
||||
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
|
||||
}),
|
||||
// 2. Lock the UI immediately so the user feels zero lag
|
||||
set({isEditing: false, backupWidgets: []});
|
||||
|
||||
cancelEdit: () => set({
|
||||
isEditing: false,
|
||||
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
|
||||
}),
|
||||
// 3. Reach outside this store and trigger the Django API call!
|
||||
try {
|
||||
await useAuthStore.getState().saveDashboardLayout(currentLayout);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync layout to MariaDB:", error);
|
||||
// If the database save fails, you could technically revert the UI here if needed
|
||||
}
|
||||
},
|
||||
|
||||
saveEdit: async () => {
|
||||
// 1. Grab the exact current state of the grid
|
||||
const currentLayout = get().widgets;
|
||||
updateWidgetSettings: (id, newSettings) => set((state) => {
|
||||
// console.log("2. Zustand updating widget:", id, "with:", newSettings);
|
||||
return {
|
||||
widgets: state.widgets.map(widget =>
|
||||
widget.id === id
|
||||
? {...widget, settings: {...widget.settings, ...newSettings}}
|
||||
: widget
|
||||
)
|
||||
};
|
||||
}),
|
||||
|
||||
// 2. Lock the UI immediately so the user feels zero lag
|
||||
set({ isEditing: false, backupWidgets: [] });
|
||||
addWidget: (widgetId, sizeKey) => {
|
||||
const registryEntry = WIDGET_REGISTRY[widgetId];
|
||||
|
||||
// 3. Reach outside this store and trigger the Django API call!
|
||||
try {
|
||||
await useAuthStore.getState().saveDashboardLayout(currentLayout);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync layout to MariaDB:", error);
|
||||
// If the database save fails, you could technically revert the UI here if needed
|
||||
}
|
||||
},
|
||||
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
|
||||
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
updateWidgetSettings: (id, newSettings) => set((state) => {
|
||||
console.log("2. Zustand updating widget:", id, "with:", newSettings);
|
||||
return {
|
||||
widgets: state.widgets.map(widget =>
|
||||
widget.id === id
|
||||
? { ...widget, settings: { ...widget.settings, ...newSettings } }
|
||||
: widget
|
||||
)
|
||||
};
|
||||
}),
|
||||
const dimensions = registryEntry.allowedSizes[sizeKey];
|
||||
const widgets = get().widgets;
|
||||
|
||||
addWidget: (widgetId, sizeKey) => {
|
||||
const registryEntry = WIDGET_REGISTRY[widgetId];
|
||||
let startX = 0;
|
||||
let startY = Infinity;
|
||||
|
||||
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
|
||||
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
|
||||
return;
|
||||
}
|
||||
if (widgets.length > 0) {
|
||||
const bottomRowY = Math.max(...widgets.map(w => w.y));
|
||||
const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
|
||||
const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
|
||||
|
||||
const dimensions = registryEntry.allowedSizes[sizeKey];
|
||||
const widgets = get().widgets;
|
||||
if (rightmostEdge + dimensions.w <= 12) {
|
||||
startX = rightmostEdge; // ...tuck it right next to the last widget!
|
||||
startY = bottomRowY; // ...and keep it on the same row.
|
||||
}
|
||||
}
|
||||
const newWidget = {
|
||||
id: uuid(),
|
||||
type: widgetId,
|
||||
size: sizeKey,
|
||||
settings: registryEntry.defaultSettings,
|
||||
x: startX,
|
||||
y: startY,
|
||||
w: dimensions.w,
|
||||
h: dimensions.h
|
||||
};
|
||||
console.log("newWidget: ", newWidget);
|
||||
set((state) => ({widgets: [...state.widgets, newWidget]}));
|
||||
},
|
||||
|
||||
let startX = 0;
|
||||
let startY = Infinity;
|
||||
removeWidget: (id) => set((state) => ({
|
||||
widgets: state.widgets.filter(w => w.id !== id)
|
||||
})),
|
||||
|
||||
if (widgets.length > 0) {
|
||||
const bottomRowY = Math.max(...widgets.map(w => w.y));
|
||||
const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
|
||||
const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
|
||||
updateLayoutPositions: (rglLayout) => {
|
||||
set((state) => ({
|
||||
widgets: state.widgets.map(widget => {
|
||||
const gridItem = rglLayout.find(l => l.i === widget.id);
|
||||
return gridItem ? {...widget, x: gridItem.x, y: gridItem.y} : widget;
|
||||
})
|
||||
}));
|
||||
},
|
||||
|
||||
if (rightmostEdge + dimensions.w <= 12) {
|
||||
startX = rightmostEdge; // ...tuck it right next to the last widget!
|
||||
startY = bottomRowY; // ...and keep it on the same row.
|
||||
}
|
||||
}
|
||||
const newWidget = {
|
||||
id: uuid(),
|
||||
type: widgetId,
|
||||
size: sizeKey,
|
||||
x: startX,
|
||||
y: startY,
|
||||
w: dimensions.w,
|
||||
h: dimensions.h
|
||||
};
|
||||
|
||||
set((state) => ({ widgets: [...state.widgets, newWidget] }));
|
||||
},
|
||||
|
||||
removeWidget: (id) => set((state) => ({
|
||||
widgets: state.widgets.filter(w => w.id !== id)
|
||||
})),
|
||||
|
||||
updateLayoutPositions: (rglLayout) => {
|
||||
set((state) => ({
|
||||
widgets: state.widgets.map(widget => {
|
||||
const gridItem = rglLayout.find(l => l.i === widget.id);
|
||||
return gridItem ? { ...widget, x: gridItem.x, y: gridItem.y } : widget;
|
||||
})
|
||||
}));
|
||||
},
|
||||
|
||||
// NEW: Action to hydrate the store on initial page load
|
||||
setWidgets: (savedWidgets) => set({ widgets: savedWidgets || [] })
|
||||
// NEW: Action to hydrate the store on initial page load
|
||||
setWidgets: (savedWidgets) => set({widgets: savedWidgets || []})
|
||||
}));
|
||||
+436
-186
@@ -1,27 +1,75 @@
|
||||
import {defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe} from "@chakra-ui/react";
|
||||
|
||||
// RECIPES
|
||||
const headerRecipe = defineRecipe({
|
||||
// REUSABLE GLASS SURFACE
|
||||
const glassSurface = {
|
||||
bg: {base: "rgb(253 253 253 / 0.4)", _dark: "rgb(37 38 46 / 0.8)"},
|
||||
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: "{transparent}",
|
||||
color: "text",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}
|
||||
root: {
|
||||
bg: "transparent",
|
||||
color: "text",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
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,
|
||||
|
||||
// The Text component
|
||||
"& p": {
|
||||
fontWeight: "600",
|
||||
fontSize: "md",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Sidebar
|
||||
const sidebarRecipe = defineSlotRecipe({
|
||||
slots: ["root", "logo", "footer", "button_text","buttons", "collapse_button"],
|
||||
slots: [
|
||||
"root",
|
||||
"logo",
|
||||
"logo_text",
|
||||
"footer",
|
||||
"button_text",
|
||||
"buttons",
|
||||
"collapse_button",
|
||||
"color_mode_button",
|
||||
],
|
||||
base: {
|
||||
root: {
|
||||
// 1. Semi-transparent backgrounds
|
||||
bg: {
|
||||
base: "linear-gradient(to bottom right, #99000040 20%, #FF660040 60%, #EEEE0040 100%)",
|
||||
_dark: "linear-gradient(to bottom right, #9900003C 20%, #FF66003C 60%, #FFC0003C 100%)"
|
||||
base: "linear-gradient(to bottom right, #B63B1F88 20%, #FF990060 70%, #FFBF0040 90%)",
|
||||
_dark: "linear-gradient(to bottom right, #9900004C 20%, #FF66003C 60%, #FFAE003C 100%)"
|
||||
},
|
||||
|
||||
// 2. The glass blur effect
|
||||
@@ -36,8 +84,6 @@ const sidebarRecipe = defineSlotRecipe({
|
||||
borderRight: "none",
|
||||
|
||||
borderRadius: "2xl",
|
||||
// boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
|
||||
// boxShadow: "-10px 4px 30px rgba(0, 0, 0, 0.1)",
|
||||
width: "100%",
|
||||
height: "calc(100% - 16px)",
|
||||
margin: "8px",
|
||||
@@ -47,7 +93,7 @@ const sidebarRecipe = defineSlotRecipe({
|
||||
justifyContent: "start",
|
||||
alignItems: "stretch",
|
||||
gap: 4,
|
||||
transition: "width 0.4s ease",
|
||||
transition: "width 0.5s ease",
|
||||
|
||||
// CRITICAL: Adjust padding and overflow to allow the button to touch the right edge
|
||||
pl: 2, // Keep left padding
|
||||
@@ -62,20 +108,22 @@ const sidebarRecipe = defineSlotRecipe({
|
||||
bg: "transparent",
|
||||
|
||||
justifyContent: "flex-start",
|
||||
transition: "background-color 0.4s ease, color 0.4s ease",
|
||||
transition: "background-color 0.3s ease, color 0.3s ease",
|
||||
|
||||
"& svg": {
|
||||
transition: "stroke-width 0.4s ease",
|
||||
flexShrink: "0"
|
||||
flexShrink: "0",
|
||||
},
|
||||
|
||||
_hover: {
|
||||
bg: "rgba(255, 255, 255, 0.1)",
|
||||
bg: "rgba(255, 255, 255, 0.2)",
|
||||
color: "{text}",
|
||||
fontWeight: "semibold",
|
||||
"& svg": {
|
||||
strokeWidth: "3"
|
||||
}
|
||||
strokeWidth: "3",
|
||||
},
|
||||
"& span": {
|
||||
fontWeight: "700",
|
||||
},
|
||||
},
|
||||
|
||||
fontSize: "l",
|
||||
@@ -94,10 +142,8 @@ const sidebarRecipe = defineSlotRecipe({
|
||||
_hover: {
|
||||
bg: "{MainBackground}",
|
||||
color: "{textInverted}",
|
||||
fontWeight: "normal",
|
||||
"& svg": {
|
||||
strokeWidth: "2" // Keeps the active icon at normal thickness on hover
|
||||
}
|
||||
"& svg": { strokeWidth: "2" },
|
||||
"& span": { fontWeight: "500" },
|
||||
},
|
||||
|
||||
// The Top Inverted Curve
|
||||
@@ -126,149 +172,164 @@ const sidebarRecipe = defineSlotRecipe({
|
||||
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%",
|
||||
height: "auto",
|
||||
overflow: "hidden",
|
||||
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",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pb: 4,
|
||||
pl: 2,
|
||||
pr: 2,
|
||||
transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease"
|
||||
},
|
||||
button_text: {
|
||||
transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease",
|
||||
whiteSpace: "nowrap"
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "clip",
|
||||
fontWeight: "500",
|
||||
},
|
||||
collapse_button: {
|
||||
"& svg": { flexShrink: "0" },
|
||||
"& svg": {flexShrink: "0"},
|
||||
},
|
||||
color_mode_button: {
|
||||
"& button": {
|
||||
_hover: {
|
||||
bg: "transparent",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
state: {
|
||||
open: {
|
||||
root: { width: "190px" },
|
||||
logo: { justifyContent: "flex-start", ml: 2, textStyle: "4xl" },
|
||||
footer: { gap: 16, flexDirection: "row" },
|
||||
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" },
|
||||
logo: { justifyContent: "center", ml: -1, textStyle: "3xl" },
|
||||
footer: { gap: 2, flexDirection: "column-reverse" },
|
||||
button_text: {ml: 0, opacity: 0, visibility: "hidden"},
|
||||
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",}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Dashboard Shell
|
||||
const dashboardRecipe = defineRecipe({
|
||||
base: {
|
||||
bg: "{MainBackground}"
|
||||
}
|
||||
});
|
||||
height: "100vh",
|
||||
width: "100vw",
|
||||
bg: "{MainBackground}",
|
||||
|
||||
const canvasRecipe = defineRecipe({
|
||||
base: {
|
||||
bg: "{transparent}",
|
||||
zIndex: "-1",
|
||||
color: "{text}"
|
||||
}
|
||||
});
|
||||
|
||||
const widgetRecipe = defineSlotRecipe({
|
||||
slots: ["root", "header", "canvas"],
|
||||
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",
|
||||
// selects the third div in the grid layout (the canvas)
|
||||
"& > div:nth-of-type(3)": {
|
||||
width: "100%",
|
||||
overflow: "auto",
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const glassPanelRecipe = defineSlotRecipe({
|
||||
// 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 GLASS MATH (Applies to everything)
|
||||
// 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",
|
||||
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",
|
||||
}
|
||||
},
|
||||
|
||||
// 📏 THE LAYOUTS (Applies selectively)
|
||||
variants: {
|
||||
layout: {
|
||||
widget: {
|
||||
root: {width: "100%", height: "100%", borderRadius: "3xl"},
|
||||
header: {display: "flex", justifyContent: "space-between", alignItems: "center", px: 3, pt: 2, pb: 0},
|
||||
body: {flex: "1", overflow: "hidden", position: "relative", px: 1, pb: 1, pt: 0}
|
||||
},
|
||||
dropdown: {
|
||||
root: {borderRadius: "xl", p: 1}
|
||||
// (Header and body slots are ignored here because dropdowns don't use them)
|
||||
},
|
||||
popover: {
|
||||
root: {
|
||||
borderRadius: "2xl",
|
||||
p: 4,
|
||||
boxShadow: "xl",
|
||||
minW: "240px",
|
||||
// OVERRIDES: Make the background 85-90% opaque instead of 40%
|
||||
bg: { base: "rgba(255, 255, 255, 0.75)", _dark: "rgba(20, 22, 25, 0.8)" },
|
||||
|
||||
// OVERRIDES: Increase the blur just to smooth out any stubborn gridlines
|
||||
backdropFilter: "blur(15px)",
|
||||
WebkitBackdropFilter: "blur(15px)",
|
||||
}
|
||||
}
|
||||
},
|
||||
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: {
|
||||
@@ -277,16 +338,202 @@ const glassPanelRecipe = defineSlotRecipe({
|
||||
borderWidth: "2px",
|
||||
borderColor: "orange.300",
|
||||
cursor: "grab",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 6. TRUE POPOVER: Compact floating card for 3-dots menus
|
||||
const glassPopoverRecipe = defineSlotRecipe({
|
||||
slots: ["content", "arrow", "positioner", "date", "checkboxcard", "checkbox", "switch", "button"],
|
||||
base: {
|
||||
content: {
|
||||
...glassSurface,
|
||||
borderRadius: "1rem",
|
||||
p: 3,
|
||||
boxShadow: "lg",
|
||||
bg: {base: "rgb(246 246 246 / 0.95)", _dark: "#353742"},
|
||||
backdropFilter: "blur(15px)",
|
||||
WebkitBackdropFilter: "blur(15px)",
|
||||
},
|
||||
date: {
|
||||
color: "text",
|
||||
bg: "widgetBadgeBg",
|
||||
borderRadius: "lg",
|
||||
h: "2rem",
|
||||
_focus: {
|
||||
borderStyle: "none",
|
||||
}
|
||||
},
|
||||
checkboxcard: {
|
||||
// applies to both dark & lt modes
|
||||
w: "100%",
|
||||
borderRadius: "lg",
|
||||
color: "text",
|
||||
|
||||
"& div[data-part='control']": {
|
||||
alignItems: "center",
|
||||
padding: "6px",
|
||||
},
|
||||
|
||||
bg: "widgetItemBg",
|
||||
_checked: {
|
||||
boxShadow: "none",
|
||||
bg: "rgb(182 59 31 / 0.2)",
|
||||
borderColor: "#B63B1F"
|
||||
},
|
||||
|
||||
_dark: {
|
||||
bg: "widgetBadgeBg",
|
||||
_checked: {
|
||||
boxShadow: "none",
|
||||
color: "text",
|
||||
bg: "#503D40",
|
||||
borderColor: "#B55038"
|
||||
}
|
||||
}
|
||||
},
|
||||
checkbox: {
|
||||
bg: "widgetInnerBadgeBg",
|
||||
_checked: {
|
||||
bg: "#B63B1F",
|
||||
borderColor: "#B63B1F"
|
||||
},
|
||||
|
||||
_dark: {
|
||||
bg: "widgetBadgeBg",
|
||||
_checked: {
|
||||
color: "text",
|
||||
bg: "#B55038",
|
||||
borderColor: "#B55038"
|
||||
}
|
||||
}
|
||||
},
|
||||
switch: {
|
||||
color: "text",
|
||||
bg: "widgetBadgeBg",
|
||||
|
||||
_checked: {
|
||||
bg: "#B63B1F",
|
||||
|
||||
_dark: {
|
||||
bg: "#b55038"
|
||||
}
|
||||
},
|
||||
},
|
||||
button: {
|
||||
w: "100%",
|
||||
fontSize: "xs",
|
||||
h: "2rem",
|
||||
borderRadius: "lg",
|
||||
|
||||
color: "white",
|
||||
bg: "#B63B1F",
|
||||
_hover: {
|
||||
color: "text",
|
||||
bg: "#B63B1F32"
|
||||
},
|
||||
|
||||
_dark: {
|
||||
bg: "#b55038",
|
||||
color: "text",
|
||||
_hover: {
|
||||
bg: "#503D40"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
layout: "widget"
|
||||
}
|
||||
});
|
||||
|
||||
// 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: "rgb(22 23 28 / 0.9)"},
|
||||
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",
|
||||
@@ -305,12 +552,12 @@ const glassTabRecipe = defineRecipe({
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
fontWeight: "bold",
|
||||
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)"},
|
||||
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "#1e2027"},
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid",
|
||||
@@ -324,7 +571,7 @@ const glassTabRecipe = defineRecipe({
|
||||
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)"},
|
||||
bg: {base: "rgba(255, 255, 255, 0.8)", _dark: "#191a22"},
|
||||
borderColor: {base: "rgba(0, 0, 0, 0.1)", _dark: "rgba(255, 255, 255, 0.15)"},
|
||||
color: "fg",
|
||||
textDecoration: "none",
|
||||
@@ -343,29 +590,46 @@ const glassTabRecipe = defineRecipe({
|
||||
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: {
|
||||
// the shadow box behind widgets when editing layout
|
||||
".react-grid-placeholder": {
|
||||
background: "#c0d2e3ff !important",
|
||||
borderColor: "#a9b6c2ff !important",
|
||||
boxShadow: "0 0 10px #c0d2e3ff !important",
|
||||
opacity: "1 !important",
|
||||
borderRadius: "5px",
|
||||
borderRadius: "3xl",
|
||||
_dark: {
|
||||
background: "#393c3fff !important",
|
||||
background: "#3B3D49FF !important",
|
||||
borderColor: "#000000ff !important",
|
||||
boxShadow: "0 0 10px #16181bff !important",
|
||||
opacity: "1 !important",
|
||||
},
|
||||
},
|
||||
".react-resizable-handle-se": {
|
||||
zIndex: "1",
|
||||
},
|
||||
"*": {
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: "#a0aec0 transparent",
|
||||
@@ -386,58 +650,43 @@ 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': {
|
||||
// this is what changes the maps colors to 'dark mode'
|
||||
".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"
|
||||
// removes the animation of moving widgets, seems very harsh and jumpy w/o animation
|
||||
// ".react-grid-item": {
|
||||
// transition: "none !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"
|
||||
},
|
||||
},
|
||||
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: "#FFFFFF"}},
|
||||
textInverted: {value: {base: "#000000", _dark: "#FFFFFF"}},
|
||||
icon: {value: {base: "#000000", _dark: "#FFFFFF"}},
|
||||
gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "rgba(255, 255, 255, 0.1)"}},
|
||||
MainBackground: {value: {base: "#F9F9F9", _dark: "#141414"}},
|
||||
titleColor: {value: {base: "gray.800", _dark: "#FFFFFF"}},
|
||||
iconColor: {value: {base: "gray.600", _dark: "#000000"}},
|
||||
glassSeperator: {value: {base: "#0000001A", _dark: "#FFFFFF3F"}},
|
||||
text: { value: { base: "#131315", _dark: "rgb(234 234 234 / 0.94)" } },
|
||||
textInverted: { value: { base: "#000000", _dark: "#FFFFFF" } }, // Fixed: Properly inverted
|
||||
MainBackground: { value: { base: "#f1f1f1", _dark: "#16171c" } },
|
||||
titleColor: { value: { base: "colors.gray.800", _dark: "#e1e1e1" } },
|
||||
iconColor: { value: { base: "#191919", _dark: "rgb(255 255 255 / 0.82)" } }, // Fixed: Visible in dark mode
|
||||
glassSeperator: { value: { base: "#0000001A", _dark: "#FFFFFF3F" } },
|
||||
gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "#333540"}},
|
||||
|
||||
// WIDGET SPECIFIC VALUES
|
||||
widgetItemBg: { value: { base: "rgb(115 132 166 / 0.17)", _dark: "rgb(51 53 64)" } },
|
||||
widgetItemExpandedBg: { value: { base: "rgb(184 197 220 / 0.17)", _dark: "rgb(59 61 73)" } },
|
||||
widgetBadgeBg: { value: { base: "rgb(154 163 179 / 0.45)", _dark: "rgb(75 77 90)" } },
|
||||
widgetInnerBadgeBg: { value: { base: "rgb(224 226 232)", _dark: "rgb(100 103 119)" } },
|
||||
widgetTextMuted: { value: { base: "#262627", _dark: "rgb(236 236 236 / 0.9)" } },
|
||||
|
||||
// ALERT COLORS
|
||||
alertRed: { value: { base: "colors.red.600", _dark: "colors.red.400" } },
|
||||
alertOrange: { value: { base: "colors.orange.600", _dark: "colors.orange.400" } },
|
||||
alertYellow: { value: { base: "colors.yellow.600", _dark: "colors.yellow.400" } },
|
||||
alertGreen: { value: { base: "colors.green.600", _dark: "colors.green.400" } },
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
@@ -446,15 +695,16 @@ const config = defineConfig({
|
||||
},
|
||||
},
|
||||
recipes: {
|
||||
header: headerRecipe,
|
||||
dashboard: dashboardRecipe,
|
||||
canvas: canvasRecipe,
|
||||
glassTab: glassTabRecipe
|
||||
glassTab: glassTabRecipe,
|
||||
},
|
||||
slotRecipes: {
|
||||
header: headerRecipe,
|
||||
sidebar: sidebarRecipe,
|
||||
home: homeRecipe,
|
||||
widget: widgetRecipe,
|
||||
glassPanel: glassPanelRecipe,
|
||||
glassPopover: glassPopoverRecipe,
|
||||
glassDialog: glassDialogRecipe,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user