Author SHA1 Message Date
chight c5f8c848a5 Merge branch '20260915_1102_Add_TravelGantChart' into 20260828_1350_settings_page 2026-09-15 14:53:08 -07:00
vkuchenik c7dcf193f9 added gantt chart to widget and it is all visible however the participants column is ugly and data is only dummy data 2026-09-15 14:04:35 -07:00
vkuchenik 6c0664d05a add - base ux ui for web admin page, form works on group and teams selection only 2026-09-02 12:44:38 -07:00
vkuchenik 43cc39fd62 add — settings page has basic ui, changes to preferences currently do not take effect 2026-09-01 09:47:54 -07:00
vkuchenik 75b65bfcaa Merge branch '20260820_1700_mapWidget' into '20260721_0845_Venessa_addingWidgets'
Add Map Widget

See merge request vkuchenik/website-framework!109
2026-08-28 20:46:01 +00:00
vkuchenik 7e0723a776 add - descriptive tooltips and default to most recent vaa on mount 2026-08-28 13:11:56 -07:00
vkuchenik 6304db66c2 add working vaa ash-polygons map w/ volcano selection capabilities 2026-08-27 12:36:25 -07:00
vkuchenik 6eb5e42111 Merge branch '20260819_1245_commcoorWidget' into '20260721_0845_Venessa_addingWidgets'
add comm coor report w/ download and copy abilities

See merge request vkuchenik/website-framework!108
2026-08-20 23:53:53 +00:00
vkuchenik 152aacb1c3 add comm coor report w/ download and copy abilities 2026-08-20 16:49:50 -07:00
vkuchenik 1f2b578c91 add differentiating capabilities according to widget size in rs 2026-08-19 12:39:24 -07:00
vkuchenik a5f2f012dc add differentiation in capability according to sizing in ivans widget 2026-08-19 09:52:08 -07:00
vkuchenik 0381bc3ce8 update colors on each widget entry 2026-08-19 07:47:48 -07:00
vkuchenik f2637c7469 add open pdf button in ivans widget 2026-08-18 14:50:37 -07:00
vkuchenik dab9b8227e Merge branch '202608121500_Add_AlertChangesWidget' into '20260721_0845_Venessa_addingWidgets'
Add Alert Changes Widget

See merge request vkuchenik/website-framework!107
2026-08-18 21:20:49 +00:00
vkuchenik 7f92ddd62b add alert-level-changes widget with working time range selector 2026-08-18 21:20:49 +00:00
vkuchenik 7667235fdb update small styling adjustment 2026-08-12 15:00:24 -07:00
vkuchenik 2bea05bf29 fix spacing issues around images/pdfs in ivans and rs 2026-08-11 13:11:11 -07:00
vkuchenik 058b51795b update data formatting when copying to clipboard ivans or rs 2026-08-11 09:20:07 -07:00
vkuchenik 0c79b409bd fix ivans & rs text overflow in header of each entry by using ellipses 2026-08-11 08:37:08 -07:00
vkuchenik d9bdb17217 add filtering on dates to all widgets, defualt is past 7 days 2026-08-10 16:22:34 -07:00
vkuchenik 520731aa47 Merge branch '20260810_0800_AddingRsWidget' into '20260721_0845_Venessa_addingWidgets'
add rs widget with copy to clipboard and sorting capabilities

See merge request vkuchenik/website-framework!105
2026-08-10 20:47:07 +00:00
vkuchenik a61d3f104e add rs widget with copy to clipboard and sorting capabilities 2026-08-10 20:47:07 +00:00
vkuchenik 5ec7734b9b Merge branch '20260806_0800_AddingSeismicWidget' into '20260721_0845_Venessa_addingWidgets'
merging seismic widget to adding widgets branch

See merge request vkuchenik/website-framework!104
2026-08-10 15:02:37 +00:00
vkuchenik abb2c2ba5c Added plot of seismic events count with date picker feature in the settings 2026-08-10 15:02:37 +00:00
vkuchenik 182495e11a updated element sizing in ivans widget 2026-08-10 07:53:26 -07:00
vkuchenik 8742b85d1c Merge branch '20260805_1200_Ivans_UpdatingInfoViewing' into '20260721_0845_Venessa_addingWidgets'
Update viewing format of ivans summary and pdf

See merge request vkuchenik/website-framework!103
2026-08-06 14:58:12 +00:00
vkuchenik dccebbc21b Update viewing format of ivans summary and pdf 2026-08-06 14:58:12 +00:00
vkuchenik 1dcee8c8c3 Merge branch '20260730_0920_UpdatingIvansWidgetUI' into '20260721_0845_Venessa_addingWidgets'
Adding functional IVANS widget with dialog popup view of summary and pdfs

See merge request vkuchenik/website-framework!102
2026-08-05 18:58:47 +00:00
vkuchenik 0861ca2b94 Adding functional IVANS widget with dialog popup view of summary and pdfs 2026-08-05 18:58:47 +00:00
vkuchenik 14a4af623d Merge branch '20260804_1300_PlumeEventWidget' into '20260721_0845_Venessa_addingWidgets'
Adding functioning plot of Plume Events widget

See merge request vkuchenik/website-framework!100
2026-08-05 17:16:45 +00:00
vkuchenik 999e3bb50c Adding functioning plot of Plume Events widget 2026-08-05 17:16:45 +00:00
vkuchenik 8525dcc71c adding working IVANS widget 2026-07-30 09:18:47 -07:00
vkuchenik bb1e55d796 added a basic ivans widget, still needs ui refactoring 2026-07-22 16:29:17 -07:00
49 changed files with 3915 additions and 933 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -24,6 +24,6 @@ export default defineConfig([globalIgnores(['dist']), {
}, },
}, },
rules: { rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], 'no-unused-vars': 'off',
}, },
}, ...storybook.configs["flat/recommended"]]) }, ...storybook.configs["flat/recommended"]])
+390
View File
@@ -11,6 +11,7 @@
"@chakra-ui/react": "^3.22.0", "@chakra-ui/react": "^3.22.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@svar-ui/react-gantt": "^2.7.3",
"axios": "^1.18.1", "axios": "^1.18.1",
"framer-motion": "^12.23.0", "framer-motion": "^12.23.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@@ -3106,6 +3107,385 @@
"node": ">=6" "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": { "node_modules/@swc/core": {
"version": "1.12.7", "version": "1.12.7",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.7.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.12.7.tgz",
@@ -5926,6 +6306,16 @@
"integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==", "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==",
"license": "BSD-3-Clause" "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": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+1
View File
@@ -15,6 +15,7 @@
"@chakra-ui/react": "^3.22.0", "@chakra-ui/react": "^3.22.0",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@svar-ui/react-gantt": "^2.7.3",
"axios": "^1.18.1", "axios": "^1.18.1",
"framer-motion": "^12.23.0", "framer-motion": "^12.23.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
+9 -4
View File
@@ -18,6 +18,8 @@ import Volcano from "@/pages/volcano/Volcano.jsx";
import GlobalMap from "./pages/maps/GlobalMap.jsx"; import GlobalMap from "./pages/maps/GlobalMap.jsx";
import Admin from "./pages/admin/Admin.jsx"; import Admin from "./pages/admin/Admin.jsx";
import MyDashboard from "./pages/home/tabs/MyDashboard.jsx"; import MyDashboard from "./pages/home/tabs/MyDashboard.jsx";
import Settings from "./pages/auth/Settings.jsx";
import WebAdmin from "./pages/auth/WebAdmin.jsx";
function AppFoundation() { function AppFoundation() {
const recipe = useRecipe({ key: "dashboard" }); const recipe = useRecipe({ key: "dashboard" });
@@ -33,12 +35,12 @@ function AppFoundation() {
height="100vh" height="100vh"
width="100vw" width="100vw"
> >
<GridItem rowSpan={2} colSpan={1}> <GridItem rowSpan={1} colSpan={2}>
<Sidebar />
</GridItem>
<GridItem rowSpan={1} colSpan={1}>
<Header /> <Header />
</GridItem> </GridItem>
<GridItem colSpan={1}>
<Sidebar />
</GridItem>
<GridItem colSpan={1} width="100%" overflow="auto"> <GridItem colSpan={1} width="100%" overflow="auto">
<Outlet /> <Outlet />
</GridItem> </GridItem>
@@ -81,6 +83,9 @@ function App() {
<Route path="daily" element={<Placeholder name="Daily Activity" />} /> <Route path="daily" element={<Placeholder name="Daily Activity" />} />
</Route> </Route>
<Route path="/settings" element={<Settings />} />
<Route path="/web-admin" element={<WebAdmin />} />
<Route path="/global-map" element={<GlobalMap />} /> <Route path="/global-map" element={<GlobalMap />} />
<Route path="/regional-map" element={<RegionalMap />} /> <Route path="/regional-map" element={<RegionalMap />} />
<Route path="/volcano" element={<Volcano />} /> <Route path="/volcano" element={<Volcano />} />
@@ -0,0 +1,219 @@
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}) {
// 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, default is 7 days past
const url = useBuildUrl('/api/v2/alert_changes', settings?.startDate, settings?.endDate);
// call api hook to retrieve data
const {data: apiData, isLoading, error} = VdapApi(url);
const data = 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 it returns 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((a, b) =>
new Date(b.alert_start_date_local) - new Date(a.alert_start_date_local))
]
);
// volcano with most recent alert level change appears at top of list
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));
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);
return [volcano[0], domain_arr];
}));
}
return sorted_entries;
}, [apiData, thisWidget]);
// list of alert colors used for coloring text below
const alertColors = {
Red: "red.400",
Orange: "orange.400",
Yellow: "yellow.400",
Green: "green.400",
};
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 flight level data available for the selected dates.</Text>
</Flex>
);
return (
<Box display="flex" flexDirection="column" gap={2} p={2} w="100%" h="100%" borderRadius="lg" bg="gray.800" overflowY="auto">
{data.map((volcano, index) => {
if (thisWidget?.size === "large") return (
<Collapsible.Root key={index} w="100%">
{/* Using context to dynamically change the border radii of the triggers */}
<Collapsible.Context>
{(collapsible) => (
<>
<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="gray.600"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Text truncate >{volcano[0]}</Text> {/* volcano name */}
<Text truncate fontSize="xs" color="gray.300/90">{volcano[1][0]?.alert_start_date_local}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{volcano[1][0]?.alert_level_name}</Text>
</Flex>
</Flex>
</Collapsible.Trigger>
<Collapsible.Content w="100%" p={3} borderBottomRadius="lg" bg="gray.700">
<Timeline.Root size="md" variant="subtle">
{volcano[1]?.map((object, index) => (
<Timeline.Item key={index} w="100%" h="100%">
<Timeline.Connector >
{/*<Timeline.Separator borderWidth="1px" borderColor="gray.600"/>*/}
<Timeline.Indicator outline="none" bg="gray.200" color="gray.500" size="lg">
{object?.alert_scheme_domain === "terrestrial" && <Footprints size={15} />}
{object?.alert_scheme_domain === "aviation" && <Plane size={15} />}
{object?.alert_scheme_domain === "maritime" && <Sailboat size={15} />}
</Timeline.Indicator>
<Timeline.Indicator h="50%" outline="none" bg="gray.700" color="gray.100" size="lg">
{object?.change_type === "increase" ? <TrendingUp /> : <TrendingDown />}
</Timeline.Indicator>
</Timeline.Connector>
<Timeline.Content w="100%" h="100%">
{/*<Timeline.Title>{object?.volcano}</Timeline.Title>*/}
<Timeline.Description w="100%" h="100%" gap={1}>
<Flex direction="column" gap={1}>
<Text w="100%" h="100%" color="gray.200">{object?.alert_start_date_local}</Text>
<Text w="100%" h="100%"
color={alertColors[object?.alert_level_name] || "gray.200"}
fontSize="md"
fontWeight="semibold"
>
{object?.alert_level_name}
</Text>
<Flex w="100%" h="20px" direction="row" justify="left" align="center">
<Text color="gray.300">Alert Scale: </Text>
{(Array.from({ length: Number(object?.alert_scheme_highest) - Number(object?.alert_scheme_lowest) + 1}, (v, i) => Number(object?.alert_scheme_lowest) + i))
.map((num) => (
<Center
key={num}
w="20px" h="100%"
color="gray.300"
bg={num === Number(object?.alert_level_rank) ? "gray.300/20" : null} // alert level rank is a string for some reason
borderRadius={num === Number(object?.alert_level_rank) ? "xl" : null}
textAlign="center"
fontSize="sm"
fontWeight="semibold"
>
{num}
</Center>
))
}
</Flex>
</Flex>
</Timeline.Description>
</Timeline.Content>
</Timeline.Item>
))}
</Timeline.Root>
</Collapsible.Content>
</>
)}
</Collapsible.Context>
</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="gray.600"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Flex direction="row" w="100%" gap={2} align="center">
<Flex color="gray.300">
{domain[0] === "aviation" && <Plane size={15}/>}
{domain[0] === "terrestrial" && <Footprints size={15}/>}
{domain[0] === "maritime" && <Sailboat size={15}/>}
</Flex>
<Text truncate>{volcano[0] || ""}</Text> {/* volcano name */}
</Flex>
<Text truncate w="100%" fontSize="xs" color="gray.300/90">{domain[1]?.[0]?.alert_start_date_local || ""}</Text>
</Flex>
<Flex direction="row" w="100%" minW={0} justify="right" gap={2} color="gray.200">
<Flex w="25px" justify="right">
{domain[1]?.[0]?.change_type === "increase" ? <TrendingUp/> : <TrendingDown/>}
</Flex>
<Text
truncate
pl={2} pr={2}
borderRadius="lg"
bg="gray.800/50"
color={alertColors[domain[1]?.[0]?.alert_level_name] || "gray.100"}
fontWeight="semibold"
>
{domain[1]?.[0]?.alert_level_name}
</Text>
</Flex>
</Flex>
))
);
})}
</Box>
)
}
@@ -0,0 +1,140 @@
import {Flex, Button, Spinner, Text} from "@chakra-ui/react";
import useBuildUrl from "../../hooks/useBuildUrl.js";
import VdapApi from '../../hooks/useVdapApi.js';
import { useRef, useEffect } from 'react';
import {useDashboardStore} from "../../store/useDashboardStore.jsx";
export default function CommCoorReport({ settings, widgetID }) {
const reportRef = useRef(null);
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
// build url, defaults to 7 days past
const url = useBuildUrl('/api/v2/comm_coor_report', settings?.startDate, settings?.endDate);
console.log(url);
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
const {data: apiData, isLoading, error} = VdapApi(url);
console.log(apiData);
const handleDownloadReport = () => {
if (apiData.length === 0) return;
const element = document.createElement('a');
element.style.display = 'none';
// Appends HTML right inside the element, after its last child
element.insertAdjacentHTML('afterbegin', apiData?.report_html);
// 1. Create a blob object with text content and text/plain mime type
const blob = new Blob([element.textContent || ""], { type: 'text/plain' });
// 2. Generate a temporary URL pointing to that blob
const fileUrl = URL.createObjectURL(blob);
// 3. Create a hidden anchor element
const linkElement = document.createElement('a');
linkElement.href = fileUrl;
linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
linkElement.style.display = 'none';
// 4. Append to body, trigger click, and remove it immediately
document.body.appendChild(linkElement);
linkElement.click();
document.body.removeChild(linkElement);
document.body.removeChild(element);
// 5. Free up memory by revoking the blob URL
URL.revokeObjectURL(fileUrl);
}
const handleCopyReport = () => {
if (apiData.length === 0) return;
const element = document.createElement('a');
element.style.display = 'none';
// Appends HTML right inside the element, after its last child
element.insertAdjacentHTML('afterbegin', apiData?.report_html);
navigator.clipboard.writeText(element.textContent || "")
.then(() => {
alert('Copied to clipboard!');
})
.catch((err => {
console.error('Failed to copy: ', err);
}));
document.body.removeChild(element);
}
useEffect(() => {
if (reportRef.current) {
reportRef.current.innerHTML = apiData?.report_html;
const p = reportRef.current.querySelectorAll('p');
const b = reportRef.current.querySelectorAll('b');
p[0].remove();
b[0].remove();
}
}, [apiData]);
// copy data to clipboard in formatted way when user clicks on copy btn in widget settings
useEffect(() => {
if (settings?.copyToClipboard === true && reportRef.current) {
navigator.clipboard.writeText(reportRef.current.innerText)
.then(() => {
alert('Copied to clipboard!');
})
.catch((err => {
console.error('Failed to copy: ', err);
}));
updateSettings(widgetID, {copyToClipboard: false});
}
}, [settings, widgetID, updateSettings]);
// download report
useEffect(() => {
if (settings?.downloadReport === true && reportRef.current) {
// 1. Create a blob object with text content and text/plain mime type
const blob = new Blob([reportRef.current.innerText || ""], { type: 'text/plain' });
// 2. Generate a temporary URL pointing to that blob
const fileUrl = URL.createObjectURL(blob);
// 3. Create a hidden anchor element
const linkElement = document.createElement('a');
linkElement.href = fileUrl;
linkElement.download = `comm_coor_report_${apiData?.start_date}_${apiData?.end_date}`; // Sets the target file name
linkElement.style.display = 'none';
// 4. Append to body, trigger click, and remove it immediately
document.body.appendChild(linkElement);
linkElement.click();
document.body.removeChild(linkElement);
// 5. Free up memory by revoking the blob URL
URL.revokeObjectURL(fileUrl);
updateSettings(widgetID, {downloadReport: false});
}
}, [settings, widgetID, updateSettings, apiData]);
if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="gray.800">
<Text color="red.400">{error}</Text>
</Flex>
);
if (!apiData) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Text textAlign="center" color="gray.400">No data</Text>
</Flex>
);
return (
<Flex direction="row" w="100%" h="100%" p={1} overflowY="auto">
<Text ref={reportRef}></Text>
</Flex>
)
}
@@ -2,9 +2,10 @@ import { useMemo } from 'react';
import { Box, Flex, Spinner, Text } from '@chakra-ui/react'; import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
import { useColorMode } from "@/components/ui/color-mode.jsx"; import { useColorMode } from "@/components/ui/color-mode.jsx";
import Plot from 'react-plotly.js'; 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 }) { export default function ExplosionEvents({ settings, widgetID }) {
// Plotly expects 'linear' for normal axes // Plotly expects 'linear' for normal axes
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear'; const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
@@ -12,8 +13,11 @@ export default function ExplosionEvents({ settings }) {
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const isDark = colorMode === 'dark'; const isDark = colorMode === 'dark';
// 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 // 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) // 1. Build the Traces (Data parsing)
const plotTraces = useMemo(() => { const plotTraces = useMemo(() => {
@@ -0,0 +1,204 @@
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 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]);
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 (some_data.length === 0) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text>
</Flex>
);
return (
<Flex direction="column"
w="100%" h="100%"
pos="relative"
justify="top" align="center"
p={2} gap={2}
overflow="auto"
borderRadius="xl"
bg="gray.800"
>
{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="gray.600"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Text truncate>{item.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{item.report_num}</Text>
</Flex>
</Flex>
</Collapsible.Trigger>
<Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Preparer: {item?.preparer}</Text>
</Flex>
{item?.files?.map((file, index) => {
return (
<Box w="100%" h="100%" key={index} p={1}>
<br />
<Flex mb={2} justify="space-between">
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
<Box borderRadius="lg" bg="gray.700" color="gray.300" pl={1} pr={1} fontSize={13} asChild>
<a href={`${file?.url}#view=FitV&toolbar=1`} target="_blank" rel="noopener noreferrer">
Open
</a>
</Box>
</Flex>
<object
data={`${file?.url}#view=FitH&toolbar=1`}
type="application/pdf"
style={{ width: '100%', height: '100%', display: 'block' }}
>
<Flex w="100%" h="100%" p={4} color="gray.400">
<Text mb={2} color="fg.muted">
Your browser cannot display this PDF directly.
</Text>
<a href={file?.url} target="_blank">
Download PDF
</a>
</Flex>
</object>
</Box>
)})}
</Box>
</Collapsible.Content>
</>
)}
</Collapsible.Context>
</Collapsible.Root>
)
else if (thisWidget?.size === "small") return (
<Flex direction="row"
w="100%"
justify="space-between" align="center"
p={1} pl={3} pr={3}
gap={2}
borderRadius="lg"
bg="gray.600"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Text truncate>{item.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" 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,143 @@
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";
export default function PlumeEvents({ settings, widgetID}) {
// 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: 'rgba(255, 255, 255, 0.7)' },
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]);
const layout = useMemo(() => {
return {
autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through
plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300
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 }
},
type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
gridcolor: '#4b5563',
zerolinecolor: '#4b5563',
automargin: true,
},
yaxis: {
title: {
text: 'Plume Height',
font: { color: 'white', size: 12 }
},
type: 'linear',
gridcolor: '#4b5563',
zerolinecolor: '#ffffff',
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
y0: 0,
y1: 0,
yref: 'y',
line: { color: 'white', width: 1 }
}
],
};
}, []);
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>
);
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>
);
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>
);
return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
<Plot
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,217 @@
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" borderRadius="md" bg="gray.800">
<Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" p={4} borderRadius="md" bg="gray.800">
<Text color="red.400">{error}</Text>
</Flex>
);
if (data.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No Remote Sensing data available this week.</Text>
</Flex>
);
return (
<Flex direction="column" w="100%" h="100%" pos="relative" justify="top" align="center" p={2} gap={2} overflow="auto" borderRadius="xl" bg="gray.800">
{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="gray.600"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Text truncate>{item?.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontSize="sm" fontWeight="semibold">
{item?.report_time}
</Text>
</Flex>
</Flex>
</Collapsible.Trigger>
<Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Source: {item?.sender}</Text>
</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="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PDF</Text>
</Flex>
<object
data={`${file?.url}#view=FitH&toolbar=1`}
type="application/pdf"
style={{width: '100%', height: '100%', display: 'block'}}
>
<Flex w="100%" h="100%" p={4} color="gray.400">
<Text mb={2} color="fg.muted">
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="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PNG</Text>
</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="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{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="gray.600">
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Text truncate>{item?.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text
truncate
pl={2} pr={2}
borderRadius="lg"
bg="gray.800/50" color="gray.100"
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,143 @@
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";
export default function SeismicEvents({ settings, widgetID}) {
// 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: 'rgba(255, 255, 255, 0.7)' },
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]);
const layout = useMemo(() => {
return {
autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through
plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300
margin: { t: 40, r: 20, l: 20, b: 20 },
// title: {
// text: '',
// font: { size: 16, color: 'white' },
// x: 0.05
// },
xaxis: {
title: {
text: 'Date (UTC)',
font: { color: 'white', size: 12 }
},
type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
gridcolor: '#4b5563',
zerolinecolor: '#4b5563',
automargin: true,
},
yaxis: {
title: {
text: 'Seismic Count',
font: { color: 'white', size: 12 }
},
type: 'linear',
gridcolor: '#4b5563',
zerolinecolor: '#ffffff',
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
y0: 0,
y1: 0,
yref: 'y',
line: { color: 'white', width: 1 }
}
],
};
}, []);
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>
);
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>
);
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>
);
return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
<Plot
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>
)
}
@@ -3,7 +3,7 @@ import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
import Plot from 'react-plotly.js'; import Plot from 'react-plotly.js';
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
export default function VaaActivity7Day({ settings }) { export default function VaaActivity7Day({ settings, widgetID }) {
console.log("3. Chart received settings:", settings); console.log("3. Chart received settings:", settings);
// Plotly expects 'linear' for normal axes // Plotly expects 'linear' for normal axes
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear'; const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
+212
View File
@@ -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='&copy; 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,36 @@
import {Flex, Slider, Switch} from "@chakra-ui/react";
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
return (
<Flex flexDirection="column" justifyContent="space-between" gap={2} pb={2}>
{/* SLIDER DATE RANGE SELECTION */}
<Slider.Root
width="80%" h="100%"
min={1} max={14}
defaultValue={[7]}
step={1}
onValueChangeEnd={(e) => {
const day = new Date(Date.now() - (e.value[0])*86400000).toLocaleDateString('en-CA');
console.log("new start: ", day);
updateSettings(widgetId, {...currentSettings, startDate: day});
}}
>
<Slider.Label>{currentSettings?.endDate} to {currentSettings?.startDate}</Slider.Label>
<Slider.Control>
<Slider.Track>
<Slider.Range />
</Slider.Track>
<Slider.Thumbs />
<Slider.MarkerGroup p={3}>
<Slider.Marker value={1} whiteSpace="nowrap">today</Slider.Marker>
<Slider.Marker value={7} whiteSpace="nowrap">1 week ago</Slider.Marker>
<Slider.Marker value={14} whiteSpace="nowrap">2 weeks ago</Slider.Marker>
</Slider.MarkerGroup>
</Slider.Control>
</Slider.Root>
</Flex>
)
}
@@ -0,0 +1,83 @@
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import {useState} from "react";
export default function CommCoorReportSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
const [startError, setStartError] = useState(null);
const [endError, setEndError] = useState(null);
const handleCopyToClipboard = () => {
updateSettings(widgetId, {copyToClipboard: true});
}
const handleDownloadReport = () => {
updateSettings(widgetId, {downloadReport: true});
}
return (
<Flex flexDirection="column" justifyContent="space-between" gap={2} overflowY="auto">
{/* COPY TO CLIPBOARD */}
<Button w="fit" size="xs" onClick={handleCopyToClipboard}>Copy Report</Button>
{/* DOWNLOAD REPORT */}
<Button w="fit" size="xs" onClick={handleDownloadReport}>Download Report</Button>
{/* SELECTING DATE RANGE */}
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
<Field.Root invalid={!!startError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%"
type="date"
value={currentSettings?.startDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {...currentSettings, startDate: value});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
else setStartError("");
}}
/>
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
<Field.Root invalid={!!endError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%"
type="date"
value={currentSettings?.endDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {...currentSettings, endDate: value});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
else setEndError("");
}}
/>
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex>
</Flex>
)
}
@@ -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 { useDashboardStore } from "@/store/useDashboardStore.jsx";
import {useState} from "react";
export default function ExplosionEventsSettings({ widgetId, currentSettings }) { export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings); 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 // 1. Convert the saved string into a boolean for the toggle UI
const isSymlog = currentSettings?.yAxisScale === 'log'; const isSymlog = currentSettings?.yAxisScale === 'log';
@@ -18,20 +21,75 @@ export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
}; };
return ( return (
<Box> <Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
<Text fontWeight="bold" mb={3} fontSize="sm">Y-Axis Settings</Text> {/* Y-AXIS SETTING*/}
<Text fontWeight="bold" mb={3} fontSize="sm">Y-Axis Settings</Text>
<Switch.Root <Switch.Root
checked={isSymlog} checked={isSymlog}
onCheckedChange={handleToggle} onCheckedChange={handleToggle}
colorPalette="blue" colorPalette="blue"
> >
<Switch.HiddenInput /> <Switch.HiddenInput />
<Switch.Control> <Switch.Control>
<Switch.Thumb /> <Switch.Thumb />
</Switch.Control> </Switch.Control>
<Switch.Label>Use Log Scale</Switch.Label> <Switch.Label>Use Log Scale</Switch.Label>
</Switch.Root> </Switch.Root>
</Box>
{/* 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,108 @@
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
import {useDashboardStore} from "../../../store/useDashboardStore.jsx";
import {useState} from "react";
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
const [startError, setStartError] = useState(null);
const [endError, setEndError] = useState(null);
const handleCopyToClipboard = () => {
updateSettings(widgetId, {copyToClipboard: true});
}
const 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">
{/* COPY TO CLIPBOARD */}
<Button w="fit" size="xs" onClick={handleCopyToClipboard}>Copy All IVANS</Button>
{/* SORTS */}
<Switch.Root
checked={currentSettings?.activeSorts?.timeSort === "asc"}
onCheckedChange={handleTimeSort}
colorPalette="blue"
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort Oldest to Newest</Switch.Label>
</Switch.Root>
<Switch.Root
checked={currentSettings?.activeSorts?.volcanoSort}
onCheckedChange={handleVolcanoSort}
colorPalette="blue"
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort by Volcano</Switch.Label>
</Switch.Root>
{/* SELECTING DATE RANGE */}
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
<Field.Root invalid={!!startError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%"
type="date"
value={currentSettings?.startDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {...currentSettings, startDate: value});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
else setStartError("");
}}
/>
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
<Field.Root invalid={!!endError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%"
type="date"
value={currentSettings?.endDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {...currentSettings, endDate: value});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
else setEndError("");
}}
/>
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex>
</Flex>
)
}
@@ -0,0 +1,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,108 @@
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import {useState} from "react";
export default function RemoteSensingSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
const [startError, setStartError] = useState(null);
const [endError, setEndError] = useState(null);
const handleCopyToClipboard = () => {
updateSettings(widgetId, {copyToClipboard: true});
}
const 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">
{/* COPYING TO CLIPBOARD */}
<Button w="fit" size="xs" onClick={handleCopyToClipboard}>Copy All RS</Button>
{/* SORTING */}
<Switch.Root
checked={currentSettings?.activeSorts?.timeSort === "asc"}
onCheckedChange={handleTimeSort}
colorPalette="blue"
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort Oldest to Newest</Switch.Label>
</Switch.Root>
<Switch.Root
checked={currentSettings?.activeSorts?.volcanoSort}
onCheckedChange={handleVolcanoSort}
colorPalette="blue"
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort by Volcano</Switch.Label>
</Switch.Root>
{/* SELECTING DATE RANGE */}
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
<Field.Root invalid={!!startError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%"
type="date"
value={currentSettings?.startDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {...currentSettings, startDate: value});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
else setStartError("");
}}
/>
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
<Field.Root invalid={!!endError}>
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
w="100%"
type="date"
value={currentSettings?.endDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {...currentSettings, endDate: value});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
else setEndError("");
}}
/>
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex>
</Flex>
)
}
@@ -0,0 +1,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>
)
}
+143 -356
View File
@@ -1,375 +1,162 @@
import {useState, useMemo, useRef, useEffect, useCallback} from "react"; import { useState, useMemo, useRef } from "react";
import { import {
Button, Button,
Box, IconButton,
Text, Box,
Flex, Text,
Grid, Flex,
Dialog, Grid,
Tabs, Dialog,
Badge, Tabs,
Menu, Badge,
HStack, Menu,
useRecipe, HStack
useSlotRecipe
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import {Settings, LayoutGrid, Move, Save, X, ChevronRight, ChevronLeft} from 'lucide-react'; import { Settings, Plus, LayoutGrid, Move, Save, X } from 'lucide-react';
import {useDashboardStore} from "../../store/useDashboardStore.jsx"; import { useDashboardStore } from "../../store/useDashboardStore.jsx";
import {WIDGET_REGISTRY} from "../../constants/widgetRegistry.jsx"; import { WIDGET_REGISTRY } from "../../constants/widgetRegistry.jsx";
// 🚨 NEW: Tiny helper to convert size keys into abbreviations
const getSizeAbbreviation = (key) => {
const mapping = {small: "S", medium: "M", large: "L", xlarge: "XL"};
return mapping[key.toLowerCase()] || key.charAt(0).toUpperCase();
};
export function SettingsButton() { export function SettingsButton() {
// 1. ☁Store connections // 1. ☁Store connections
const isEditing = useDashboardStore(state => state.isEditing); const isEditing = useDashboardStore(state => state.isEditing);
const enterEditMode = useDashboardStore(state => state.enterEditMode); const enterEditMode = useDashboardStore(state => state.enterEditMode);
const saveEdit = useDashboardStore(state => state.saveEdit);
const cancelEdit = useDashboardStore(state => state.cancelEdit);
const addWidget = useDashboardStore(state => state.addWidget);
const recipe = useRecipe({key: "glassTab"}); // This now handles both local UI lock AND database saving!
const tabStyles = recipe(); const saveEdit = useDashboardStore(state => state.saveEdit);
// Pull the glass container styles for the dropdown and modal const cancelEdit = useDashboardStore(state => state.cancelEdit);
const panelRecipe = useSlotRecipe({key: "glassPanel"}); const addWidget = useDashboardStore(state => state.addWidget);
const panelStyles = panelRecipe({layout: "dropdown"});
const modalStyles = panelRecipe({layout: "popover"});
const [isLibraryOpen, setIsLibraryOpen] = useState(false); const [isLibraryOpen, setIsLibraryOpen] = useState(false);
// ⬅️ ➡️ This is the callbacks and scroll control to add the chevron arrows in the tab list // Smart Add Handler
const tabsRef = useRef(null); const handleAddWidget = (widgetId, sizeKey) => {
const [canScrollLeft, setCanScrollLeft] = useState(false); enterEditMode();
const [canScrollRight, setCanScrollRight] = useState(false); addWidget(widgetId, sizeKey);
};
const checkScroll = useCallback(() => { const categorizedWidgets = useMemo(() => {
if (tabsRef.current) { return Object.entries(WIDGET_REGISTRY).reduce((acc, [id, widget]) => {
const {scrollLeft, scrollWidth, clientWidth} = tabsRef.current; const cat = widget.category || 'General';
setCanScrollLeft(scrollLeft > 0); if (!acc[cat]) acc[cat] = [];
// We use -1 here to account for sub-pixel rendering in some browsers acc[cat].push({ id, ...widget });
setCanScrollRight(Math.ceil(scrollLeft + clientWidth) < scrollWidth - 1); return acc;
} }, {});
}, []); }, []);
useEffect(() => { const categories = Object.keys(categorizedWidgets);
if (isLibraryOpen) {
// Small timeout ensures the DOM has rendered the tabs before measuring
setTimeout(checkScroll, 50);
window.addEventListener('resize', checkScroll);
return () => window.removeEventListener('resize', checkScroll);
}
}, [isLibraryOpen, checkScroll]);
// Smart Add Handler // use ref to capture html node for placing menu accordingly
const handleAddWidget = (widgetId, sizeKey) => { const menuRef = useRef(null);
enterEditMode(); const getAnchorRect = () => {
addWidget(widgetId, sizeKey); if (!menuRef.current) return null;
}; return menuRef.current.getBoundingClientRect();
};
const categorizedWidgets = useMemo(() => { return (
return Object.entries(WIDGET_REGISTRY).reduce((acc, [id, widget]) => { <>
const cat = widget.category || 'General'; {/* THE STATIC DROPDOWN MENU */}
if (!acc[cat]) acc[cat] = []; <Menu.Root positioning={{ getAnchorRect }}>
acc[cat].push({id, ...widget}); <Menu.Trigger asChild>
return acc; <IconButton
}, {}); ref={menuRef}
}, []); aria-label="Settings"
bg="vdap.darkGreen"
const categories = Object.keys(categorizedWidgets); color="white"
size="sm"
// use ref to capture html node for placing menu accordingly borderRadius="md"
const menuRef = useRef(null); _hover={{ bg: "vdap.darkGreenHover" }}
const getAnchorRect = () => {
if (!menuRef.current) return null;
return menuRef.current.getBoundingClientRect();
};
return (
<>
{isEditing ? (
// 📝 EDIT MODE: The 3-Pill Layout
<HStack gap={2}>
{/* 1. The Add Widget Button */}
<Box
as="button"
css={tabStyles}
data-state="open"
onClick={() => setIsLibraryOpen(true)}
color={{base: "blue.500", _dark: "blue.300"}}
_hover={{bg: "rgba(66, 153, 225, 0.1)"}}
>
<LayoutGrid size={20} style={{flexShrink: 0}}/>
<Box as="span">Widget Library</Box>
</Box>
{/* 2. The Save Button */}
<Box
as="button"
css={tabStyles}
data-state="open"
onClick={saveEdit}
color={{base: "green.600", _dark: "green.400"}}
_hover={{bg: "rgba(72, 187, 120, 0.1)"}}
>
<Save size={20} style={{flexShrink: 0}}/>
<Box as="span">Save Layout</Box>
</Box>
{/* 3. The Discard Button */}
<Box
as="button"
css={tabStyles}
data-state="open"
onClick={cancelEdit}
color={{base: "red.500", _dark: "red.400"}}
_hover={{bg: "rgba(245, 101, 101, 0.1)"}}
>
<X size={20} style={{flexShrink: 0}}/>
<Box as="span">Discard</Box>
</Box>
</HStack>
) : (
// 👀 VIEW MODE: The Static Dropdown Menu
<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 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.Item
onClick={enterEditMode}
cursor="pointer"
_hover={{WebkitTextStroke: "0.5px currentColor"}}
>
<Move size={16} style={{marginRight: '8px'}}/> Edit Layout
</Menu.Item>
<Menu.Separator my={1} borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}/>
<Menu.Item
onClick={() => setIsLibraryOpen(true)}
cursor="pointer"
_hover={{
bg: {base: "rgba(0, 0, 0, 0.05)", _dark: "rgba(255, 255, 255, 0.1)"},
WebkitTextStroke: "0.5px currentColor"
}}
>
<LayoutGrid size={16} style={{marginRight: '8px'}}/> Widget Library
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
)}
{/* 📚📚📚 THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
<Dialog.Root
open={isLibraryOpen}
onOpenChange={(e) => setIsLibraryOpen(e.open)}
size="xl"
placement="center"
closeOnInteractOutside={true}
closeOnEscapeKeyDown={true}
>
{/* Soft blur can be added to the background behind the modal */}
{/*<Dialog.Backdrop bg="blackAlpha.100" backdropFilter="blur(2px)"/>*/}
<Dialog.Positioner>
<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>
<Dialog.Body p={0}>
<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 */}
{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 */} <Settings size={18} />
<ChevronLeft size={18} style={{ marginLeft: "-30px" }}/> </IconButton>
</Flex> </Menu.Trigger>
)} <Menu.Positioner>
<Tabs.List <Menu.Content bg="white" boxShadow="lg" borderRadius="md" p={1} zIndex="dropdown">
ref={tabsRef} {isEditing ? (
onScroll={checkScroll} <>
bg={{base: "rgba(0, 0, 0, 0.04)", _dark: "rgba(255, 255, 255, 0.05)"}} {/* WIRE DIRECTLY TO saveEdit */}
p={1} <Menu.Item onClick={saveEdit} color="green.600" fontWeight="bold" cursor="pointer" _hover={{ bg: "green.50" }}>
borderRadius="full" <Save size={16} style={{ marginRight: '8px' }} /> Save Layout
display="flex" </Menu.Item>
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;
}
}}
>
{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",
}}
>
{cat}
</Tabs.Trigger>
))}
</Tabs.List>
{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! <Menu.Item onClick={cancelEdit} color="red.500" cursor="pointer" _hover={{ bg: "red.50" }}>
WebkitMaskImage="radial-gradient(circle at 0% 50%, transparent 30px, black 19px)" <X size={16} style={{ marginRight: '8px' }} /> Discard Changes
maskImage="radial-gradient(circle at 0% 50%, transparent 30px, black 35px)" </Menu.Item>
</>
) : (
<Menu.Item onClick={enterEditMode} cursor="pointer" _hover={{ bg: "gray.100" }}>
<Move size={16} style={{ marginRight: '8px' }} /> Edit Layout
</Menu.Item>
)}
color={{base: "gray.600", _dark: "gray.300"}} <Menu.Separator my={1} borderColor="gray.200" />
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 right to optically center it in the crescent mass */}
<ChevronRight size={18} style={{ marginRight: "-30px" }}/>
</Flex>
)}
</Box> <Menu.Item onClick={() => setIsLibraryOpen(true)} cursor="pointer" _hover={{ bg: "gray.100" }}>
{categories.map(cat => ( <LayoutGrid size={16} style={{ marginRight: '8px' }} /> Widget Library
<Tabs.Content key={cat} value={cat} p={6}> </Menu.Item>
<Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}> </Menu.Content>
{categorizedWidgets[cat].map(widget => ( </Menu.Positioner>
<Flex </Menu.Root>
key={widget.id}
direction="column"
bg={{base: "rgba(255, 255, 255, 0.6)", _dark: "rgba(0, 0, 0, 0.2)"}}
p={4}
borderRadius="xl"
border="1px solid"
borderColor={{base: "rgba(255, 255, 255, 0.9)", _dark: "whiteAlpha.200"}}
boxShadow="sm"
>
<Text fontWeight="bold" fontSize="md" mb={1}>{widget.name}</Text>
<Text fontSize="sm" color="gray.500" mb={4} flex="1">{widget.description}</Text>
<Box borderTop="1px solid" borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}} {/* THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
pt={3}> <Dialog.Root open={isLibraryOpen} onOpenChange={(e) => setIsLibraryOpen(e.open)} size="xl" placement="center">
<Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase" <Dialog.Backdrop bg="blackAlpha.600" />
mb={2}> <Dialog.Positioner>
Add to Dashboard: <Dialog.Content bg="gray.50" borderRadius="xl" overflow="hidden" boxShadow="xl" maxW="800px">
</Text> <Dialog.Header bg="vdap.darkGreen" color="white" py={4}>
<HStack gap={2} flexWrap="wrap"> <Dialog.Title fontSize="xl" fontWeight="bold">Add Dashboard Widgets</Dialog.Title>
{Object.keys(widget.allowedSizes).map(sizeKey => ( </Dialog.Header>
<Box
key={sizeKey}
as="button"
css={tabStyles}
onClick={() => handleAddWidget(widget.id, sizeKey)}
color={{base: "blue.600", _dark: "blue.300"}}
>
{/* THE ABBREVIATION ICON: Always visible */}
<Flex
align="center"
justify="center"
w="24px"
h="24px"
flexShrink={0}
borderRadius="full"
bg={{base: "blue.50", _dark: "whiteAlpha.200"}}
fontWeight="black"
fontSize="xs"
bg='transparent'
>
{getSizeAbbreviation(sizeKey)}
</Flex>
{/* THE EXPANDED TEXT: Only visible on hover */} <Dialog.Body p={0}>
<Box as="span">Add Widget</Box> <Tabs.Root defaultValue={categories[0]} variant="enclosed" colorScheme="blue">
</Box> <Tabs.List bg="white" px={4} pt={4} borderBottom="1px solid" borderColor="gray.200">
))} {categories.map(cat => (
</HStack> <Tabs.Trigger key={cat} value={cat} pb={2}>{cat}</Tabs.Trigger>
</Box> ))}
</Flex> </Tabs.List>
))}
</Grid> {categories.map(cat => (
</Tabs.Content> <Tabs.Content key={cat} value={cat} p={6}>
))} <Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}>
</Tabs.Root> {categorizedWidgets[cat].map(widget => (
</Dialog.Body> <Flex key={widget.id} direction="column" bg="white" p={4} borderRadius="md" border="1px solid" borderColor="gray.200" boxShadow="sm">
<Dialog.CloseTrigger position="absolute" top={3} right={3} <Text fontWeight="bold" fontSize="md" mb={1}>{widget.name}</Text>
color={{base: "gray.500", _dark: "whiteAlpha.800"}} _hover={{color: "red.400"}}/> <Text fontSize="sm" color="gray.500" mb={4} flex="1">{widget.description}</Text>
</Dialog.Content>
</Dialog.Positioner> <Box borderTop="1px solid" borderColor="gray.100" pt={3}>
</Dialog.Root> <Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase" mb={2}>Add to Grid:</Text>
</> <HStack gap={2} flexWrap="wrap">
); {Object.keys(widget.allowedSizes).map(sizeKey => (
<Badge
key={sizeKey}
as="button"
onClick={() => handleAddWidget(widget.id, sizeKey)}
colorScheme="gray"
variant="subtle"
px={3}
py={1}
borderRadius="full"
cursor="pointer"
_hover={{ bg: "blue.50", color: "blue.600", transform: "scale(1.05)" }}
transition="all 0.2s"
>
<Plus size={12} style={{ display: 'inline', marginRight: '4px' }}/>
{sizeKey.charAt(0).toUpperCase() + sizeKey.slice(1)}
</Badge>
))}
</HStack>
</Box>
</Flex>
))}
</Grid>
</Tabs.Content>
))}
</Tabs.Root>
</Dialog.Body>
<Dialog.CloseTrigger position="absolute" top={3} right={3} color="white" />
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
</>
);
} }
@@ -0,0 +1,79 @@
import Widget from "./Widget.jsx";
import { Fieldset, NativeSelect, For, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { Asterisk } from 'lucide-react';
export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
const { register, handleSubmit, reset, formState: {errors} } = useForm();
const [isSubmit, setIsSubmit] = useState(false);
const onSubmit = (data) => {
setIsSubmit(true);
reset();
}
// Prop Forwarding
const widgetProps = { id, isEditable, isDelete, DeleteWidget, isSubmit, setIsSubmit }
return (
<Widget title="Alert Level" {...widgetProps} >
{!isSubmit ? (
// DISPLAYING INPUT FIElDS AND LABELS
<Box
as="form"
onSubmit={handleSubmit(onSubmit)}
width="100%"
p={3}
display="flex"
flexDir="column"
>
<Fieldset.Root size="md" maxW="md">
<Fieldset.Content>
<Field.Root>
<Field.Label fontWeight="semibold">Volcano</Field.Label>
<Input name="volcanoname" />
</Field.Root>
<Field.Root>
<Field.Label fontWeight="semibold">Country</Field.Label>
<NativeSelect.Root>
<NativeSelect.Field name="country">
<For each={["United Kingdom", "Canada", "United States"]}>
{(item) => (
<option key={item} value={item}>
{item}
</option>
)}
</For>
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
</Field.Root>
</Fieldset.Content>
<Button type="submit" size="sm">Submit</Button>
</Fieldset.Root>
</Box>
):(
// DISPLAYING ALERT LEVEL DATA
<Box p={1} mt={1} ml="auto" mr="auto" width="200px">
<Box
bg="bg"
shadow="md"
borderRadius="md"
p={8}
width="200px"
height="10px"
textAlign="center"
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontsize="2xl" fontWeight="bold" color="gray.700">Alert Level Data Coming Soon.</Text>
</Box>
</Box>
)}
</Widget>
);
}
@@ -0,0 +1,7 @@
import { Button } from "@chakra-ui/react";
export function CancelButton ({onCancel}) {
return (
<Button onClick={onCancel} borderRadius="md">Cancel</Button>
)
}
@@ -0,0 +1,39 @@
// DEPRECATED
// import GlobalMap from "./views/GlobalMap";
// import RegionalMap from "./views/RegionalMap";
// import Volcano from "./views/Volcano";
// import Admin from "./views/Admin";
// import Account from "./views/Account"
// import Home from "./views/Home";
// import { DASHBOARD_VIEWS, ADMIN_VIEWS, VOLCANO_VIEWS } from "@/constants/viewKeys";
// import { chakra } from "@chakra-ui/react";
// import Personal from "./views/Personal";
// import Security from "./views/Security";
//
// export default function Canvas(props) {
// const { view } = props;
//
// return (
// <chakra.div width="100%" height="100%">
// {/* Check if view is a tab inside the Home Header */}
// { DASHBOARD_VIEWS.includes(view) && (
// <Home {...props}/>
// )}
// {/* Check if view is a tab inside the Volcano Header */}
// { VOLCANO_VIEWS.includes(view) && (
// <Volcano {...props}/>
// )}
// {/* Check if view is a tab inside the Admin Header */}
// { ADMIN_VIEWS.includes(view) && (
// <Admin {...props}/>
// )}
// {view === "global" && <GlobalMap />}
// {view === "regional" && <RegionalMap />}
// {view === "account" && <Account />}
// {view === "settings" && <Personal />}
// {view === "personal" && <Personal />}
// {view === "security" && <Security />}
// </chakra.div>
// );
// }
@@ -0,0 +1,27 @@
import { MoveDiagonal2 } from 'lucide-react';
import React from "react";
const CustomResizeHandle = React.forwardRef((props, ref) => {
const { handleAxis, isVisable, ...restProps } = props;
if (!isVisable) return null;
return (
<div
ref={ref}
{...restProps}
className={`resize-hande-${handleAxis}`}
style={{
position: 'absolute',
bottom: 0,
right: 0,
cursor: "pointer",
width: "20px",
height: "20px",
zIndex: "1",
}}
>
<MoveDiagonal2 />
</div>
);
});
export default CustomResizeHandle;
@@ -0,0 +1,95 @@
// DEPRECATED
// import Header from './Header/Header.jsx';
// import Sidebar from './Sidebar/Sidebar.jsx';
// import Canvas from './Canvas/Canvas.jsx';
// import { Grid, GridItem, useRecipe, useDialog } from '@chakra-ui/react';
// import { useState } from 'react';
// import DialogPopup from './Canvas/CanvasComponents/DialogPopup.jsx';
//
// export default function Dashboard() {
// const recipe = useRecipe({ key: "dashboard" });
// const styles = recipe();
//
// const [darkMode, setDarkMode] = useState(false);
// const [isEditable, setIsEditable] = useState(false); // editable=true means static=false (vice versa)
// const [isDelete, setIsDelete] = useState(false);
// const [originalLayout, setOriginalLayout] = useState([]);
// const [originalWidgets, setOriginalWidgets] = useState([]);
// const [view, setView] = useState("mydashboard");
// const [layout, setLayout] = useState([]);
// const [widgetArray, setWidgetArray] = useState([]);
// const dialog = useDialog();
//
// const beginEditIfNeeded = () => {
// if ( !isEditable ) {
// setOriginalLayout(layout);
// setOriginalWidgets(widgetArray);
// }
// };
//
// const onCancel = () => {
// console.log("canceling changes...");
// console.log(originalWidgets);
// if (originalLayout.length) {
// setLayout(originalLayout.map(item => ({
// ...item,
// static: true
// })));
// };
// setWidgetArray(originalWidgets);
// setIsEditable(false);
// setIsDelete(false);
// setOriginalLayout([]);
// setOriginalWidgets([]);
// }
//
// // Prevent navigation while in Edit mode without saving or cancelling
// const handleViewChange = (nextView) => {
// console.log("1. handling view change")
// if (view === "mydashboard" && isEditable) {
// console.log("2. currently editing");
// dialog.setOpen(true);
// return;
//
// // const confirmed = window.confirm(
// // "You have unsaved changes. Discard them and continue?"
// // );
// // if (!confirmed) return;
// // onCancel();
// }
// setView(nextView);
// };
//
// const onLayoutChange = (newLayout) => setLayout(newLayout);
//
// // Prop Forwarding
// const canvasProps = { view, layout, setLayout, setOriginalLayout, onLayoutChange, onChangeView: handleViewChange,
// onCancel, isEditable, setIsEditable, isDelete, setIsDelete, widgetArray, setWidgetArray, originalWidgets, setOriginalWidgets, beginEditIfNeeded
// };
// const sidebarProps = { view, onChangeView: handleViewChange, darkMode, setDarkMode };
// const headerProps = { onChangeView: handleViewChange };
//
// return (
// <>
// <DialogPopup dialog={dialog} />
// <Grid css={styles}
// templateRows="1fr 11fr"
// // templateColumns="1fr 20fr"
// templateColumns="auto 1fr"
// height="100vh"
// width="100vw"
// >
// <GridItem rowSpan={1} colSpan={2}>
// <Header {...headerProps}/>
// </GridItem>
// <GridItem colSpan={1}>
// <Sidebar {...sidebarProps}/>
// </GridItem>
// <GridItem colSpan={1} width="100%" overflow="auto">
// <Canvas {...canvasProps}/>
// </GridItem>
// </Grid>
// </>
// );
// }
@@ -0,0 +1,9 @@
import { Button } from "@chakra-ui/react";
export default function DeleteAllButton ({onDeleteAll}) {
return (
<Button onClick={onDeleteAll} color="#FFFFFF" bg="#EF4444" _hover={{ bg: "bg.error", color: "#EF4444"}}>
Delete All
</Button>
)
}
@@ -0,0 +1,57 @@
import { Box, Menu, Portal } from '@chakra-ui/react'
import AlertLevel from "@/components/deprecated/AlertLevelWidget.jsx";
export default function FirstWidgetButton({ appendWidget }) {
const triggerItem = (
<>
<Menu.Trigger asChild>
<Box
p={6}
m="auto"
mt={6}
align="center"
textAlign="center"
justify="center"
width="400px"
borderRadius="md"
boxShadow="md"
border="2px dashed"
borderColor="gray.300"
bg="gray.50"
cursor="pointer"
_hover = {{
bg: "gray.100",
borderColor: "gray.400",
boxShadow: "lg",
}}
>
Add Your First Widget
</Box>
</Menu.Trigger>
</>
);
const positioning = { placement: "bottom-middle" };
// const WidgetMenuProps = { appendWidget, triggerItem, positioning };
return (
// <WidgetMenu {...WidgetMenuProps} />
<Menu.Root positioning={positioning}>
{triggerItem}
<Portal>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="AlertLevel"
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
>
Alert Level
</Menu.Item>
<Menu.Item value="CO2">CO2</Menu.Item>
<Menu.Item value="Ivans">IVANS</Menu.Item>
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
}
@@ -0,0 +1,7 @@
import { Button } from "@chakra-ui/react";
export function SaveButton ({onSave}) {
return (
<Button onClick={onSave} borderRadius="md" bg="primary" _hover={{bg: "primaryLt"}}>Save</Button>
)
}
@@ -0,0 +1,32 @@
import { useSlotRecipe, chakra, Flex, CloseButton, Text, IconButton } from "@chakra-ui/react"
import { Undo } from 'lucide-react';
export default function Widget(props) {
// deconstructing props
const { id, title, isEditable, isDelete, DeleteWidget, isSubmit, setIsSubmit, children } = props;
const recipe = useSlotRecipe({ key: "widget" });
const styles = recipe();
return (
<Flex flexDirection="column" w="100%" h="auto">
{/* HEADER */}
<Flex css={styles.header} cursor={isEditable ? "move":"default"} flexDirection="row" w="100%" h="auto" justifyContent="space-between" alignItems="center">
<Text className="widget-handle" w="100%" display="flex" alignItems="center" height="32px" fontWeight="bold">{title}</Text>
{isDelete ? (
<CloseButton onClick={() => DeleteWidget(id)} size="xs" _hover={{bg: "tomato", color: "white"}}/>
):(
isSubmit ? (
<IconButton onClick={() => setIsSubmit(false)} size="xs" bg="base300" cursor="pointer"><Undo fill="text" /></IconButton>
):null
)}
</Flex>
{/* CHILDREN */}
<Flex w="100%" h="auto">
{children}
</Flex>
</Flex>
);
}
@@ -0,0 +1,26 @@
import { Menu, Portal } from "@chakra-ui/react";
import AlertLevel from "./AlertLevelWidget.jsx";
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
return (
<Menu.Root positioning={positioning}>
{triggerItem}
<Portal>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="AlertLevel"
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
>
Alert Level
</Menu.Item>
<Menu.Item value="CO2">CO2</Menu.Item>
<Menu.Item value="Ivans">IVANS</Menu.Item>
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
)
}
@@ -0,0 +1,35 @@
import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react'
import * as React from 'react'
export const Tooltip = React.forwardRef(function Tooltip(props, ref) {
const {
showArrow,
children,
disabled,
portalled = true,
content,
contentProps,
portalRef,
...rest
} = props
if (disabled) return children
return (
<ChakraTooltip.Root {...rest}>
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
<Portal disabled={!portalled} container={portalRef}>
<ChakraTooltip.Positioner>
<ChakraTooltip.Content ref={ref} {...contentProps}>
{showArrow && (
<ChakraTooltip.Arrow>
<ChakraTooltip.ArrowTip />
</ChakraTooltip.Arrow>
)}
{content}
</ChakraTooltip.Content>
</ChakraTooltip.Positioner>
</Portal>
</ChakraTooltip.Root>
)
})
+12 -7
View File
@@ -2,7 +2,6 @@ import { useRecipe, Button, Stack, Text, Flex } from "@chakra-ui/react"
import UserAvatarMenu from "./UserAvatarMenu.jsx"; import UserAvatarMenu from "./UserAvatarMenu.jsx";
import { NavLink } from "react-router-dom"; import { NavLink } from "react-router-dom";
import useAuthStore from "@/store/authStore"; import useAuthStore from "@/store/authStore";
import {SearchBar} from "@/components/dashboard/SearchBar.jsx";
// Import the asset properly so Vite can bundle it for any server // Import the asset properly so Vite can bundle it for any server
// import defaultAvatar from "@/assets/user_profile.svg"; // import defaultAvatar from "@/assets/user_profile.svg";
@@ -14,19 +13,25 @@ export default function Header() {
const { user } = useAuthStore(); const { user } = useAuthStore();
console.log(user); console.log(user);
const displayName = user ? (user.firstName || user.username) : "Loading..."; const displayName = user ? (user.fullName || user.username) : "Loading...";
const avatarname = user ? (user.fullName || user.username) : "Loading...";
const displayEmail = user?.email || "LDAP User"; const displayEmail = user?.email || "LDAP User";
return ( return (
<Flex css={styles} > <Flex css={styles} justify="space-between" align="center" width="100%">
<SearchBar placeholder="Enter a volcano to search" size="sm" width="410px" /> {/* Home Link Logo */}
<Button as={NavLink} to="/home" color="color" variant="plain" textStyle="5xl" fontWeight="bold">
VDAP
</Button>
{/* User Profile Section */} {/* User Profile Section */}
<Flex align="center" gap={3} pr={6}> <Flex align="center" gap={3} pr={6}>
<Text fontWeight="bold" textStyle="md" >{displayName}</Text> <Stack gap="0" textAlign="right" cursor="default">
<Text color="color" fontWeight="bold" textStyle="lg">{displayName}</Text>
<Text color="fg.muted" textStyle="sm">{displayEmail}</Text>
</Stack>
<UserAvatarMenu <UserAvatarMenu
name={avatarname} name={displayName}
// avatar={user.avatar} // avatar={user.avatar}
/> />
</Flex> </Flex>
+52 -53
View File
@@ -1,24 +1,22 @@
import {useSlotRecipe, Box, Button, IconButton} from "@chakra-ui/react"; import { useSlotRecipe, Box, Button, IconButton } from "@chakra-ui/react";
import {ColorModeButton} from "@/components/ui/color-mode.jsx"; import { ColorModeButton } from "@/components/ui/color-mode.jsx";
import {useState} from "react"; import { useState } from "react";
import {NavLink} from "react-router-dom"; import { NavLink } from "react-router-dom";
import Tooltip from "../ui/Tooltip.jsx"; import Tooltip from "../ui/Tooltip.jsx";
import {Mountain, House, Earth, MapPin, ShieldUser, Menu, PanelLeftClose, PanelLeftOpen} from 'lucide-react'; import { Mountain, House, Earth, MapPin, ShieldUser, Menu } from 'lucide-react';
const SIDEBAR_DEFAULT_BUTTONS = [ const SIDEBAR_DEFAULT_BUTTONS = [
{icon: House, name: "Home", url: "/home"}, { icon: House, name: "Home", url: "/home" },
{icon: Earth, name: "Global Map", url: "/global-map"}, { icon: Earth, name: "Global Map", url: "/global-map" },
{icon: MapPin, name: "Regional Map", url: "/regional-map"}, { icon: MapPin, name: "Regional Map", url: "/regional-map" },
{icon: Mountain, name: "Volcano", url: "/volcano"}, { icon: Mountain, name: "Volcano", url: "/volcano" },
{icon: ShieldUser, name: "Admin", url: "/admin"} // TODO remove in the future as default should not have admin { icon: ShieldUser, name: "Admin", url: "/admin" } // TODO remove in the future as default should not have admin
]; ];
export default function Sidebar() { export default function Sidebar() {
const [isOpen, setIsOpen] = useState(true); const [isOpen, setIsOpen] = useState(true);
const recipe = useSlotRecipe({ key: "sidebar" });
const recipe = useSlotRecipe({key: "sidebar"}); const styles = recipe();
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 // 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;
@@ -26,50 +24,51 @@ export default function Sidebar() {
const toggleSidebar = () => setIsOpen((prev) => !prev); const toggleSidebar = () => setIsOpen((prev) => !prev);
return ( return (
<Box css={styles.root}> <Box css={styles.root} width={isOpen ? "210px" : "71px"} transition="width 0.4s ease" position="relative" display="flex" flexDirection="column" gap={2}
{/* Home Link Logo */}
<Button
as={NavLink}
to="/home"
css={styles.logo}
variant="plain"
> >
{isOpen ? "🌋 VDAP" : "🌋"}
</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] */}
<Icon/>
<Box as="span" css={styles.button_text}>
{button.name}
</Box>
</Button>
);
})}
<Box css={styles.footer}>
{/* Menu Trigger */} {/* Menu Trigger */}
<IconButton <IconButton aria-label="Toggle Sidebar" variant="plain" size="md" onClick={toggleSidebar} width="54px" alignSelf="flex-start"
aria-label="Toggle Sidebar"
variant="plain"
size="md"
onClick={toggleSidebar}
css={styles.collapse_button}
> >
{isOpen ? <PanelLeftClose /> : <PanelLeftOpen />} <Menu />
</IconButton> </IconButton>
{/* Theme Toggler */} {/* Navigation Buttons */}
<ColorModeButton _hover={{ bg: "transparent" }} /> {visibleButtons.map((button) => {
</Box> const Icon = button.icon;
return (
<Tooltip key={button.name} content={button.name} sidebarIsOpen={isOpen}>
<Button
as={NavLink}
to={button.url}
css={styles.buttons}
justifyContent="flex-start"
width="100%"
overflow="hidden" // Prevents text popout on collapse
>
<Icon style={{ flexShrink: 0 }} /> {/* Prevents icon from squishing */}
{/* Cleaned conditional transition styling */}
<Box
as="span"
ml={isOpen ? 3 : 0}
opacity={isOpen ? 1 : 0}
visibility={isOpen ? "visible" : "hidden"} // Prevents phantom width calculations and overflow into canvas
transition="opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease"
whiteSpace="nowrap"
>
{button.name}
</Box>
</Button>
</Tooltip>
);
})}
{/* Theme Toggler */}
<ColorModeButton
position="absolute"
bottom="4"
left="4"
/>
</Box> </Box>
); );
} }
+129 -10
View File
@@ -7,6 +7,13 @@ import { lazy } from 'react';
// w: 4 = one-third screen width // w: 4 = one-third screen width
// w: 3 = one-quarter 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 = { export const WIDGET_REGISTRY = {
"vaa_activity": { "vaa_activity": {
name: "VAA 7 Day Activity", name: "VAA 7 Day Activity",
@@ -19,7 +26,9 @@ export const WIDGET_REGISTRY = {
// The baseline data every new VAA widget starts with // The baseline data every new VAA widget starts with
defaultSettings: { defaultSettings: {
yAxisScale: 'normal' // Options: 'normal' | 'symlog' // startDate: getStartDate(),
// endDate: getEndDate(),
yAxisScale: 'normal', // Options: 'normal' | 'symlog'
}, },
allowedSizes: { allowedSizes: {
@@ -41,9 +50,21 @@ export const WIDGET_REGISTRY = {
name: "IVANS", name: "IVANS",
category: "Communications", category: "Communications",
description: "Feed of recent IVANS reports and reviewer statuses.", 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: { allowedSizes: {
small: { w: 3, h: 12 }, small: { w: 3, h: 6 },
medium: { w: 6, h: 24 } medium: { w: 3, h: 9 },
large: { w: 3, h: 12 },
} }
}, },
@@ -51,8 +72,21 @@ export const WIDGET_REGISTRY = {
name: "Remote Sensing", name: "Remote Sensing",
category: "Remote Sensing", category: "Remote Sensing",
description: "Feed of recent remote sensing reports.", 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: { allowedSizes: {
small: { w: 3, h: 12 }, small: { w: 3, h: 6 },
medium: { w: 3, h: 9 },
large: { w: 3, h: 12 },
} }
}, },
@@ -60,9 +94,15 @@ export const WIDGET_REGISTRY = {
name: "Alert Level Changes", name: "Alert Level Changes",
category: "Daily Alerts", category: "Daily Alerts",
description: "List of 7-day alert level changes with trend arrows.", 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(),
},
allowedSizes: { allowedSizes: {
small: { w: 3, h: 12 }, small: { w: 3, h: 6 },
large: { w: 9, h: 12 } large: { w: 3, h: 12 }
} }
}, },
@@ -78,7 +118,39 @@ export const WIDGET_REGISTRY = {
// The baseline data every new VAA widget starts with // The baseline data every new VAA widget starts with
defaultSettings: { 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: { allowedSizes: {
medium: { w: 6, h: 12 } medium: { w: 6, h: 12 }
@@ -88,11 +160,58 @@ export const WIDGET_REGISTRY = {
"llm_al_changes_quad_plot": { "llm_al_changes_quad_plot": {
name: "Alert Level Changes Quad Plot", name: "Alert Level Changes Quad Plot",
category: "Computer Science", category: "Computer Science",
description: "Percentage of True-Positive, True-Negative, False-Positive, and False-Negative changes in all " + description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
"alert levels.",
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')), component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
allowedSizes: { allowedSizes: {
small: { w: 3, h: 12 } medium: { w: 3, h: 12 }
}
},
"comm_coor_report": {
name: "Communication Coordinator Report",
category: "Daily Volcanic Activity",
description: "Generated Report",
component: lazy(() => import('../components/Widgets/CommCoorReport.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/CommCoorReportSettings.jsx')),
defaultSettings: {
copyToClipboard: false,
downloadReport: false,
startDate: getStartDate(),
endDate: getEndDate(),
},
allowedSizes: {
medium: { w: 3, h: 12 },
}
},
"vaa_map": {
name: "VAA Polygon Projections Map",
category: "Maps",
description: "By selecting a volcano, the map will take you to it and show a polygon of the ash in 8, 12, and 18 hours",
component: lazy(() => import('../components/Widgets/VaaMap.jsx')),
settingsComponent: lazy(() => import('../components/Widgets/settings/VaaMapSettings.jsx')),
defaultSettings: {
position: [45.61142, -122.49630], // CVO's coordinates
startDate: getToday(),
endDate: getToday(),
data: [],
isLoading: false,
isError: undefined,
selected: undefined,
defaultVAA: undefined,
},
allowedSizes: {
medium: { w: 6, h: 12 }
}
},
"travel_gantt_chart": {
name: "Travel Gantt Chart",
category: "Communications",
description: "View upcoming travels in a gantt chart",
component: lazy(() => import('../components/Widgets/TravelGanttChart.jsx')),
allowedSizes: {
medium: { w: 6, h: 12 }
} }
} }
+4
View File
@@ -1,3 +1,7 @@
:root { :root {
font-family: Georgia, 'Times New Roman', Times, serif; font-family: Georgia, 'Times New Roman', Times, serif;
} }
.wx-theme {
height: 100%;
}
+16
View File
@@ -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]);
}
+2 -2
View File
@@ -26,11 +26,11 @@ export default function useWidgetApi(endpoint) {
} }
const json = await response.json(); const json = await response.json();
const responseData = Array.isArray(json) ? json : json.data; const responseData = Array.isArray(json) ? json : (json?.data ?? json);
setData(responseData || []); setData(responseData || []);
} catch (err) { } 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?"); setError("Failed to load widget data. Is the Django API running?");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
+131
View File
@@ -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>
);
}
+234
View File
@@ -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>
)
}
+48 -53
View File
@@ -1,69 +1,64 @@
import {HStack, Flex, Box, useRecipe} from "@chakra-ui/react"; import { HStack, Flex, Box } from "@chakra-ui/react";
import {NavLink, Outlet, useLocation} from "react-router-dom"; import { NavLink, Outlet, useLocation } from "react-router-dom";
import {SearchBar} from "../../components/dashboard/SearchBar.jsx"; import { SearchBar } from "../../components/dashboard/SearchBar.jsx";
import {SettingsButton} from "../../components/dashboard/SettingsButton.jsx"; import { SettingsButton } from "../../components/dashboard/SettingsButton.jsx";
// 1. Import your icons (Swap these for whichever you prefer!)
import {LayoutDashboard, CalendarDays, Cloud, Satellite, Activity} from "lucide-react";
// 2. Define the tabs array
const HOME_TABS = [
{name: "My Dashboard", url: "/home", icon: LayoutDashboard, exact: true},
{name: "Daily Activity", url: "/home/daily", icon: CalendarDays},
{name: "Gas", url: "/home/gas", icon: Cloud},
{name: "Remote Sensing", url: "/home/remote", icon: Satellite},
{name: "Seismic", url: "/home/seismic", icon: Activity},
];
export default function Home() { export default function Home() {
const recipe = useRecipe({key: "glassTab"});
const tabStyles = recipe();
const location = useLocation(); const location = useLocation();
const isPersonalDashboard = location.pathname === "/home" || location.pathname === "/home/"; const isPersonalDashboard = location.pathname === "/home" || location.pathname === "/home/";
// 1. CSS making this look like how we want it, TODO turn to theme?
const tabStyles = {
fontSize: "lg",
fontWeight: "bold",
color: "fg.muted",
px: 2, // Horizontal padding
// py: 2, // Vertical padding
borderBottom: "1px solid transparent",
transition: "all 0.2s",
_hover: { color: "fg" },
"&.active": { // Target the active class applied by React Router NavLink
color: "fg",
borderColor: "fg",
}
};
return ( return (
<Flex direction="column" height="100%" width="100%"> <Flex direction="column" height="100%" width="100%">
<Flex as="header" position="sticky" top="0" zIndex="dropdown" bg="transparent" p={5} align="center" {/* 2. 🛠️ SUB HEADER: Matches your original padding and background */}
justify="space-between" width="100%"> <Flex as="header" position="sticky" top="0" zIndex="dropdown" bg="base200" p={5} align="center" justify="space-between" width="100%">
{/* TABS CONTAINER */} {/* TABS CONTAINER - Replicates the Tabs.List line container */}
<Flex gap={2} alignItems="center"> <Flex borderBottom="1px solid" borderColor="fg.muted" gap={2}>
{HOME_TABS.map((tab) => { <Box as={NavLink} to="/home" end css={tabStyles}>My Dashboard</Box>
const Icon = tab.icon; <Box as={NavLink} to="/home/daily" css={tabStyles}>Daily Activity</Box>
return ( <Box as={NavLink} to="/home/gas" css={tabStyles}>Gas</Box>
<Box <Box as={NavLink} to="/home/remote" css={tabStyles}>Remote Sensing</Box>
key={tab.name} <Box as={NavLink} to="/home/seismic" css={tabStyles}>Seismic</Box>
as={NavLink} </Flex>
to={tab.url}
end={tab.exact} // CRITICAL: Forces exact match so "/home" deactivates on sub-routes
css={tabStyles}
>
{/* flexShrink: 0 ensures the icon never squishes during the gooey animation */}
<Icon size={24} style={{flexShrink: 0}}/>
<Box as="span">{tab.name}</Box>
</Box>
);
})}
{/* The Settings button is now inline with the tabs and only renders on the personal dashboard */} {/* EDIT MODE BUTTONS & CONTROLS */}
{isPersonalDashboard && <SettingsButton/>} <HStack width="258px" justifyContent="flex-end">
{isPersonalDashboard ? (
<>
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
{/* This will eventually trigger your Zustand store's edit mode! */}
<SettingsButton />
</>
) : (
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
)}
</HStack>
</Flex> </Flex>
{/* EDIT MODE BUTTONS & CONTROLS */} {/* 3. 🛠️ CANVAS AREA */}
{/* Leaving this HStack here in case you plan to put the SearchBar or other global tools back on the right side */} <Flex flex="1" overflow="auto">
<HStack width="258px" justifyContent="flex-end"> <Outlet />
{/* Future global controls go here */} </Flex>
</HStack>
</Flex> </Flex>
<Flex flex="1" overflow="auto">
<Outlet/>
</Flex>
</Flex>
); );
} }
+41 -26
View File
@@ -1,9 +1,9 @@
import {Box, Text, Flex, IconButton, Spinner, Popover, useSlotRecipe} from "@chakra-ui/react"; import {Box, Text, Flex, IconButton, Spinner, Popover} from "@chakra-ui/react";
import {X, MoreVertical} from "lucide-react"; import {X, MoreVertical} from "lucide-react";
import {Responsive, WidthProvider} from "react-grid-layout"; import {Responsive, WidthProvider} from "react-grid-layout";
import {useDashboardStore} from "@/store/useDashboardStore.jsx"; import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import {Suspense, useEffect, useRef} from 'react'; import {Suspense, useEffect, useRef} from 'react';
import {useColorModeValue} from "@/components/ui/color-mode";
// 1. Import database messenger // 1. Import database messenger
import useAuthStore from "@/store/authStore"; import useAuthStore from "@/store/authStore";
@@ -14,6 +14,11 @@ import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx';
const ResponsiveGridLayout = WidthProvider(Responsive); const ResponsiveGridLayout = WidthProvider(Responsive);
export default function MyDashboard() { export default function MyDashboard() {
// 🌗 Let's adjust for our light and dark mode
const frameBg = useColorModeValue("gray.100", "gray.800");
const titleColor = useColorModeValue("gray.800", "gray.100");
const iconColor = useColorModeValue("gray.600", "gray.400");
const iconHoverBg = useColorModeValue("gray.100", "gray.700");
// ☁️ Database Connection // ☁️ Database Connection
const {user} = useAuthStore(); const {user} = useAuthStore();
@@ -28,11 +33,9 @@ export default function MyDashboard() {
// 🔒 Safety lock to prevent infinite re-renders // 🔒 Safety lock to prevent infinite re-renders
const hasHydrated = useRef(false); const hasHydrated = useRef(false);
// 1. Pull the new global slot recipe // ==========================================
const recipe = useSlotRecipe({key: "glassPanel"}); // INITIAL LOAD: Grab data from Django
const styles = recipe({ layout: "widget" }); // ==========================================
const popoverStyles = recipe({ layout: "popover" });
useEffect(() => { useEffect(() => {
if (user?.preferences?.dashboard_layout && !hasHydrated.current) { if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
const savedLayout = user.preferences.dashboard_layout; const savedLayout = user.preferences.dashboard_layout;
@@ -42,6 +45,7 @@ export default function MyDashboard() {
if (Array.isArray(savedLayout)) { if (Array.isArray(savedLayout)) {
setWidgets(savedLayout); setWidgets(savedLayout);
} }
hasHydrated.current = true; hasHydrated.current = true;
} }
}, [user, setWidgets]); }, [user, setWidgets]);
@@ -68,9 +72,17 @@ export default function MyDashboard() {
const SettingsUI = registryEntry.settingsComponent; const SettingsUI = registryEntry.settingsComponent;
return ( return (
<> // The "Frame" that holds both the Title and the Chart
<Box css={styles.header}> <Flex direction="column" height="100%" width="100%" bg={frameBg}>
<Text color="titleColor" fontWeight="semibold" fontSize="md" isTruncated>
{/* THE TITLE BAR */}
<Flex
justify="space-between"
align="center"
px={3}
py={2}
>
<Text color={titleColor} fontWeight="semibold" fontSize="md" isTruncated>
{registryEntry.name} {registryEntry.name}
</Text> </Text>
@@ -82,7 +94,6 @@ export default function MyDashboard() {
size="xs" size="xs"
variant="ghost" variant="ghost"
color="gray.400" color="gray.400"
borderRadius="xl"
_hover={{color: "white", bg: "gray.700"}} _hover={{color: "white", bg: "gray.700"}}
aria-label="Widget Settings" aria-label="Widget Settings"
> >
@@ -92,7 +103,7 @@ export default function MyDashboard() {
{/* NEW: The Positioner lifts the content out of the Flexbox flow */} {/* NEW: The Positioner lifts the content out of the Flexbox flow */}
<Popover.Positioner zIndex={10}> <Popover.Positioner zIndex={10}>
<Popover.Content css={popoverStyles.root}> <Popover.Content bg="gray.50" p={4} boxShadow="xl" borderRadius="md">
<Suspense fallback={<Spinner size="sm"/>}> <Suspense fallback={<Spinner size="sm"/>}>
<SettingsUI <SettingsUI
widgetId={widget.id} widgetId={widget.id}
@@ -105,18 +116,22 @@ export default function MyDashboard() {
) : ( ) : (
<Box w="20px" h="20px"/> <Box w="20px" h="20px"/>
)} )}
</Flex>
{/* THE CHART CANVAS */}
<Box flex="1" overflow="hidden" position="relative" p={1}>
{/* Pass the settings object into the actual chart component */}
{/* Pass widget ID to allow updating settings in widget component rather than just in settings component */}
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
</Box> </Box>
<Box css={styles.body}> </Flex>
<Component settings={widget.settings || {}}/>
</Box>
</>
); );
} }
// Fallback if someone asks for a widget that doesn't exist // Fallback if someone asks for a widget that doesn't exist
return ( return (
<Flex css={styles.body} align="center" justify="center" direction="column"> <Flex width="100%" height="100%" align="center" justify="center" direction="column" bg="gray.800">
<Text fontWeight="bold" color="red.400">Error</Text> <Text fontWeight="bold" color="red.400">Error</Text>
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text> <Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
</Flex> </Flex>
@@ -140,12 +155,12 @@ export default function MyDashboard() {
{widgets.map(widget => ( {widgets.map(widget => (
<Box <Box
key={widget.id} key={widget.id}
css={styles.root} bg="white"
// 3. Grid-specific state overrides remain inline borderRadius="md"
boxShadow={isEditing ? "outline" : styles.root.boxShadow} boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
borderStyle={isEditing ? "dashed" : "solid"} border={isEditing ? "2px dashed gray" : "1px solid"}
borderWidth={isEditing ? "2px" : "1px"} borderColor={isEditing ? "gray.400" : "gray.200"}
borderColor={isEditing ? "orange.300" : styles.root.borderColor} overflow="hidden"
cursor={isEditing ? "grab" : "default"} cursor={isEditing ? "grab" : "default"}
position="relative" // 3. REQUIRED so the absolute button stays inside this box position="relative" // 3. REQUIRED so the absolute button stays inside this box
> >
@@ -158,9 +173,8 @@ export default function MyDashboard() {
right={2} right={2}
size="xs" size="xs"
bg="white" bg="white"
color="black" color={iconColor}
border="1px solid" border="1px solid"
borderRadius="xl"
borderColor="gray.200" borderColor="gray.200"
_hover={{bg: "red.50", color: "red.500", borderColor: "red.200"}} _hover={{bg: "red.50", color: "red.500", borderColor: "red.200"}}
zIndex={2} zIndex={2}
@@ -174,12 +188,13 @@ export default function MyDashboard() {
{/* 4. SUSPENSE BOUNDARY wrapping the render function */} {/* 4. SUSPENSE BOUNDARY wrapping the render function */}
<Suspense fallback={ <Suspense fallback={
<Flex css={styles.body} align="center" justify="center"> <Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
<Spinner size="md" color="blue.400" thickness="3px"/> <Spinner size="md" color="blue.400" thickness="3px"/>
</Flex> </Flex>
}> }>
{renderWidget(widget)} {renderWidget(widget)}
</Suspense> </Suspense>
</Box> </Box>
))} ))}
</ResponsiveGridLayout> </ResponsiveGridLayout>
+16 -1
View File
@@ -86,7 +86,22 @@ const useAuthStore = create((set) => ({
} catch (error) { } catch (error) {
console.error("Failed to save layout to database", 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; export default useAuthStore;
+90 -89
View File
@@ -1,109 +1,110 @@
import { create } from 'zustand'; import {create} from 'zustand';
import { v4 as uuid } from 'uuid'; import {v4 as uuid} from 'uuid';
import { WIDGET_REGISTRY } from '../constants/widgetRegistry.jsx'; import {WIDGET_REGISTRY} from '../constants/widgetRegistry.jsx';
// 1. Import your database messenger // 1. Import your database messenger
import useAuthStore from './authStore'; import useAuthStore from './authStore';
export const useDashboardStore = create((set, get) => ({ 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, isEditing: false,
widgets: [], // The master array of what is currently on the screen widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button }),
// ========================================== saveEdit: async () => {
// 2. ACTIONS (The Logic) // 1. Grab the exact current state of the grid
// ========================================== const currentLayout = get().widgets;
enterEditMode: () => set({ // 2. Lock the UI immediately so the user feels zero lag
isEditing: true, set({isEditing: false, backupWidgets: []});
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
}),
cancelEdit: () => set({ // 3. Reach outside this store and trigger the Django API call!
isEditing: false, try {
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags 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 () => { updateWidgetSettings: (id, newSettings) => set((state) => {
// 1. Grab the exact current state of the grid // console.log("2. Zustand updating widget:", id, "with:", newSettings);
const currentLayout = get().widgets; 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 addWidget: (widgetId, sizeKey) => {
set({ isEditing: false, backupWidgets: [] }); const registryEntry = WIDGET_REGISTRY[widgetId];
// 3. Reach outside this store and trigger the Django API call! if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
try { console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
await useAuthStore.getState().saveDashboardLayout(currentLayout); return;
} 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
}
},
updateWidgetSettings: (id, newSettings) => set((state) => { const dimensions = registryEntry.allowedSizes[sizeKey];
console.log("2. Zustand updating widget:", id, "with:", newSettings); const widgets = get().widgets;
return {
widgets: state.widgets.map(widget =>
widget.id === id
? { ...widget, settings: { ...widget.settings, ...newSettings } }
: widget
)
};
}),
addWidget: (widgetId, sizeKey) => { let startX = 0;
const registryEntry = WIDGET_REGISTRY[widgetId]; let startY = Infinity;
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) { if (widgets.length > 0) {
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`); const bottomRowY = Math.max(...widgets.map(w => w.y));
return; const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
} const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
const dimensions = registryEntry.allowedSizes[sizeKey]; if (rightmostEdge + dimensions.w <= 12) {
const widgets = get().widgets; 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; removeWidget: (id) => set((state) => ({
let startY = Infinity; widgets: state.widgets.filter(w => w.id !== id)
})),
if (widgets.length > 0) { updateLayoutPositions: (rglLayout) => {
const bottomRowY = Math.max(...widgets.map(w => w.y)); set((state) => ({
const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY); widgets: state.widgets.map(widget => {
const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w)); const gridItem = rglLayout.find(l => l.i === widget.id);
return gridItem ? {...widget, x: gridItem.x, y: gridItem.y} : widget;
})
}));
},
if (rightmostEdge + dimensions.w <= 12) { // NEW: Action to hydrate the store on initial page load
startX = rightmostEdge; // ...tuck it right next to the last widget! setWidgets: (savedWidgets) => set({widgets: savedWidgets || []})
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 || [] })
})); }));
+53 -312
View File
@@ -1,12 +1,14 @@
import {defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe} from "@chakra-ui/react"; import { defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe } from "@chakra-ui/react";
// RECIPES // RECIPES
const headerRecipe = defineRecipe({ const headerRecipe = defineRecipe({
base: { base: {
bg: "{transparent}", bg: "{base200}",
color: "text", color: "text",
width: "100%", width: "100%",
height: "100%", height: "100%",
borderBottom: "1px solid",
borderBottomColor:"{border}",
display: "flex", display: "flex",
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between", justifyContent: "space-between",
@@ -14,177 +16,48 @@ const headerRecipe = defineRecipe({
} }
}); });
const sidebarRecipe = defineSlotRecipe({
slots: ["root", "logo", "footer", "button_text","buttons", "collapse_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%)"
},
// 2. The glass blur effect
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
// 3. Subtle semi-transparent borders
border: "1px solid",
borderColor: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"},
// CRITICAL: Remove the right border so it doesn't draw a line through your cutout
borderRight: "none",
borderRadius: "2xl",
// 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",
display: "flex",
flexDirection: "column",
position: "relative",
justifyContent: "start",
alignItems: "stretch",
gap: 4,
transition: "width 0.4s ease",
// CRITICAL: Adjust padding and overflow to allow the button to touch the right edge
pl: 2, // Keep left padding
py: 2, // Keep top/bottom padding
pr: 0, // Remove right padding
pt: 4, // Added top padding to give the logo room to breathe
overflow: "visible", // Allows the curved corner patches to render outside the button
zIndex: 10,
},
buttons: {
position: "relative", // Required to anchor the corner patches
bg: "transparent",
justifyContent: "flex-start",
transition: "background-color 0.4s ease, color 0.4s ease",
"& svg": {
transition: "stroke-width 0.4s ease",
flexShrink: "0"
},
_hover: {
bg: "rgba(255, 255, 255, 0.1)",
color: "{text}",
fontWeight: "semibold",
"& svg": {
strokeWidth: "3"
}
},
fontSize: "l",
color: {base: "{text}"},
variant: "ghost",
width: "100%",
// Flat on the right, rounded on the left
borderRightRadius: "0",
borderLeftRadius: "4xl",
"&.active": {
bg: "{MainBackground}",
color: "{textInverted}",
_hover: {
bg: "{MainBackground}",
color: "{textInverted}",
fontWeight: "normal",
"& svg": {
strokeWidth: "2" // Keeps the active icon at normal thickness on hover
}
},
// The Top Inverted Curve
_before: {
content: '""',
position: "absolute",
right: "-1px",
top: "-25px",
width: "25px",
height: "25px",
bg: "inherit",
WebkitMaskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
maskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
pointerEvents: "none",
},
// The Bottom Inverted Curve
_after: {
content: '""',
position: "absolute",
right: "-1px",
bottom: "-25px",
width: "25px",
height: "25px",
bg: "inherit",
WebkitMaskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
maskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
pointerEvents: "none",
}
},
},
logo: {
width: "100%",
height: "auto",
overflow: "hidden",
fontWeight: "bold",
px: 0,
},
footer: {
mt: "auto",
display: "flex",
justifyContent: "center",
alignItems: "center",
pb: 4,
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"
},
collapse_button: {
"& svg": { flexShrink: "0" },
},
},
variants: {
state: {
open: {
root: { width: "190px" },
logo: { justifyContent: "flex-start", ml: 2, textStyle: "4xl" },
footer: { gap: 16, flexDirection: "row" },
button_text: {ml: 3, opacity: 1, visibility: "visible"},
},
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"},
}
}
}
});
const dashboardRecipe = defineRecipe({ const dashboardRecipe = defineRecipe({
base: { base: {
bg: "{MainBackground}" bg: "{base200}"
} }
}); });
const canvasRecipe = defineRecipe({ const canvasRecipe = defineRecipe({
base: { base: {
bg: "{transparent}", bg: "{base200}",
zIndex: "-1", zIndex: "-1",
color: "{text}" color: "{text}"
} }
}); });
const sidebarRecipe = defineSlotRecipe({
slots: ["root", "close_button", "buttons", "dark_mode_button"],
base: {
root: {
bg: "{base200}",
borderRight: "1px solid",
borderColor: "{border}",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "start",
alignItems: "start",
gap: 2,
p: 2,
},
buttons: {
bg: "transparent",
_hover: {bg: "{primaryLt}", color: "{textInverted}"},
"&.active": {bg: "{primary}", color: "{textInverted}"},
fontSize: "xl",
color: {base: "{text}"},
variant: "ghost",
width: "100%",
},
}
});
const widgetRecipe = defineSlotRecipe({ const widgetRecipe = defineSlotRecipe({
slots: ["root", "header", "canvas"], slots: ["root", "header", "canvas"],
base: { base: {
@@ -223,144 +96,19 @@ const widgetRecipe = defineSlotRecipe({
} }
}); });
const glassPanelRecipe = defineSlotRecipe({
slots: ["root", "header", "body"],
// 🧊 THE GLASS MATH (Applies to everything)
base: {
root: {
display: "flex",
flexDirection: "column",
position: "relative",
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)",
}
}
},
mode: {
editing: {
root: {
boxShadow: "outline",
borderStyle: "dashed",
borderWidth: "2px",
borderColor: "orange.300",
cursor: "grab",
}
}
}
},
defaultVariants: {
layout: "widget"
}
});
const glassTabRecipe = defineRecipe({
base: {
display: "flex",
alignItems: "center",
height: "40px",
// The Circle State (Inactive)
maxWidth: "40px",
minWidth: "40px",
px: "8px",
gap: 2,
// CRITICAL: Locks the shape to a perfect circle/pill
borderRadius: "full",
overflow: "hidden",
whiteSpace: "nowrap",
textDecoration: "none",
fontWeight: "bold",
color: "fg.muted",
// --- THE GLASS EFFECT (Inactive) ---
// Uses a highly transparent white/black depending on mode
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "rgba(255, 255, 255, 0.03)"},
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid",
borderColor: {base: "rgba(0, 0, 0, 0.05)", _dark: "rgba(255, 255, 255, 0.08)"},
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.02)", // Tiny shadow to lift it off the canvas
transition: "all 0.85s cubic-bezier(0.34, 1.56, 0.64, 1)",
"&:hover, &[data-state='open'], &[aria-expanded='true']": {
maxWidth: "250px",
pr: "16px",
// --- THE GLASS EFFECT (Hover) ---
// Becomes slightly more opaque when you mouse over
bg: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"},
borderColor: {base: "rgba(0, 0, 0, 0.1)", _dark: "rgba(255, 255, 255, 0.15)"},
color: "fg",
textDecoration: "none",
},
"&.active": {
maxWidth: "250px",
pr: "16px",
bg: "{gooeyButtonBackground}",
borderColor: "transparent",
color: "{textInverted}",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)", // Stronger shadow for the active tab
_hover: {
bg: "{gooeyButtonBackground}",
cursor: "default",
textDecoration: "none",
}
}
}
});
const config = defineConfig({ const config = defineConfig({
globalCss: { globalCss: {
".react-grid-placeholder": { ".react-grid-placeholder": {
background: "#c0d2e3ff !important", background: "#c0d2e3ff !important",
borderColor: "#a9b6c2ff !important", borderColor: "#a9b6c2ff !important",
boxShadow: "0 0 10px #c0d2e3ff !important", boxShadow: "0 0 10px #c0d2e3ff !important",
opacity: "1 !important",
borderRadius: "5px",
_dark: {
background: "#393c3fff !important",
borderColor: "#000000ff !important",
boxShadow: "0 0 10px #16181bff !important",
opacity: "1 !important", opacity: "1 !important",
borderRadius: "5px",
_dark: {
background: "#393c3fff !important",
borderColor: "#000000ff !important",
boxShadow: "0 0 10px #16181bff !important",
opacity: "1 !important",
}, },
}, },
".react-resizable-handle-se": { ".react-resizable-handle-se": {
@@ -423,38 +171,31 @@ const config = defineConfig({
theme: { theme: {
semanticTokens: { semanticTokens: {
colors: { colors: {
base100: {value: {base: "#f6f9faff", _dark: "#1617199e"}}, base100: { value: {base: "#f6f9faff", _dark: "#1617199e"}},
base200: {value: {base: "#e6ecf1ff", _dark: "#242424"}}, base200: { value: {base: "#e6ecf1ff", _dark: "#242424"}},
base300: {value: {base: "#d3dae4ff", _dark: "#0a0b0bff"}}, base300: { value: {base: "#d3dae4ff", _dark: "#0a0b0bff"}},
primary: {value: {base: "#2B98F8", _dark: "#2B98F8"}}, primary: { value: {base: "#2B98F8", _dark: "#2B98F8"}},
primaryLt: {value: {base: "#98cdffff", _dark: "#98cdffff"}}, primaryLt: { value: {base: "#98cdffff", _dark: "#98cdffff"}},
secondaryBG: {value: {base: "#EDF1F1", _dark: "#121212"}}, secondaryBG: { value: {base: "#EDF1F1", _dark: "#121212"}},
border: {value: {base: "#000000", _dark: "#cbcbcb84"}}, border: {value: {base: "#000000", _dark: "#cbcbcb84"}},
text: {value: {base: "#000000", _dark: "#FFFFFF"}}, text: {value: {base: "#000000", _dark: "lightgrey"}},
textInverted: {value: {base: "#000000", _dark: "#FFFFFF"}}, textInverted: {value: "white"},
icon: {value: {base: "#000000", _dark: "#FFFFFF"}}, icon: {value: {base: "#000000", _dark: "lightgrey"}},
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"}},
}, },
}, },
tokens: { tokens: {
fonts: { fonts: {
body: {value: "system-ui, sans-serif"}, body: { value: "system-ui, sans-serif" },
}, },
}, },
recipes: { recipes: {
header: headerRecipe, header: headerRecipe,
dashboard: dashboardRecipe, dashboard: dashboardRecipe,
canvas: canvasRecipe, canvas: canvasRecipe,
glassTab: glassTabRecipe
}, },
slotRecipes: { slotRecipes: {
sidebar: sidebarRecipe, sidebar: sidebarRecipe,
widget: widgetRecipe, widget: widgetRecipe,
glassPanel: glassPanelRecipe,
}, },
}, },
}); });