Author SHA1 Message Date
vkuchenik 7b3e7d3d0c refactor: continueing to propogate changes made to widget setting popover in al changes to ivans widget 2026-11-06 10:53:49 -08:00
vkuchenik 58c6a40b06 refactor: adjusting colors in themes file for better visibility/distinction of entries in widgets like al changes, ivans, and rs in both lt and dark mode 2026-11-06 10:52:58 -08:00
vkuchenik e50638ab55 add: volcano drop down filter w/ multiselect capabilities in widget settings. UI only! No functionality has been built yet 2026-11-05 15:39:27 -08:00
vkuchenik 189d6e2d1d add: new filtering options were added to the alert changes widget. options are lvl inc, lvl dec, elevated lvls. additionally, updated how the UI looks for the al changes widget settings menu 2026-11-05 10:43:05 -08:00
vkuchenik d4fb170e14 fix:
- alert level changes now display accurate color.
- comm coor report no longer has dark loading screen in lt mode. login input text no longer white in dark mode
2026-11-03 13:27:47 -08:00
vkuchenik 872550c2c9 wip: small random ui changes 2026-10-23 11:23:16 -07:00
chight 0c178baa01 Refactor changed widgets to match themes. 2026-10-20 16:19:45 -07:00
chight 0ec4f27640 Refactor CSS for scatter plots 2026-10-20 14:59:03 -07:00
chight c027a30b3f Refactor widgets to use theme better. Alert change and 7day vaa 2026-10-20 14:05:52 -07:00
chight bd5e02ac1b Refactor connecting more of theme.js 2026-10-19 14:59:01 -07:00
chight c810056d67 Refactor dashboard to use new theme.js 2026-10-19 14:34:40 -07:00
chight 56ae6d2e3f Refactor theme.js to split components out more. 2026-10-19 14:24:43 -07:00
chight f43cfb649e Bug Fix copy paste errors repaired 2026-10-19 14:06:34 -07:00
chight 91af70dbb5 Refactor lots of changes to normaliz theme.js a bit more 2026-10-19 13:44:59 -07:00
chight 64c78cea70 Refactor starting to update theme.js 2026-10-19 10:43:28 -07:00
vkuchenik bc9fbaae6e wip - committing to check out another branch 2026-10-05 15:21:51 -07:00
vkuchenik feaa1c9f14 Merge branch '20260916_0925_UI_hot_fix' into '20260605_1123_Hight_updates'
fix: ivans, rs, and al were failing to load data

See merge request vkuchenik/website-framework!111
2026-09-16 16:59:43 +00:00
vkuchenik 91d77b8bd4 fix: ivans, rs, and al were failing to load data
MyDashboard was not passing widgetID to widget components
2026-09-16 09:54:00 -07:00
chight a01ae24908 Merge branch '20260721_0845_Venessa_addingWidgets' into 20260605_1123_Hight_updates 2026-09-15 14:57:43 -07:00
chight 49f3b0807d Merge branch '20260902_1249_Kuchenik_Flatten_CSS' into 20260605_1123_Hight_updates 2026-09-15 14:50:24 -07:00
chight 4474b8ae33 Merge branch '20260715_UI_update' into 20260605_1123_Hight_updates 2026-09-15 14:48:52 -07:00
vkuchenik c32afff1c5 reformatting slot recipes for better orginization 2026-09-15 14:45:32 -07:00
vkuchenik 4523f8035f refactoring theme.js by breaking up the root slot into further header, body slots to better compartmentalize css in components 2026-09-15 10:59:20 -07:00
vkuchenik 18c1df93d1 fixed disappearing color mode button on re-opening of sidebar 2026-09-15 08:30:53 -07:00
vkuchenik 243bb13537 fix color mode button bg to be transparent on hover 2026-09-14 15:50:28 -07:00
vkuchenik 3595599dae Updated color more button by consolidating into one button, it now uses css positioning w respect to state rather then visibility 2026-09-14 15:31:23 -07:00
vkuchenik 8e514418d4 removed some leftover code from a recipe that was unused 2026-09-14 09:04:16 -07:00
vkuchenik 10cbedb6ef wip moved the category-menu css in the widget library to the themes file 2026-09-14 08:58:09 -07:00
vkuchenik 0c8820d109 wip moving menu nav buttons in widget menu 2026-09-11 14:45:39 -07:00
vkuchenik 4914b64fe2 fixed color mode button background 2026-09-09 11:54:24 -07:00
vkuchenik 4aa8eb7ff0 updated header so all styling is now in the designated themes file 2026-09-03 14:07:39 -07:00
vkuchenik cde2c4660b updated themes file to further reduce styling in sidebar component page 2026-09-03 12:32:49 -07:00
vkuchenik 1c6951d245 fixed sidebar animation issues by truncating btn text and using css transitions on footer icons 2026-09-03 11:21:50 -07:00
chight 60f71485da Refactor CSS on mydashboard started needs completed 2026-09-02 12:45:40 -07:00
chight 8e9b17f6dd Refactor, cleaned up header file 2026-08-26 13:10:02 -07:00
chight c45bc778ec Refactor, cleaned up sidebar and theme 2026-08-26 13:04:17 -07:00
chight 816046d05a Fix the color mode button was highlighting odd. removed highlight 2026-08-24 10:58:09 -07:00
chight 12e540b61b Fix adjustment to VDAP logo and emoji 2026-08-24 08:58:31 -07:00
chight 1e1130f965 Update widget library will now open to a percentage of screen 2026-08-21 14:39:57 -07:00
chight 8ddf9ff393 Update chevrons are updated for better UI 2026-08-21 13:30:02 -07:00
chight 9abcb78327 Refactor only formatting of file 2026-08-21 12:58:28 -07:00
chight f5f09e31fe Update added chevrons to scrollbar - bug in size of chevrons 2026-08-21 12:55:54 -07:00
chight 510c63e7b0 Update tabs in widget library now scroll and look nice 2026-08-21 11:41:08 -07:00
chight 3017535d02 Update to buttons on widget choices 2026-08-20 14:45:13 -07:00
chight 9b1ddf6dcc Refactor widget library UI code 2026-08-20 12:51:58 -07:00
chight 2b0300b075 Refactor continued of UI. changes to theme to make baselines 2026-08-04 09:05:30 -07:00
chight a6ebe5af1e Refactor recipe adjustment 2026-07-29 14:18:10 -07:00
chight 6d0af310ae Refactor continuing to work on UI 2026-07-29 13:48:38 -07:00
chight 2283d156ea Refactoring some theme colors 2026-07-24 14:11:17 -07:00
chight 3697575f54 Refactor the UI for light mode is at an acceptable point 2026-07-23 15:47:51 -07:00
chight 9fbf22bc32 Refactor still working on UI changes 2026-07-22 14:58:59 -07:00
chight 7568d0dba4 Merge remote-tracking branch 'origin/20260721_0845_Venessa_addingWidgets' into 20260715_UI_update 2026-07-22 10:37:09 -07:00
chight 022d4169b5 Reformat starting changes on layout for better UI visuals 2026-07-22 10:37:01 -07:00
32 changed files with 2069 additions and 1451 deletions
+3
View File
@@ -2,6 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="volcano.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VDAP</title>
+32 -40
View File
@@ -21,41 +21,7 @@ import MyDashboard from "./pages/home/tabs/MyDashboard.jsx";
import Settings from "./pages/auth/Settings.jsx";
import WebAdmin from "./pages/auth/WebAdmin.jsx";
function AppFoundation() {
const recipe = useRecipe({ key: "dashboard" });
const styles = recipe();
const dialog = useDialog();
return (
<>
<DialogPopup dialog={dialog} />
<Grid css={styles}
templateRows="1fr 11fr"
templateColumns="auto 1fr"
height="100vh"
width="100vw"
>
<GridItem rowSpan={1} colSpan={2}>
<Header />
</GridItem>
<GridItem colSpan={1}>
<Sidebar />
</GridItem>
<GridItem colSpan={1} width="100%" overflow="auto">
<Outlet />
</GridItem>
</Grid>
</>
)
}
const Placeholder = ({ name }) => (
<Flex width="100%" height="100%" align="center" justify="center">
<Heading color="gray.500" _dark={{ color: "gray.200" }} size="lg">{name} Dashboard - Coming Soon</Heading>
</Flex>
);
function App() {
export default function App() {
// 1. Pull the initialization function from Zustand
const checkAuth = useAuthStore((state) => state.checkAuth);
@@ -69,7 +35,7 @@ function App() {
{/* --- Public Routes --- */}
<Route path="/login" element={<Login/>}/>
{/* --- SECURE ROUTES --- */}
{/* --- Secure Routes --- */}
<Route element={<ProtectedRoute/>}>
{/* --- Non-Public Routes (Now completely locked down) --- */}
@@ -83,9 +49,6 @@ function App() {
<Route path="daily" element={<Placeholder name="Daily Activity"/>}/>
</Route>
<Route path="/settings" element={<Settings />} />
<Route path="/web-admin" element={<WebAdmin />} />
<Route path="/global-map" element={<GlobalMap/>}/>
<Route path="/regional-map" element={<RegionalMap/>}/>
<Route path="/volcano" element={<Volcano/>}/>
@@ -100,4 +63,33 @@ function App() {
);
}
export default App;
// The fundamental layout of the webpage
function AppFoundation() {
const recipe = useRecipe({key: "dashboard"});
const styles = recipe();
const dialog = useDialog();
return (
<>
<DialogPopup dialog={dialog}/>
<Grid css={styles} templateRows="1fr 11fr" templateColumns="auto 1fr">
<GridItem rowSpan={2} colSpan={1}>
<Sidebar/>
</GridItem>
<GridItem rowSpan={1} colSpan={1}>
<Header/>
</GridItem>
<GridItem colSpan={1}>
<Outlet/>
</GridItem>
</Grid>
</>
)
}
// A dummy page that acts as a placeholder for pages that aren't developed yet but are navigatable to
const Placeholder = ({name}) => (
<Flex width="100%" height="100%" align="center" justify="center">
<Heading color="gray.500" _dark={{color: "gray.200"}} size="lg">{name} Dashboard - Coming Soon</Heading>
</Flex>
);
+311 -91
View File
@@ -1,28 +1,51 @@
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 {
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]
));
// 0) CREATE SELF AWARENESS, by finding this specific widgets object in the state-list of active/displayed widgets
const thisWidget = useDashboardStore(
useCallback(
(state) => state.widgets.find((widget) => widget.id === widgetID),
[widgetID],
),
);
// build url, default is 7 days past
const url = useBuildUrl('/api/v2/alert_changes', settings?.startDate, settings?.endDate);
// 1) BUILD URL, default is 7 days past
const url = useBuildUrl(
"/api/v2/alert_changes",
settings?.startDate,
settings?.endDate,
);
// call api hook to retrieve data
// 2) CALL API hook to retrieve data
const { data: apiData, isLoading, error } = VdapApi(url);
const data = useMemo(() => {
// 3) SORT the data by grouping into volcanoes and sorting form newest to oldest
// doing this 1st bc the filters might change w/ user input but the sorts (currently) wont and thus will
// only run when the api retrieves new data
const sortedData = useMemo(() => {
if (apiData.length === 0) return [];
// .reduce() is a built-in array method used to transform an entire array into a single value (like an object)
// here it returns a single massive object w/ volcano names as keys
// here we sort the data by volcano, and return it as a single massive object w/ volcano names as keys
const grouped = apiData.reduce((accumulator, item) => {
if (!accumulator[item.volcano]) accumulator[item.volcano] = [];
accumulator[item.volcano].push(item);
@@ -33,131 +56,305 @@ export default function AlertChanges({ settings, widgetID}) {
const group_arr = Object.entries(grouped);
// sort data newest to oldest within each volcano
const sorted = group_arr.map((item) =>
[
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))
]
[...item[1]].sort( // item[1] is the array of al changes for a volcano
(a, b) =>
new Date(b.alert_start_date_local) -
new Date(a.alert_start_date_local),
),
]);
// the volcanoes with the most recent issued alert level change appear at the top of the list (newest -> oldest)
const sorted_entries = [...sorted].sort(
(a, b) =>
new Date(b[1][0].alert_start_date_local) -
new Date(a[1][0].alert_start_date_local),
);
// 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));
// additionally, seperates out data into domains within a volcano for sm widgets,
// to list all most recent al changes w/in a volcano w/in a domain
if (thisWidget?.size === "small") {
return (sorted_entries.map((volcano) => {
return sorted_entries.map((volcano) => {
const domain = volcano[1].reduce((acc, curr) => {
if (!acc[curr.alert_scheme_domain]) acc[curr.alert_scheme_domain] = [];
if (!acc[curr.alert_scheme_domain])
acc[curr.alert_scheme_domain] = [];
acc[curr.alert_scheme_domain].push(curr);
return acc;
}, {});
const domain_arr = Object.entries(domain);
console.log("sm data: ", [volcano[0], domain_arr]);
return [volcano[0], domain_arr];
}));
});
}
console.log("lg data: ", sorted_entries);
return sorted_entries;
}, [apiData, thisWidget]);
// list of alert colors used for coloring text below
// 4) FILTER according to what is set in the widgets settings
const filteredData = useMemo(() => {
if (sortedData.length === 0) return [];
let filtered_arr = [];
// must show at least one inc or dec alert levels, otherwise no data is displayed
if (
!settings?.filters?.levelIncreases &&
!settings?.filters?.levelDecreases
)
return [];
// return all data
if (
settings?.filters?.levelIncreases &&
settings?.filters?.levelDecreases &&
!settings?.filters.elevatedLevels
)
return sortedData;
// only elevated alert levels
filtered_arr = settings?.filters.elevatedLevels
? sortedData.map((volcano) => [
volcano[0],
volcano[1].filter(
(item) =>
item.alert_scheme_highest - Number(item.alert_level_rank) <= 1,
),
])
.filter((volcano) => volcano[1].length > 0)
: sortedData;
// only decreases (rm inc)
filtered_arr = !settings?.filters.levelIncreases
? filtered_arr.map((volcano) => [
volcano[0],
volcano[1].filter((item) => !(item.change_type === "increase")),
])
.filter((volcano) => volcano[1].length > 0)
: filtered_arr;
// only increases (rm dec)
filtered_arr = !settings?.filters?.levelDecreases
? filtered_arr.map((volcano) => [
volcano[0],
volcano[1].filter((item) => !(item.change_type === "decrease")),
])
.filter((volcano) => volcano[1].length > 0)
: filtered_arr;
console.log("filtered_arr: ", filtered_arr);
return filtered_arr;
}, [sortedData, settings]);
// Alert colors are now light/dark mode responsive
const alertColors = {
Red: "red.400",
Orange: "orange.400",
Yellow: "yellow.400",
Green: "green.400",
Red: "alertRed",
Orange: "alertOrange",
Yellow: "alertYellow",
Green: "alertGreen",
};
if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
const options = {
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
second: "2-digit",
timeZoneName: "short"
};
if (isLoading)
return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px" />
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
if (error)
return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Text color="red.400">{error}</Text>
</Flex>
);
if (data.length === 0) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text>
if (filteredData.length === 0)
return (
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Text textAlign="center" color="widgetTextMuted">
No alert level data available for the selected dates.
</Text>
</Flex>
);
return (
<Box display="flex" flexDirection="column" gap={2} p={2} w="100%" h="100%" borderRadius="lg" bg="gray.800" overflowY="auto">
{data.map((volcano, index) => {
if (thisWidget?.size === "large") return (
<Box
display="flex"
flexDirection="column"
gap={2}
p={2}
w="100%"
h="100%"
bg="transparent"
overflowY="auto"
>
{filteredData.map((volcano, index) => {
if (thisWidget?.size === "large")
return (
<Collapsible.Root key={index} w="100%">
{/* Using context to dynamically change the border radii of the triggers */}
<Collapsible.Context>
{(collapsible) => (
<>
<Collapsible.Trigger w="100%">
<Flex direction="row"
<Flex
direction="row"
w="100%"
justify="space-between" align="center"
p={1} pl={3} pr={3}
justify="space-between"
align="center"
p={1}
pl={3}
pr={3}
borderTopRadius="lg"
borderBottomRadius={collapsible.open ? "0" : "lg"}
bg="gray.600"
bg="widgetItemBg"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Text truncate >{volcano[0]}</Text> {/* volcano name */}
<Text truncate fontSize="xs" color="gray.300/90">{volcano[1][0]?.alert_start_date_local}</Text>
<Flex
direction="column"
w="100%"
minW={0}
textAlign="left"
>
<Text truncate color="text" fontWeight="medium">
{volcano[0]}
</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">
{new Date(volcano[1][0]?.alert_start_date_local)
.toLocaleString('en-US', options)
}
</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 w="100%" minW={0} justify="right" gap={2}>
<Flex w="fit" color="iconColor">
{volcano[1][0]?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Flex>
<Text
truncate
pl={2}
pr={2}
borderRadius="lg"
bg="widgetBadgeBg"
color="text"
fontWeight="semibold"
>
{volcano[1][0]?.alert_level_name}
</Text>
</Flex>
</Flex>
</Collapsible.Trigger>
<Collapsible.Content w="100%" p={3} borderBottomRadius="lg" bg="gray.700">
)}
</Collapsible.Context>
<Collapsible.Content
w="100%"
p={3}
borderBottomRadius="lg"
bg="widgetItemExpandedBg"
>
<Timeline.Root size="md" variant="subtle">
{volcano[1]?.map((object, index) => (
<Timeline.Item key={index} w="100%" h="100%">
<Timeline.Connector>
{/*<Timeline.Separator borderWidth="1px" borderColor="gray.600"/>*/}
<Timeline.Indicator outline="none" bg="gray.200" color="gray.500" size="lg">
<Timeline.Indicator
outline="none"
bg="transparent"
color="iconColor"
size="lg"
>
{object?.alert_scheme_domain === "terrestrial" && <Footprints size={15} />}
{object?.alert_scheme_domain === "aviation" && <Plane size={15} />}
{object?.alert_scheme_domain === "maritime" && <Sailboat size={15} />}
</Timeline.Indicator>
<Timeline.Indicator h="50%" outline="none" bg="gray.700" color="gray.100" size="lg">
{object?.change_type === "increase" ? <TrendingUp /> : <TrendingDown />}
<Timeline.Indicator
h="50%"
outline="none"
bg="transparent"
color="iconColor"
size="lg"
>
{object?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Timeline.Indicator>
</Timeline.Connector>
<Timeline.Content w="100%" h="100%">
{/*<Timeline.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"}
<Text
w="100%"
h="100%"
color="widgetTextMuted"
>
{new Date(object?.alert_start_date_local)
.toLocaleString('en-US', {...options, timeZone: 'UTC'})
}
</Text>
<Text
w="100%"
h="100%"
color={
alertColors[object?.alert_level_name] || "text"
}
fontSize="md"
fontWeight="semibold"
>
{object?.alert_level_name}
</Text>
<Flex w="100%" h="20px" direction="row" justify="left" align="center">
<Text color="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) => (
<Flex
w="100%"
h="20px"
direction="row"
justify="left"
align="center"
>
<Text color="widgetTextMuted" mr={1}>
Alert Scale:
</Text>
{Array.from(
{
length:
Number(object?.alert_scheme_highest) -
Number(object?.alert_scheme_lowest) + 1,
},
(v, i) => Number(object?.alert_scheme_lowest) + i
).map((num) => (
<Center
key={num}
w="20px" h="100%"
color="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}
w="20px"
h="100%"
color="text"
bg={
num === Number(object?.alert_level_rank)
? "widgetInnerBadgeBg"
: "transparent"
}
borderRadius={
num === Number(object?.alert_level_rank)
? "xl"
: "none"
}
textAlign="center"
fontSize="sm"
fontWeight="semibold"
fontWeight="bold"
>
{num}
</Center>
))
}
))}
</Flex>
</Flex>
</Timeline.Description>
@@ -166,54 +363,77 @@ export default function AlertChanges({ settings, widgetID}) {
))}
</Timeline.Root>
</Collapsible.Content>
</>
)}
</Collapsible.Context>
</Collapsible.Root>
)
else if (thisWidget?.size === "small") return (
volcano[1]?.map((domain, index) => (
);
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}
justify="space-between"
align="center"
p={1}
pl={3}
pr={3}
borderRadius="lg"
bg="gray.600"
bg="widgetItemBg"
>
<Flex
direction="column"
w="100%"
minW={0}
textAlign="left"
color="text"
>
<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">
<Flex color="iconColor">
{domain[0] === "aviation" && <Plane size={15} />}
{domain[0] === "terrestrial" && <Footprints size={15} />}
{domain[0] === "maritime" && <Sailboat size={15} />}
</Flex>
<Text truncate>{volcano[0] || ""}</Text> {/* volcano name */}
<Text truncate fontWeight="medium">
{volcano[0] || ""}
</Text>
</Flex>
<Text truncate w="100%" fontSize="xs" color="gray.300/90">{domain[1]?.[0]?.alert_start_date_local || ""}</Text>
<Text truncate w="100%" fontSize="xs" color="widgetTextMuted">
{domain[1]?.[0]?.alert_start_date_local || ""}
</Text>
</Flex>
<Flex direction="row" w="100%" minW={0} justify="right" gap={2} color="gray.200">
<Flex w="25px" justify="right">
{domain[1]?.[0]?.change_type === "increase" ? <TrendingUp/> : <TrendingDown/>}
<Flex
direction="row"
w="100%"
minW={0}
justify="right"
gap={2}
color="text"
>
<Flex w="25px" justify="right" color="iconColor">
{domain[1]?.[0]?.change_type === "increase" ? (
<TrendingUp />
) : (
<TrendingDown />
)}
</Flex>
<Text
truncate
pl={2} pr={2}
pl={2}
pr={2}
borderRadius="lg"
bg="gray.800/50"
color={alertColors[domain[1]?.[0]?.alert_level_name] || "gray.100"}
bg="widgetBadgeBg"
color={
alertColors[domain[1]?.[0]?.alert_level_name] || "text"
}
fontWeight="semibold"
>
{domain[1]?.[0]?.alert_level_name}
</Text>
</Flex>
</Flex>
))
);
));
})}
</Box>
)
);
}
@@ -115,19 +115,19 @@ export default function CommCoorReport({ settings, widgetID }) {
}, [settings, widgetID, updateSettings, apiData]);
if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" textAlign="center" borderRadius="lg" bg="transparent">
<Text color="red.400">{error}</Text>
</Flex>
);
if (!apiData) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Text textAlign="center" color="gray.400">No data</Text>
</Flex>
);
@@ -5,13 +5,17 @@ import Plot from 'react-plotly.js';
import VdapApi from '../../hooks/useVdapApi.js';
import useBuildUrl from "@/hooks/useBuildUrl.js";
// 1. Import your central color dictionary
import { rawWidgetColors } from '../../theme.js';
export default function ExplosionEvents({ settings, widgetID }) {
// Plotly expects 'linear' for normal axes
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
// 🎨 Let's check color mode so we can make people happy with light and dark
const { colorMode } = useColorMode();
const isDark = colorMode === 'dark';
// Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
// Generate url with input dates, default to 7 day range
const url = useBuildUrl('/api/v2/explosion_events', settings?.startDate, settings?.endDate);
@@ -53,7 +57,7 @@ export default function ExplosionEvents({ settings, widgetID }) {
mode: 'markers',
marker: {
size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
line: { width: .5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length],
},
// Replicating the clean D3/Observable tooltip
@@ -62,49 +66,37 @@ export default function ExplosionEvents({ settings, widgetID }) {
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
'Number of Explosions: %{y}<extra></extra>'
}));
}, [apiData]);
}, [apiData, plotColors]);
// 2. Build the Layout (Styling and Grid)
const layout = useMemo(() => {
return {
autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300
font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 },
// title: {
// text: '7 Day Consolidated Flight Levels',
// font: { size: 16, color: 'white' },
// x: 0.05
// },
xaxis: {
title: {
text: 'Date (UTC)',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
gridcolor: '#4b5563',
zerolinecolor: '#4b5563',
tickformat: '%b %d<br>%Y',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.grid,
automargin: true,
},
yaxis: {
title: {
text: 'Number of Explosions',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: plotYAxisType,
gridcolor: '#4b5563',
zerolinecolor: '#ffffff',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.text,
automargin: true,
},
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest',
shapes: [
{
@@ -115,37 +107,37 @@ export default function ExplosionEvents({ settings, widgetID }) {
y0: 0,
y1: 0,
yref: 'y',
line: { color: 'white', width: 1 }
line: { color: plotColors.text, width: 1 }
}
],
};
}, [apiData, plotYAxisType]);
}, [plotYAxisType, plotColors]);
if (isLoading) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" />
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text>
</Flex>
);
if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No data available for current filter.</Text>
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="var(--chakra-colors-widgetTextMuted)">No data available for current filter.</Text>
</Flex>
);
return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot
key={plotYAxisType}
key={`${plotYAxisType}-${colorMode}`}
data={plotTraces}
layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing!
useResizeHandler={true}
style={{ width: '100%', height: '100%' }}
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
/>
+33 -30
View File
@@ -1,4 +1,4 @@
import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react";
import {Button, Flex, Spinner, Text, Box, Collapsible, Highlight} from "@chakra-ui/react";
import {useEffect, useMemo, useCallback } from 'react';
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import VdapApi from '../../hooks/useVdapApi.js';
@@ -52,21 +52,22 @@ export default function IvansAlerts({ settings, widgetID }) {
}
}, [settings, some_data, widgetID, updateSettings]);
// Cleaned hardcoded grays for transparent so glass shell shows through
if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Text color="red.400">{error}</Text>
</Flex>
);
if (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 w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="transparent">
<Text textAlign="center" color="widgetTextMuted">No flight level data available for the selected dates.</Text>
</Flex>
);
@@ -78,7 +79,7 @@ export default function IvansAlerts({ settings, widgetID }) {
p={2} gap={2}
overflow="auto"
borderRadius="xl"
bg="gray.800"
bg="transparent" // Let the global widgetRecipe glass shell show through
>
{some_data.map((item, index) => {
if (thisWidget?.size === "large") return (
@@ -96,47 +97,48 @@ export default function IvansAlerts({ settings, widgetID }) {
gap={2}
borderTopRadius="lg"
borderBottomRadius={collapsible.open ? "0" : "lg"}
bg="gray.600"
bg="widgetItemBg"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Text truncate>{item.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate color="text" fontWeight="medium">{item.volcano_name}</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">{item.send_time}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{item.report_num}</Text>
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">{item.report_num}</Text>
</Flex>
</Flex>
</Collapsible.Trigger>
<Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Preparer: {item?.preparer}</Text>
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
<Flex direction="column" gap={2} borderRadius="lg" p={2} align="start">
<Text w="fit" fontSize="sm" color="text" p={1} bg="widgetBadgeBg" borderRadius="md">Preparer: {item?.preparer}</Text>
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
</Flex>
{item?.files?.map((file, index) => {
return (
<Box w="100%" h="100%" key={index} 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
<Flex direction="row" mb={2} gap={1} justify="space-between">
<a href={`${file?.url}#view=FitV&toolbar=0`} target="_blank" rel="noopener noreferrer">
<Text mr={1} truncate p={1} bg="widgetBadgeBg" borderRadius="lg" fontSize="sm" color="text">
File: {file?.name}
</Text>
</a>
</Box>
{/*<Box borderRadius="lg" bg="widgetBadgeBg" color="text" p={1} fontSize="sm" asChild>*/}
{/* Open*/}
{/*</Box>*/}
</Flex>
<object
data={`${file?.url}#view=FitH&toolbar=1`}
type="application/pdf"
style={{ width: '100%', height: '100%', display: 'block' }}
>
<Flex w="100%" h="100%" p={4} color="gray.400">
<Text mb={2} color="fg.muted">
<Flex direction="column" w="100%" h="100%" align="center" p={4} color="widgetTextMuted" bg="widgetBadgeBg" borderRadius="md">
<Text mb={2} fontSize="xs" color="red.300">
Your browser cannot display this PDF directly.
</Text>
<a href={file?.url} target="_blank">
Download PDF
<a href={file?.url} target="_blank" bg="widgetBadgeBg" rel="noopener noreferrer">
<Text fontSize="xs" bg="widgetInnerBadgeBg" borderRadius="md" p={1}>Download PDF Instead</Text>
</a>
</Flex>
</object>
@@ -151,19 +153,20 @@ export default function IvansAlerts({ settings, widgetID }) {
)
else if (thisWidget?.size === "small") return (
<Flex direction="row"
key={index} // Added missing map key
w="100%"
justify="space-between" align="center"
p={1} pl={3} pr={3}
gap={2}
borderRadius="lg"
bg="gray.600"
bg="widgetItemBg"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate>{item.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">{item.send_time}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{item.report_num}</Text>
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontWeight="semibold">{item.report_num}</Text>
</Flex>
</Flex>
)
+30 -33
View File
@@ -1,10 +1,18 @@
import VdapApi from '../../hooks/useVdapApi.js';
import { useMemo } from 'react';
import Plot from 'react-plotly.js';
import { Box, Flex, Spinner, Text } from "@chakra-ui/react";
import { useColorMode } from "@/components/ui/color-mode.jsx";
import Plot from 'react-plotly.js';
import VdapApi from '../../hooks/useVdapApi.js';
import useBuildUrl from "@/hooks/useBuildUrl.js";
// 1. Import your central color dictionary
import { rawWidgetColors } from '../../theme.js';
export default function PlumeEvents({ settings, widgetID}) {
const { colorMode } = useColorMode();
// 2. Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
// Build url, defaults to 7 days past
const url = useBuildUrl('/api/v2/plume_events', settings?.startDate, settings?.endDate);
@@ -44,7 +52,7 @@ export default function PlumeEvents({ settings, widgetID}) {
mode: 'markers',
marker: {
size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
line: { width: .5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length],
},
// Replicating the clean D3/Observable tooltip
@@ -53,88 +61,77 @@ export default function PlumeEvents({ settings, widgetID}) {
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
'Plume Height (m): %{y}<extra></extra>'
}));
}, [apiData]);
}, [apiData, plotColors]);
const layout = useMemo(() => {
return {
autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through
plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300
font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 },
// title: {
// text: '7 Day Consolidated Flight Levels',
// font: { size: 16, color: 'white' },
// x: 0.05
// },
xaxis: {
title: {
text: 'Date (UTC)',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
gridcolor: '#4b5563',
zerolinecolor: '#4b5563',
tickformat: '%b %d<br>%Y',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.grid,
automargin: true,
},
yaxis: {
title: {
text: 'Plume Height',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: 'linear',
gridcolor: '#4b5563',
zerolinecolor: '#ffffff',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.text,
automargin: true,
},
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest',
shapes: [
{
type: 'line',
x0: 0,
x1: 1,
xref: 'paper', // Spans entire chart width
xref: 'paper',
y0: 0,
y1: 0,
yref: 'y',
line: { color: 'white', width: 1 }
line: { color: plotColors.text, width: 1 }
}
],
};
}, []);
}, [plotColors]);
if (isLoading) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" />
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text>
</Flex>
);
if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No data available for current filter.</Text>
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="widgetTextMuted">No data available for current filter.</Text>
</Flex>
);
return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot
key={colorMode}
data={plotTraces}
layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing!
useResizeHandler={true}
style={{ width: '100%', height: '100%' }}
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
/>
+23 -24
View File
@@ -52,25 +52,25 @@ export default function RemoteSensing({ settings, widgetID }) {
}, [settings, data, widgetID, updateSettings]);
if (isLoading) return (
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="md" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" bg="transparent">
<Spinner size="xl" color="blue.400" thickness="4px"/>
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" align="center" justify="center" p={4} borderRadius="md" bg="gray.800">
<Flex w="100%" h="100%" align="center" justify="center" p={4} bg="transparent">
<Text color="red.400">{error}</Text>
</Flex>
);
if (data.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No Remote Sensing data available this week.</Text>
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="widgetTextMuted">No Remote Sensing data available this week.</Text>
</Flex>
);
return (
<Flex direction="column" w="100%" h="100%" pos="relative" justify="top" align="center" p={2} gap={2} overflow="auto" borderRadius="xl" bg="gray.800">
<Flex direction="column" w="100%" h="100%" pos="relative" justify="top" align="center" p={2} gap={2} overflow="auto" bg="transparent">
{data.map((item, index) => {
if (thisWidget?.size === "large" || thisWidget?.size === "medium") return (
<Collapsible.Root key={index} w="100%">
@@ -80,14 +80,14 @@ export default function RemoteSensing({ settings, widgetID }) {
<>
<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"
borderTopRadius="lg" borderBottomRadius={collapsible.open ? "0" : "lg"} bg="widgetItemBg"
>
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate>{item?.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">{item?.country}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontSize="sm" fontWeight="semibold">
<Text truncate pl={2} pr={2} borderRadius="lg" bg="widgetBadgeBg" color="text" fontSize="sm" fontWeight="semibold">
{item?.report_time}
</Text>
</Flex>
@@ -95,10 +95,10 @@ export default function RemoteSensing({ settings, widgetID }) {
</Collapsible.Trigger>
<Collapsible.Content w="100%" h="auto">
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Source: {item?.sender}</Text>
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="widgetItemExpandedBg">
<Flex direction="column" gap={2} borderRadius="lg" p={2} align="end">
<Text w="100%" h="100%" fontSize="sm" color="text" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
<Text w="fit" fontSize="sm" color="text" pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md">Source: {item?.sender}</Text>
</Flex>
{item?.files?.map((file, index) => {
@@ -107,15 +107,15 @@ export default function RemoteSensing({ settings, widgetID }) {
<Box w="100%" h="100%" key={index} p={1}>
<br />
<Flex dir="row" justify="space-between" align="center" mb={2}>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PDF</Text>
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="widgetTextMuted">PDF</Text>
</Flex>
<object
data={`${file?.url}#view=FitH&toolbar=1`}
type="application/pdf"
style={{width: '100%', height: '100%', display: 'block'}}
>
<Flex w="100%" h="100%" p={4} color="gray.400">
<Flex w="100%" h="100%" p={4} color="widgetTextMuted">
<Text mb={2} color="fg.muted">
Your browser cannot display this PDF directly.
</Text>
@@ -132,8 +132,8 @@ export default function RemoteSensing({ settings, widgetID }) {
<Box key={index} w="100%" h="100%" p={1}>
<br />
<Flex dir="row" justify="space-between" align="center" mb={2}>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PNG</Text>
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="widgetTextMuted">PNG</Text>
</Flex>
<a href={file?.url} target="_blank" rel="noopener noreferrer">
<img src={file?.url} alt={file?.name} />
@@ -146,7 +146,7 @@ export default function RemoteSensing({ settings, widgetID }) {
<>
<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>
<Text pl={1} pr={1} bg="widgetBadgeBg" borderRadius="md" fontSize="xs" color="text">{file?.name}</Text>
<Button asChild variant="surface" size="xxs" fontSize="xs" pl={2} pr={2}>
<a href={file?.url} target="_blank" rel="noopener noreferrer">Download</a>
</Button>
@@ -162,17 +162,17 @@ export default function RemoteSensing({ settings, widgetID }) {
</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">
<Flex key={index} direction="row" w="100%" justify="space-between" align="center" gap={2} p={1} pl={3} pr={3} borderRadius="lg" bg="widgetItemBg">
<Flex direction="column" w="100%" minW={0} textAlign="left" color="text">
<Text truncate>{item?.volcano_name}</Text>
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text>
<Text truncate fontSize="xs" color="widgetTextMuted">{item?.country}</Text>
</Flex>
<Flex w="100%" minW={0} justify="right">
<Text
truncate
pl={2} pr={2}
borderRadius="lg"
bg="gray.800/50" color="gray.100"
bg="widgetBadgeBg" color="text"
fontSize="sm" fontWeight="semibold"
>
{item?.report_time}
@@ -185,7 +185,6 @@ export default function RemoteSensing({ settings, widgetID }) {
);
}
function ascTimeVolcanoSort(a, b) {
if (a.volcano_name < b.volcano_name) return -1
else if (a.volcano_name > b.volcano_name) return 1
+25 -28
View File
@@ -4,7 +4,15 @@ import Plot from 'react-plotly.js';
import { Box, Flex, Spinner, Text } from "@chakra-ui/react";
import useBuildUrl from "@/hooks/useBuildUrl.js";
// 1. Import Chakra color mode and your central color dictionary
import { useColorMode } from "@/components/ui/color-mode.jsx";
import { rawWidgetColors } from '../../theme.js';
export default function SeismicEvents({ settings, widgetID}) {
const { colorMode } = useColorMode();
// 2. Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
// build url, defaults to 7 days past
const url = useBuildUrl('/api/v2/seismic_events', settings?.startDate, settings?.endDate);
@@ -44,7 +52,7 @@ export default function SeismicEvents({ settings, widgetID}) {
mode: 'markers',
marker: {
size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
line: { width: .5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length],
},
// Replicating the clean D3/Observable tooltip
@@ -53,48 +61,36 @@ export default function SeismicEvents({ settings, widgetID}) {
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
'Number of Seismic Events: %{y}<extra></extra>'
}));
}, [apiData]);
}, [apiData, plotColors]);
const layout = useMemo(() => {
return {
autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through
plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300
font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 },
// title: {
// text: '',
// font: { size: 16, color: 'white' },
// x: 0.05
// },
xaxis: {
title: {
text: 'Date (UTC)',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: 'date',
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
gridcolor: '#4b5563',
zerolinecolor: '#4b5563',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.grid,
automargin: true,
},
yaxis: {
title: {
text: 'Seismic Count',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: 'linear',
gridcolor: '#4b5563',
zerolinecolor: '#ffffff',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.text,
automargin: true,
},
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest',
shapes: [
{
@@ -105,33 +101,34 @@ export default function SeismicEvents({ settings, widgetID}) {
y0: 0,
y1: 0,
yref: 'y',
line: { color: 'white', width: 1 }
line: { color: plotColors.text, width: 1 }
}
],
};
}, []);
}, [plotColors]);
if (isLoading) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" />
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text>
</Flex>
);
if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No data available for current filter.</Text>
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="var(--chakra-colors-widgetTextMuted)">No data available for current filter.</Text>
</Flex>
);
return (
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot
key={colorMode}
data={plotTraces}
layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing!
@@ -1,11 +1,18 @@
import { useMemo } from 'react';
import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
import Plot from 'react-plotly.js';
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
import VdapApi from '../../hooks/useVdapApi.js';
import { useColorMode } from "@/components/ui/color-mode.jsx";
// Import central color dictionary
import { rawWidgetColors } from '../../theme.js'; // Adjust path if needed
export default function VaaActivity7Day({ settings, widgetID }) {
console.log("3. Chart received settings:", settings);
// Plotly expects 'linear' for normal axes
const { colorMode } = useColorMode();
// 2. Assign the active palette based on the current mode
const plotColors = colorMode === 'dark' ? rawWidgetColors.dark : rawWidgetColors.light;
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
// Clean, single-line data fetching
@@ -17,13 +24,10 @@ export default function VaaActivity7Day({ settings, widgetID }) {
// Array of distinct Plotly shapes
const Symbolway = [
'circle', 'square', 'diamond',
'triangle-up', 'triangle-down',
'star', 'hexagram', 'pentagon',
'hexagon', 'octagon', 'cross', 'x'
'circle', 'square', 'diamond', 'triangle-up', 'triangle-down',
'star', 'hexagram', 'pentagon', 'hexagon', 'octagon', 'cross', 'x'
];
// Sort data descending by flight level (matching Observable behavior)
const sortedData = [...apiData].sort((a, b) => b.obs_fl - a.obs_fl);
// Efficiently group into Plotly traces by volcano
@@ -44,7 +48,7 @@ export default function VaaActivity7Day({ settings, widgetID }) {
mode: 'markers',
marker: {
size: 7,
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
line: { width: 0.5, color: plotColors.text },
symbol: Symbolway[index % Symbolway.length],
},
// Replicating the clean Observable tooltip
@@ -53,90 +57,78 @@ export default function VaaActivity7Day({ settings, widgetID }) {
'Date: %{x|%b %d %Y %H:%M}<br>' +
'Flight Level: %{y}<extra></extra>'
}));
}, [apiData]);
}, [apiData, plotColors]);
// 2. Build the Layout (Styling and Grid)
const layout = useMemo(() => {
return {
autosize: true,
paper_bgcolor: 'transparent', // Let Chakra UI background show through
paper_bgcolor: 'transparent',
plot_bgcolor: 'transparent',
font: { color: '#d1d5db' }, // gray.300
font: { color: plotColors.muted },
margin: { t: 40, r: 20, l: 20, b: 20 },
// title: {
// text: '7 Day Consolidated Flight Levels',
// font: { size: 16, color: 'white' },
// x: 0.05
// },
xaxis: {
title: {
text: 'Date (UTC)',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: 'date',
tickformat: '%b %d<br>%Y %H:%M', // Multiline ticks from Observable
gridcolor: '#4b5563',
zerolinecolor: '#4b5563',
tickformat: '%b %d<br>%Y %H:%M',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.grid,
automargin: true,
},
yaxis: {
title: {
text: 'Flight Level',
font: { color: 'white', size: 12 }
font: { color: plotColors.text, size: 12 }
},
type: plotYAxisType,
gridcolor: '#4b5563',
zerolinecolor: '#ffffff',
gridcolor: plotColors.grid,
zerolinecolor: plotColors.text,
automargin: true,
},
// legend: {
// orientation: 'h',
// y: -0.2,
// x: 0.5,
// xanchor: 'center'
// },
hovermode: 'closest',
shapes: [
{
type: 'line',
x0: 0,
x1: 1,
xref: 'paper', // Spans entire chart width
xref: 'paper',
y0: 0,
y1: 0,
yref: 'y',
line: { color: 'white', width: 1 }
line: { color: plotColors.text, width: 1 }
}
],
};
}, [apiData, plotYAxisType]);
}, [plotYAxisType, plotColors]);
if (isLoading) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center">
<Spinner size="xl" color="blue.400" thickness="4px" />
</Flex>
);
if (error) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
<Flex w="100%" h="100%" bg="transparent" align="center" justify="center" p={4}>
<Text color="red.400">{error}</Text>
</Flex>
);
if (plotTraces.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No flight level data available.</Text>
<Flex height="100%" width="100%" align="center" justify="center" bg="transparent">
<Text color="widgetTextMuted">No flight level data available.</Text>
</Flex>
);
return (
<Box height="100%" width="100%" bg="gray.800" borderRadius="md" p={2} overflow="hidden">
<Box height="100%" width="100%" bg="transparent" overflow="hidden">
<Plot
key={plotYAxisType}
key={`${plotYAxisType}-${colorMode}`}
data={plotTraces}
layout={layout}
useResizeHandler={true} // Essential for dashboard grid resizing!
useResizeHandler={true}
style={{ width: '100%', height: '100%' }}
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
/>
@@ -1,36 +1,289 @@
import {Flex, Slider, Switch} from "@chakra-ui/react";
import {
Flex,
Slider,
Switch,
Text,
Field,
Fieldset,
Input,
CheckboxCard,
CheckboxGroup,
Combobox,
Portal,
useFilter,
useListCollection,
Wrap,
Badge
} from "@chakra-ui/react";
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
import { useState, useMemo } from "react";
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
export default function AlertChangeSettings({ widgetId, currentSettings }) {
const updateSettings = useDashboardStore(
(state) => state.updateWidgetSettings,
);
const [startError, setStartError] = useState(null);
const [endError, setEndError] = useState(null);
console.log("currentSettings:", currentSettings);
const [selectedVolcanoes, setSelectedVolcanoes] = useState([]);
const { startsWith } = useFilter({ sensitivity: "base" });
const { collection, filter, reset } = useListCollection({
initialItems: volcanoes,
filter: startsWith,
limit: 10,
});
const handleValueChange = (details) => {
setSelectedVolcanoes(details.value)
}
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});
<Flex direction="column" pb={2} overflowY="auto">
<Fieldset.Root>
<Fieldset.Content>
{/* SELECTING DATE RANGE */}
<Flex direction="column" w="100%" h="100%" justify="center" gap={2}>
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Date Range Selection
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* start date */}
<Field.Root invalid={!!startError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="text">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.startDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {
...currentSettings,
startDate: value,
});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && dateSelected > today)
setStartError("Can't Choose a Future Date");
else setStartError("");
}}
/>
{startError && (
<Field.ErrorText>{startError}</Field.ErrorText>
)}
</Flex>
</Flex>
</Field.Root>
{/* end date */}
<Field.Root invalid={!!endError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="text">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.endDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {
...currentSettings,
endDate: value,
});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && dateSelected > today)
setEndError("Can't Choose a Future Date");
else setEndError("");
}}
/>
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex>
{/* FILTERS */}
<CheckboxGroup w="100%">
<Flex direction="column" w="100%" gap={2}>
<Flex direction="column" w="100%">
<Text w="fit" fontWeight="bold">
Filters
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* level increases */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.levelIncreases}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
levelIncreases: e.checked,
},
});
}}
>
<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>
<CheckboxCard.HiddenInput /> {/* when removed can't check/uncheck box */}
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Level Increases
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* level decreases */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.levelDecreases}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
levelDecreases: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Level Decreases
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* elevated levels */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.elevatedLevels}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
elevatedLevels: e.checked,
},
});
}}
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Only Elevated Levels
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
</Flex>
)
</CheckboxGroup>
{/* select volcano to filter on */}
<Combobox.Root
mt={1}
variant="subtle"
multiple
openOnClick
collection={collection}
onValueChange={handleValueChange}
onInputValueChange={(e) => filter(e.inputValue)}
>
<Wrap gap="2">
{selectedVolcanoes.map((volcano) => (
<Badge key={volcano}>{volcano}</Badge>
))}
</Wrap>
<Combobox.Label fontSize="xs" >Select Volcano (s)</Combobox.Label>
<Combobox.Control>
<Combobox.Input placeholder="Type to search" borderRadius="lg"/>
<Combobox.IndicatorGroup>
<Combobox.ClearTrigger />
<Combobox.Trigger onClick={reset} />
</Combobox.IndicatorGroup>
</Combobox.Control>
<Portal>
<Combobox.Positioner>
<Combobox.Content>
{volcanoes.map((item) => (
<Combobox.Item item={item} key={item.value}>
{item.label}
<Combobox.ItemIndicator />
</Combobox.Item>
))}
<Combobox.Empty>No items found</Combobox.Empty>
</Combobox.Content>
</Combobox.Positioner>
</Portal>
</Combobox.Root>
</Fieldset.Content>
</Fieldset.Root>
</Flex>
);
}
const volcanoes = [
{ label: "Mt. St. Helens", value: "helens" },
{ label: "Mt. Adams", value: "adams" },
{ label: "Rainier", value: "rainier" },
{ label: "The Three Sisters", value: "three sisters" },
{ label: "Fuego", value: "fuego" },
{ label: "Laki Laki", value: "lakilaki" },
{ label: "Lewotobi", value: "lewotobi" },
{ label: "Karangatang", value: "karangatang" },
{ label: "Cotopaxi", value: "cotopaxi" },
{ label: "Merapi", value: "merapi" },
{ label: "Marapi", value: "marapi" },
{ label: "Kick 'Em Jenny", value: "kick em jenny"}
]
@@ -1,4 +1,4 @@
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
import {Button, Field, Flex, Input, Switch, Text, Fieldset, CheckboxCard} from "@chakra-ui/react";
import {useDashboardStore} from "../../../store/useDashboardStore.jsx";
import {useState} from "react";
@@ -20,11 +20,108 @@ export default function IvansAlertsSettings({ widgetId, currentSettings }) {
}
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>
<Flex flexDirection="column" justifyContent="space-between" overflowY="auto">
<Fieldset.Root>
<Fieldset.Content>
{/* SELECTING DATE RANGE */}
<Flex direction="column" w="100%" h="100%" justify="center" gap={2}>
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Date Range Selection
</Text>
<hr style={{borderColor: "red.700"}} /> {/*line seperator*/}
</Flex>
{/* start date */}
<Field.Root invalid={!!startError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="text">
Start Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.startDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {
...currentSettings,
startDate: value,
});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && dateSelected > today)
setStartError("Can't Choose a Future Date");
else setStartError("");
}}
/>
{startError && (
<Field.ErrorText>{startError}</Field.ErrorText>
)}
</Flex>
</Flex>
</Field.Root>
{/* end date */}
<Field.Root invalid={!!endError}>
<Flex
direction="row"
w="100%"
h="100%"
align="center"
justify="center"
>
<Text w="50%" align="center" justify="center" color="text">
End Date
</Text>
<Flex direction="column" w="100%">
<Input
type="date"
w="100%"
borderRadius="lg"
value={currentSettings?.endDate || ""}
onChange={(e) => {
const value = e.target.value;
updateSettings(widgetId, {
...currentSettings,
endDate: value,
});
const dateSelected = new Date(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (value && dateSelected > today)
setEndError("Can't Choose a Future Date");
else setEndError("");
}}
/>
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
</Flex>
</Flex>
</Field.Root>
</Flex>
{/* SORTS */}
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Sorts
</Text>
<hr /> {/*line seperator*/}
</Flex>
{/* time sort */}
<Switch.Root
checked={currentSettings?.activeSorts?.timeSort === "asc"}
onCheckedChange={handleTimeSort}
@@ -34,8 +131,10 @@ export default function IvansAlertsSettings({ widgetId, currentSettings }) {
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort Oldest to Newest</Switch.Label>
<Switch.Label fontWeight={400}>Oldest to Newest</Switch.Label>
</Switch.Root>
{/* alphabetic sort */}
<Switch.Root
checked={currentSettings?.activeSorts?.volcanoSort}
onCheckedChange={handleVolcanoSort}
@@ -45,64 +144,53 @@ export default function IvansAlertsSettings({ widgetId, currentSettings }) {
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label>Sort by Volcano</Switch.Label>
<Switch.Label fontWeight={400}>Alphabetically 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
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Filters
</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});
<hr /> {/*line seperator*/}
</Flex>
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("");
{/* elevated levels */}
<CheckboxCard.Root
size="sm"
variant="surface"
borderRadius="lg"
defaultChecked={currentSettings?.filters?.elevatedLevels}
onCheckedChange={(e) => {
updateSettings(widgetId, {
...currentSettings,
filters: {
...currentSettings.filters,
elevatedLevels: e.checked,
},
});
}}
/>
{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
>
<CheckboxCard.HiddenInput />
<CheckboxCard.Control>
<CheckboxCard.Content>
<CheckboxCard.Label fontWeight={400}>
Only Elevated Levels
</CheckboxCard.Label>
</CheckboxCard.Content>
<CheckboxCard.Indicator />
</CheckboxCard.Control>
</CheckboxCard.Root>
{/* COPY TO CLIPBOARD */}
<Flex direction="column">
<Text w="100%" fontWeight="bold">
Tools
</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>
<hr /> {/*line seperator*/}
<Button mt={2} w="100%" size="xs" borderRadius="lg" variant="subtle" onClick={handleCopyToClipboard}>Copy All IVANS</Button>
</Flex>
</Fieldset.Content>
</Fieldset.Root>
</Flex>
)
}
@@ -1,7 +1,6 @@
import { useState, useMemo, useRef } from "react";
import {useState, useMemo, useRef, useEffect, useCallback} from "react";
import {
Button,
IconButton,
Box,
Text,
Flex,
@@ -10,25 +9,64 @@ import {
Tabs,
Badge,
Menu,
HStack
HStack,
useRecipe,
useSlotRecipe
} from "@chakra-ui/react";
import { Settings, Plus, LayoutGrid, Move, Save, X } from 'lucide-react';
import {Settings, LayoutGrid, Move, Save, X, ChevronRight, ChevronLeft} from 'lucide-react';
import {useDashboardStore} from "../../store/useDashboardStore.jsx";
import {WIDGET_REGISTRY} from "../../constants/widgetRegistry.jsx";
// 🚨 NEW: Tiny helper to convert size keys into abbreviations
const getSizeAbbreviation = (key) => {
const mapping = {small: "S", medium: "M", large: "L", xlarge: "XL"};
return mapping[key.toLowerCase()] || key.charAt(0).toUpperCase();
};
export function SettingsButton() {
// 1. ☁Store connections
const isEditing = useDashboardStore(state => state.isEditing);
const enterEditMode = useDashboardStore(state => state.enterEditMode);
// This now handles both local UI lock AND database saving!
const saveEdit = useDashboardStore(state => state.saveEdit);
const cancelEdit = useDashboardStore(state => state.cancelEdit);
const addWidget = useDashboardStore(state => state.addWidget);
const glassTabRecipe = useRecipe({key: "glassTab"});
const tabStyles = glassTabRecipe();
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// Pull the glass container styles for the dropdown and modal
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
const popoverStyles = popoverRecipe();
const dialogRecipe = useSlotRecipe({ key: "glassDialog" });
const dialogStyles = dialogRecipe();
const [isLibraryOpen, setIsLibraryOpen] = useState(false);
// ⬅️ ➡️ This is the callbacks and scroll control to add the chevron arrows in the tab list
const tabsRef = useRef(null);
const checkScroll = useCallback(() => {
if (tabsRef.current) {
const {scrollLeft, scrollWidth, clientWidth} = tabsRef.current;
setCanScrollLeft(scrollLeft > 0);
// We use -1 here to account for sub-pixel rendering in some browsers
setCanScrollRight(Math.ceil(scrollLeft + clientWidth) < scrollWidth - 1);
}
}, []);
useEffect(() => {
if (isLibraryOpen) {
// Small timeout ensures the DOM has rendered the tabs before measuring
setTimeout(checkScroll, 50);
window.addEventListener('resize', checkScroll);
return () => window.removeEventListener('resize', checkScroll);
}
}, [isLibraryOpen, checkScroll]);
// Smart Add Handler
const handleAddWidget = (widgetId, sizeKey) => {
enterEditMode();
@@ -55,94 +93,172 @@ export function SettingsButton() {
return (
<>
{/* THE STATIC DROPDOWN MENU */}
{isEditing ? (
// 📝 EDIT MODE: The 3-Pill Layout
<HStack>
{/* 1. The Add Widget Button */}
<Box as="button" css={tabStyles} data-state="open" onClick={() => setIsLibraryOpen(true)}
color={{base: "blue.500", _dark: "blue.300"}} _hover={{bg: "rgba(66, 153, 225, 0.1)"}}
>
<LayoutGrid size={20} style={{flexShrink: 0}}/>
<Box as="span">Widget Library</Box>
</Box>
{/* 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>
<IconButton
ref={menuRef}
aria-label="Settings"
bg="vdap.darkGreen"
color="white"
size="sm"
borderRadius="md"
_hover={{ bg: "vdap.darkGreenHover" }}
>
<Settings size={18} />
</IconButton>
<Box ref={menuRef} as="button" css={tabStyles}>
{/* settings icon size set to 24 to match tabs. flexShrink 0 is CRITICAL */}
<Settings size={24} style={{flexShrink: 0}}/>
<Box as="span">Dashboard Settings</Box>
</Box>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content bg="white" boxShadow="lg" borderRadius="md" p={1} zIndex="dropdown">
{isEditing ? (
<>
{/* WIRE DIRECTLY TO saveEdit */}
<Menu.Item onClick={saveEdit} color="green.600" fontWeight="bold" cursor="pointer" _hover={{ bg: "green.50" }}>
<Save size={16} style={{ marginRight: '8px' }} /> Save Layout
</Menu.Item>
<Menu.Item onClick={cancelEdit} color="red.500" cursor="pointer" _hover={{ bg: "red.50" }}>
<X size={16} style={{ marginRight: '8px' }} /> Discard Changes
</Menu.Item>
</>
) : (
<Menu.Item onClick={enterEditMode} cursor="pointer" _hover={{ bg: "gray.100" }}>
<Menu.Positioner>
<Menu.Content css={popoverStyles.content} zIndex="dropdown" minW="200px">
<Menu.Item
onClick={enterEditMode}
cursor="pointer"
_hover={{WebkitTextStroke: "0.5px currentColor"}}
>
<Move size={16} style={{marginRight: '8px'}}/> Edit Layout
</Menu.Item>
)}
<Menu.Separator my={1} borderColor="gray.200" />
<Menu.Separator my={1} borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}/>
<Menu.Item onClick={() => setIsLibraryOpen(true)} cursor="pointer" _hover={{ bg: "gray.100" }}>
<Menu.Item
onClick={() => setIsLibraryOpen(true)}
cursor="pointer"
_hover={{
bg: {base: "rgba(0, 0, 0, 0.05)", _dark: "rgba(255, 255, 255, 0.1)"},
WebkitTextStroke: "0.5px currentColor"
}}
>
<LayoutGrid size={16} style={{marginRight: '8px'}}/> Widget Library
</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
)}
{/* THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
<Dialog.Root open={isLibraryOpen} onOpenChange={(e) => setIsLibraryOpen(e.open)} size="xl" placement="center">
<Dialog.Backdrop bg="blackAlpha.600" />
{/* 📚📚📚 THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
<Dialog.Root
open={isLibraryOpen}
onOpenChange={(e) => setIsLibraryOpen(e.open)}
closeOnInteractOutside={true}
closeOnEscapeKeyDown={true}
size="xl"
placement="center"
>
{/* Soft blur can be added to the background behind the modal */}
{/*<Dialog.Backdrop bg="blackAlpha.100" backdropFilter="blur(2px)"/>*/}
<Dialog.Positioner>
<Dialog.Content bg="gray.50" borderRadius="xl" overflow="hidden" boxShadow="xl" maxW="800px">
<Dialog.Header bg="vdap.darkGreen" color="white" py={4}>
<Dialog.Title fontSize="xl" fontWeight="bold">Add Dashboard Widgets</Dialog.Title>
<Dialog.Content css={dialogStyles.content}>
<Dialog.Header css={dialogStyles.header}>
<Dialog.Title>Widgets Shop</Dialog.Title>
</Dialog.Header>
<Dialog.Body p={0}>
<Tabs.Root defaultValue={categories[0]} variant="enclosed" colorScheme="blue">
<Tabs.List bg="white" px={4} pt={4} borderBottom="1px solid" borderColor="gray.200">
<Dialog.Body css={dialogStyles.body}>
<Tabs.Root defaultValue={categories[0]} variant="unstyled">
{/* HORIZONTAL SCROLLABLE MENU OF WIDGET CATEGORIES */}
<Box>
{/* left glass crescent */}
{canScrollLeft && (
<Flex onClick={() => tabsRef.current.scrollBy({left: -200, behavior: 'smooth'})}>
<ChevronLeft size={18} style={{ marginLeft: "-30px" }}/> {/* Shifted slightly left to optically center it in the crescent mass */}
</Flex>
)}
{/* the categories */}
<Tabs.List ref={tabsRef} onScroll={checkScroll}
// For desktop users (not on trackpad) to scroll
onWheel={(e) => { if (e.currentTarget) e.currentTarget.scrollLeft += e.deltaY; }}
>
{categories.map(cat => (
<Tabs.Trigger key={cat} value={cat} pb={2}>{cat}</Tabs.Trigger>
<Tabs.Trigger key={cat} value={cat}>
{cat}
</Tabs.Trigger>
))}
</Tabs.List>
{/* right glass crescent */}
{canScrollRight && (
<Flex onClick={() => tabsRef.current.scrollBy({left: 200, behavior: 'smooth'})}>
{/* Shifted slightly right to optically center it in the crescent mass */}
<ChevronRight size={18} style={{ marginRight: "-30px" }}/>
</Flex>
)}
</Box>
{/* THE WIDGET OPTIONS W/IN EACH CATEGORY */}
{categories.map(cat => (
<Tabs.Content key={cat} value={cat} p={6}>
<Grid templateColumns="repeat(auto-fill, minmax(300px, 1fr))" gap={4}>
{categorizedWidgets[cat].map(widget => (
<Flex key={widget.id} direction="column" bg="white" p={4} borderRadius="md" border="1px solid" borderColor="gray.200" boxShadow="sm">
<Flex
key={widget.id}
direction="column"
bg={{base: "rgba(255, 255, 255, 0.6)", _dark: "rgba(0, 0, 0, 0.2)"}}
p={4}
borderRadius="xl"
border="1px solid"
borderColor={{base: "rgba(255, 255, 255, 0.9)", _dark: "whiteAlpha.200"}}
boxShadow="sm"
>
<Text fontWeight="bold" fontSize="md" mb={1}>{widget.name}</Text>
<Text fontSize="sm" color="gray.500" mb={4} flex="1">{widget.description}</Text>
<Box borderTop="1px solid" borderColor="gray.100" pt={3}>
<Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase" mb={2}>Add to Grid:</Text>
<Box borderTop="1px solid" borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}
pt={3}>
<Text fontSize="xs" fontWeight="bold" color="gray.400" textTransform="uppercase"
mb={2}>
Add to Dashboard:
</Text>
<HStack gap={2} flexWrap="wrap">
{Object.keys(widget.allowedSizes).map(sizeKey => (
<Badge
<Box
key={sizeKey}
as="button"
css={tabStyles}
onClick={() => handleAddWidget(widget.id, sizeKey)}
colorScheme="gray"
variant="subtle"
px={3}
py={1}
borderRadius="full"
cursor="pointer"
_hover={{ bg: "blue.50", color: "blue.600", transform: "scale(1.05)" }}
transition="all 0.2s"
color={{base: "blue.600", _dark: "blue.300"}}
>
<Plus size={12} style={{ display: 'inline', marginRight: '4px' }}/>
{sizeKey.charAt(0).toUpperCase() + sizeKey.slice(1)}
</Badge>
{/* THE ABBREVIATION ICON: Always visible */}
<Flex
align="center"
justify="center"
w="24px"
h="24px"
flexShrink={0}
borderRadius="full"
fontWeight="black"
fontSize="xs"
bg='transparent'
>
{getSizeAbbreviation(sizeKey)}
</Flex>
{/* THE EXPANDED TEXT: Only visible on hover */}
<Box as="span">Add Widget</Box>
</Box>
))}
</HStack>
</Box>
@@ -153,7 +269,8 @@ export function SettingsButton() {
))}
</Tabs.Root>
</Dialog.Body>
<Dialog.CloseTrigger position="absolute" top={3} right={3} color="white" />
<Dialog.CloseTrigger position="absolute" top={3} right={3}
color={{base: "gray.500", _dark: "whiteAlpha.800"}} _hover={{color: "red.400"}}/>
</Dialog.Content>
</Dialog.Positioner>
</Dialog.Root>
@@ -1,79 +0,0 @@
import Widget from "./Widget.jsx";
import { Fieldset, NativeSelect, For, Box, Field, Input, Button, Text, Flex } from "@chakra-ui/react";
import { useForm } from "react-hook-form";
import { useState } from "react";
import { Asterisk } from 'lucide-react';
export default function AlertLevel({id, isEditable, isDelete, DeleteWidget}) {
const { register, handleSubmit, reset, formState: {errors} } = useForm();
const [isSubmit, setIsSubmit] = useState(false);
const onSubmit = (data) => {
setIsSubmit(true);
reset();
}
// Prop Forwarding
const widgetProps = { id, isEditable, isDelete, DeleteWidget, isSubmit, setIsSubmit }
return (
<Widget title="Alert Level" {...widgetProps} >
{!isSubmit ? (
// DISPLAYING INPUT FIElDS AND LABELS
<Box
as="form"
onSubmit={handleSubmit(onSubmit)}
width="100%"
p={3}
display="flex"
flexDir="column"
>
<Fieldset.Root size="md" maxW="md">
<Fieldset.Content>
<Field.Root>
<Field.Label fontWeight="semibold">Volcano</Field.Label>
<Input name="volcanoname" />
</Field.Root>
<Field.Root>
<Field.Label fontWeight="semibold">Country</Field.Label>
<NativeSelect.Root>
<NativeSelect.Field name="country">
<For each={["United Kingdom", "Canada", "United States"]}>
{(item) => (
<option key={item} value={item}>
{item}
</option>
)}
</For>
</NativeSelect.Field>
<NativeSelect.Indicator />
</NativeSelect.Root>
</Field.Root>
</Fieldset.Content>
<Button type="submit" size="sm">Submit</Button>
</Fieldset.Root>
</Box>
):(
// DISPLAYING ALERT LEVEL DATA
<Box p={1} mt={1} ml="auto" mr="auto" width="200px">
<Box
bg="bg"
shadow="md"
borderRadius="md"
p={8}
width="200px"
height="10px"
textAlign="center"
display="flex"
alignItems="center"
justifyContent="center"
>
<Text fontsize="2xl" fontWeight="bold" color="gray.700">Alert Level Data Coming Soon.</Text>
</Box>
</Box>
)}
</Widget>
);
}
@@ -1,7 +0,0 @@
import { Button } from "@chakra-ui/react";
export function CancelButton ({onCancel}) {
return (
<Button onClick={onCancel} borderRadius="md">Cancel</Button>
)
}
@@ -1,39 +0,0 @@
// DEPRECATED
// import GlobalMap from "./views/GlobalMap";
// import RegionalMap from "./views/RegionalMap";
// import Volcano from "./views/Volcano";
// import Admin from "./views/Admin";
// import Account from "./views/Account"
// import Home from "./views/Home";
// import { DASHBOARD_VIEWS, ADMIN_VIEWS, VOLCANO_VIEWS } from "@/constants/viewKeys";
// import { chakra } from "@chakra-ui/react";
// import Personal from "./views/Personal";
// import Security from "./views/Security";
//
// export default function Canvas(props) {
// const { view } = props;
//
// return (
// <chakra.div width="100%" height="100%">
// {/* Check if view is a tab inside the Home Header */}
// { DASHBOARD_VIEWS.includes(view) && (
// <Home {...props}/>
// )}
// {/* Check if view is a tab inside the Volcano Header */}
// { VOLCANO_VIEWS.includes(view) && (
// <Volcano {...props}/>
// )}
// {/* Check if view is a tab inside the Admin Header */}
// { ADMIN_VIEWS.includes(view) && (
// <Admin {...props}/>
// )}
// {view === "global" && <GlobalMap />}
// {view === "regional" && <RegionalMap />}
// {view === "account" && <Account />}
// {view === "settings" && <Personal />}
// {view === "personal" && <Personal />}
// {view === "security" && <Security />}
// </chakra.div>
// );
// }
@@ -1,27 +0,0 @@
import { MoveDiagonal2 } from 'lucide-react';
import React from "react";
const CustomResizeHandle = React.forwardRef((props, ref) => {
const { handleAxis, isVisable, ...restProps } = props;
if (!isVisable) return null;
return (
<div
ref={ref}
{...restProps}
className={`resize-hande-${handleAxis}`}
style={{
position: 'absolute',
bottom: 0,
right: 0,
cursor: "pointer",
width: "20px",
height: "20px",
zIndex: "1",
}}
>
<MoveDiagonal2 />
</div>
);
});
export default CustomResizeHandle;
@@ -1,95 +0,0 @@
// DEPRECATED
// import Header from './Header/Header.jsx';
// import Sidebar from './Sidebar/Sidebar.jsx';
// import Canvas from './Canvas/Canvas.jsx';
// import { Grid, GridItem, useRecipe, useDialog } from '@chakra-ui/react';
// import { useState } from 'react';
// import DialogPopup from './Canvas/CanvasComponents/DialogPopup.jsx';
//
// export default function Dashboard() {
// const recipe = useRecipe({ key: "dashboard" });
// const styles = recipe();
//
// const [darkMode, setDarkMode] = useState(false);
// const [isEditable, setIsEditable] = useState(false); // editable=true means static=false (vice versa)
// const [isDelete, setIsDelete] = useState(false);
// const [originalLayout, setOriginalLayout] = useState([]);
// const [originalWidgets, setOriginalWidgets] = useState([]);
// const [view, setView] = useState("mydashboard");
// const [layout, setLayout] = useState([]);
// const [widgetArray, setWidgetArray] = useState([]);
// const dialog = useDialog();
//
// const beginEditIfNeeded = () => {
// if ( !isEditable ) {
// setOriginalLayout(layout);
// setOriginalWidgets(widgetArray);
// }
// };
//
// const onCancel = () => {
// console.log("canceling changes...");
// console.log(originalWidgets);
// if (originalLayout.length) {
// setLayout(originalLayout.map(item => ({
// ...item,
// static: true
// })));
// };
// setWidgetArray(originalWidgets);
// setIsEditable(false);
// setIsDelete(false);
// setOriginalLayout([]);
// setOriginalWidgets([]);
// }
//
// // Prevent navigation while in Edit mode without saving or cancelling
// const handleViewChange = (nextView) => {
// console.log("1. handling view change")
// if (view === "mydashboard" && isEditable) {
// console.log("2. currently editing");
// dialog.setOpen(true);
// return;
//
// // const confirmed = window.confirm(
// // "You have unsaved changes. Discard them and continue?"
// // );
// // if (!confirmed) return;
// // onCancel();
// }
// setView(nextView);
// };
//
// const onLayoutChange = (newLayout) => setLayout(newLayout);
//
// // Prop Forwarding
// const canvasProps = { view, layout, setLayout, setOriginalLayout, onLayoutChange, onChangeView: handleViewChange,
// onCancel, isEditable, setIsEditable, isDelete, setIsDelete, widgetArray, setWidgetArray, originalWidgets, setOriginalWidgets, beginEditIfNeeded
// };
// const sidebarProps = { view, onChangeView: handleViewChange, darkMode, setDarkMode };
// const headerProps = { onChangeView: handleViewChange };
//
// return (
// <>
// <DialogPopup dialog={dialog} />
// <Grid css={styles}
// templateRows="1fr 11fr"
// // templateColumns="1fr 20fr"
// templateColumns="auto 1fr"
// height="100vh"
// width="100vw"
// >
// <GridItem rowSpan={1} colSpan={2}>
// <Header {...headerProps}/>
// </GridItem>
// <GridItem colSpan={1}>
// <Sidebar {...sidebarProps}/>
// </GridItem>
// <GridItem colSpan={1} width="100%" overflow="auto">
// <Canvas {...canvasProps}/>
// </GridItem>
// </Grid>
// </>
// );
// }
@@ -1,9 +0,0 @@
import { Button } from "@chakra-ui/react";
export default function DeleteAllButton ({onDeleteAll}) {
return (
<Button onClick={onDeleteAll} color="#FFFFFF" bg="#EF4444" _hover={{ bg: "bg.error", color: "#EF4444"}}>
Delete All
</Button>
)
}
@@ -1,57 +0,0 @@
import { Box, Menu, Portal } from '@chakra-ui/react'
import AlertLevel from "@/components/deprecated/AlertLevelWidget.jsx";
export default function FirstWidgetButton({ appendWidget }) {
const triggerItem = (
<>
<Menu.Trigger asChild>
<Box
p={6}
m="auto"
mt={6}
align="center"
textAlign="center"
justify="center"
width="400px"
borderRadius="md"
boxShadow="md"
border="2px dashed"
borderColor="gray.300"
bg="gray.50"
cursor="pointer"
_hover = {{
bg: "gray.100",
borderColor: "gray.400",
boxShadow: "lg",
}}
>
Add Your First Widget
</Box>
</Menu.Trigger>
</>
);
const positioning = { placement: "bottom-middle" };
// const WidgetMenuProps = { appendWidget, triggerItem, positioning };
return (
// <WidgetMenu {...WidgetMenuProps} />
<Menu.Root positioning={positioning}>
{triggerItem}
<Portal>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="AlertLevel"
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
>
Alert Level
</Menu.Item>
<Menu.Item value="CO2">CO2</Menu.Item>
<Menu.Item value="Ivans">IVANS</Menu.Item>
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
}
@@ -1,7 +0,0 @@
import { Button } from "@chakra-ui/react";
export function SaveButton ({onSave}) {
return (
<Button onClick={onSave} borderRadius="md" bg="primary" _hover={{bg: "primaryLt"}}>Save</Button>
)
}
@@ -1,32 +0,0 @@
import { useSlotRecipe, chakra, Flex, CloseButton, Text, IconButton } from "@chakra-ui/react"
import { Undo } from 'lucide-react';
export default function Widget(props) {
// deconstructing props
const { id, title, isEditable, isDelete, DeleteWidget, isSubmit, setIsSubmit, children } = props;
const recipe = useSlotRecipe({ key: "widget" });
const styles = recipe();
return (
<Flex flexDirection="column" w="100%" h="auto">
{/* HEADER */}
<Flex css={styles.header} cursor={isEditable ? "move":"default"} flexDirection="row" w="100%" h="auto" justifyContent="space-between" alignItems="center">
<Text className="widget-handle" w="100%" display="flex" alignItems="center" height="32px" fontWeight="bold">{title}</Text>
{isDelete ? (
<CloseButton onClick={() => DeleteWidget(id)} size="xs" _hover={{bg: "tomato", color: "white"}}/>
):(
isSubmit ? (
<IconButton onClick={() => setIsSubmit(false)} size="xs" bg="base300" cursor="pointer"><Undo fill="text" /></IconButton>
):null
)}
</Flex>
{/* CHILDREN */}
<Flex w="100%" h="auto">
{children}
</Flex>
</Flex>
);
}
@@ -1,26 +0,0 @@
import { Menu, Portal } from "@chakra-ui/react";
import AlertLevel from "./AlertLevelWidget.jsx";
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
return (
<Menu.Root positioning={positioning}>
{triggerItem}
<Portal>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="AlertLevel"
onClick={() => appendWidget(AlertLevel, 4, 7, 4, 7, 8, 14)} // width and height are passed through here for each widget
>
Alert Level
</Menu.Item>
<Menu.Item value="CO2">CO2</Menu.Item>
<Menu.Item value="Ivans">IVANS</Menu.Item>
<Menu.Item value="PlumeHeights">Plume Heights</Menu.Item>
<Menu.Item value="AlertLevelChanges">Alert Level Changes</Menu.Item>
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
)
}
@@ -1,35 +0,0 @@
import { Tooltip as ChakraTooltip, Portal } from '@chakra-ui/react'
import * as React from 'react'
export const Tooltip = React.forwardRef(function Tooltip(props, ref) {
const {
showArrow,
children,
disabled,
portalled = true,
content,
contentProps,
portalRef,
...rest
} = props
if (disabled) return children
return (
<ChakraTooltip.Root {...rest}>
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
<Portal disabled={!portalled} container={portalRef}>
<ChakraTooltip.Positioner>
<ChakraTooltip.Content ref={ref} {...contentProps}>
{showArrow && (
<ChakraTooltip.Arrow>
<ChakraTooltip.ArrowTip />
</ChakraTooltip.Arrow>
)}
{content}
</ChakraTooltip.Content>
</ChakraTooltip.Positioner>
</Portal>
</ChakraTooltip.Root>
)
})
+12 -15
View File
@@ -1,37 +1,34 @@
import { useRecipe, Button, Stack, Text, Flex } from "@chakra-ui/react"
import {useSlotRecipe, Text, Flex} from "@chakra-ui/react"
import UserAvatarMenu from "./UserAvatarMenu.jsx";
import { NavLink } from "react-router-dom";
import useAuthStore from "@/store/authStore";
import {SearchBar} from "@/components/dashboard/SearchBar.jsx";
// Import the asset properly so Vite can bundle it for any server
// import defaultAvatar from "@/assets/user_profile.svg";
// Accept a 'user' prop now, fallback to placeholder if empty
export default function Header() {
const recipe = useRecipe({ key: "header" });
const recipe = useSlotRecipe({key: "header"});
const styles = recipe();
const {user} = useAuthStore();
console.log(user);
const displayName = user ? (user.fullName || user.username) : "Loading...";
const displayName = user ? (user.firstName || user.username) : "Loading...";
const avatarname = user ? (user.fullName || user.username) : "Loading...";
const displayEmail = user?.email || "LDAP User";
return (
<Flex css={styles} justify="space-between" align="center" width="100%">
{/* Home Link Logo */}
<Button as={NavLink} to="/home" color="color" variant="plain" textStyle="5xl" fontWeight="bold">
VDAP
</Button>
<Flex css={styles.root}>
<Flex css={styles.search_bar}>
<SearchBar placeholder="Enter a volcano to search"/>
</Flex>
{/* User Profile Section */}
<Flex align="center" gap={3} pr={6}>
<Stack gap="0" textAlign="right" cursor="default">
<Text color="color" fontWeight="bold" textStyle="lg">{displayName}</Text>
<Text color="fg.muted" textStyle="sm">{displayEmail}</Text>
</Stack>
<Flex css={styles.user_info}>
<Text>{displayName}</Text>
<UserAvatarMenu
name={displayName}
name={avatarname}
// avatar={user.avatar}
/>
</Flex>
+25 -42
View File
@@ -1,9 +1,8 @@
import { useSlotRecipe, Box, Button, IconButton } from "@chakra-ui/react";
import {Mountain, House, Earth, MapPin, ShieldUser, PanelLeftClose, PanelLeftOpen} from 'lucide-react';
import {useSlotRecipe, Box, Button, IconButton, Text, Flex} from "@chakra-ui/react";
import {ColorModeButton} from "@/components/ui/color-mode.jsx";
import { useState } from "react";
import {NavLink} from "react-router-dom";
import Tooltip from "../ui/Tooltip.jsx";
import { Mountain, House, Earth, MapPin, ShieldUser, Menu } from 'lucide-react';
import {useState} from "react";
const SIDEBAR_DEFAULT_BUTTONS = [
{icon: House, name: "Home", url: "/home"},
@@ -16,59 +15,43 @@ const SIDEBAR_DEFAULT_BUTTONS = [
export default function Sidebar() {
const [isOpen, setIsOpen] = useState(true);
const recipe = useSlotRecipe({key: "sidebar"});
const styles = recipe();
// TODO we will need to call buttons here by authorized user, for now we update the visible buttons as simply the default buttons
const visibleButtons = SIDEBAR_DEFAULT_BUTTONS;
const styles = recipe({state: isOpen ? "open" : "closed"});
const visibleButtons = SIDEBAR_DEFAULT_BUTTONS; // TODO: call btns here according to users authorization
const toggleSidebar = () => setIsOpen((prev) => !prev);
return (
<Box css={styles.root} width={isOpen ? "210px" : "71px"} transition="width 0.4s ease" position="relative" display="flex" flexDirection="column" gap={2}
>
{/* Menu Trigger */}
<IconButton aria-label="Toggle Sidebar" variant="plain" size="md" onClick={toggleSidebar} width="54px" alignSelf="flex-start"
>
<Menu />
</IconButton>
<Box css={styles.root}>
{/* Logo Navigates to Home Page */}
<Button as={NavLink} to="/home" variant="plain" css={styles.logo}>
<Flex>
<Text>🌋</Text>
<Text css={styles.logo_text}>VDAP</Text>
</Flex>
</Button>
{/* Navigation Buttons */}
{visibleButtons.map((button) => {
const Icon = button.icon;
return (
<Tooltip key={button.name} content={button.name} sidebarIsOpen={isOpen}>
<Button
as={NavLink}
to={button.url}
css={styles.buttons}
justifyContent="flex-start"
width="100%"
overflow="hidden" // Prevents text popout on collapse
>
<Icon style={{ flexShrink: 0 }} /> {/* Prevents icon from squishing */}
{/* Cleaned conditional transition styling */}
<Box
as="span"
ml={isOpen ? 3 : 0}
opacity={isOpen ? 1 : 0}
visibility={isOpen ? "visible" : "hidden"} // Prevents phantom width calculations and overflow into canvas
transition="opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease"
whiteSpace="nowrap"
>
<Button as={NavLink} to={button.url} css={styles.buttons}> {/* Navigation buttons are [icon text] */}
<Icon/>
<Box as="span" css={styles.button_text}>
{button.name}
</Box>
</Button>
</Tooltip>
);
})}
{/* Theme Toggler */}
<ColorModeButton
position="absolute"
bottom="4"
left="4"
/>
{/* Collapse Sidebar and Theme Footer Buttons */}
<Flex css={styles.footer}>
<IconButton aria-label="Toggle Sidebar" variant="plain" onClick={toggleSidebar} css={styles.collapse_button}>
{isOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
</IconButton>
<Flex css={styles.color_mode_button}>
<ColorModeButton />
</Flex>
</Flex>
</Box>
);
}
+7 -3
View File
@@ -63,7 +63,6 @@ export const WIDGET_REGISTRY = {
},
allowedSizes: {
small: { w: 3, h: 6 },
medium: { w: 3, h: 9 },
large: { w: 3, h: 12 },
}
},
@@ -85,7 +84,6 @@ export const WIDGET_REGISTRY = {
},
allowedSizes: {
small: { w: 3, h: 6 },
medium: { w: 3, h: 9 },
large: { w: 3, h: 12 },
}
},
@@ -99,6 +97,12 @@ export const WIDGET_REGISTRY = {
defaultSettings: {
startDate: getStartDate(),
endDate: getEndDate(),
filters: {
levelIncreases: true,
levelDecreases: true,
elevatedLevels: false, // will filter to show only volcanoes in their top 2 highest alert levels
volcanoes: 'all', // filter on what volcanoes to display, defaults to 'all' instead of listing every volc
},
},
allowedSizes: {
small: { w: 3, h: 6 },
@@ -180,7 +184,7 @@ export const WIDGET_REGISTRY = {
endDate: getEndDate(),
},
allowedSizes: {
medium: { w: 3, h: 12 },
large: { w: 3, h: 12 },
}
},
+2 -1
View File
@@ -1,5 +1,6 @@
:root {
font-family: Georgia, 'Times New Roman', Times, serif;
/*font-family: Georgia, 'Times New Roman', Times, serif;*/
font-family: "Roboto", sans-serif;
}
.wx-theme {
+4 -2
View File
@@ -65,7 +65,7 @@ const Login = () => {
return (
<Flex p={8} bg="gray.50" _dark={{ bg: "gray.900" }} flex="1" minH="100vh" align="center" justify="center">
<Box w="full" maxW="450px" bg="white" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px" borderColor="gray.200">
<Box w="full" maxW="450px" bg="white" borderColor="gray.200" _dark={{ bg: "gray.800", borderColor: "gray.700" }} p={8} borderRadius="2xl" boxShadow="sm" borderWidth="1px">
<Stack gap={6} align="stretch" as="form" onSubmit={handleSubmit}>
<Stack gap={2} textAlign="center">
@@ -88,6 +88,7 @@ const Login = () => {
onChange={(e) => setUsername(e.target.value)}
placeholder="jdoe"
{...fieldStyles}
color="gray.800"
/>
</Field.Root>
@@ -102,8 +103,9 @@ const Login = () => {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
// placeholder="••••••••" // not a necessary placeholder
{...fieldStyles}
color="gray.800"
/>
</Field.Root>
+38 -41
View File
@@ -1,61 +1,58 @@
import { HStack, Flex, Box } from "@chakra-ui/react";
import {HStack, Flex, Box, useRecipe, useSlotRecipe} from "@chakra-ui/react";
import {NavLink, Outlet, useLocation} from "react-router-dom";
import {SearchBar} from "../../components/dashboard/SearchBar.jsx";
import {SettingsButton} from "../../components/dashboard/SettingsButton.jsx";
// 1. Import your icons (Swap these for whichever you prefer!)
import {LayoutDashboard, CalendarDays, Cloud, Satellite, Activity} from "lucide-react";
// 2. Define the tabs array
const HOME_TABS = [
{name: "My Dashboard", url: "/home", icon: LayoutDashboard, exact: true},
{name: "Daily Activity", url: "/home/daily", icon: CalendarDays},
{name: "Gas", url: "/home/gas", icon: Cloud},
{name: "Remote Sensing", url: "/home/remote", icon: Satellite},
{name: "Seismic", url: "/home/seismic", icon: Activity},
];
export default function Home() {
const glassTabsRecipe = useRecipe({key: "glassTab"});
const homeRecipe = useSlotRecipe({key: "home"});
const glassTabsStyle = glassTabsRecipe();
const homeStyle = homeRecipe();
const location= useLocation();
const isPersonalDashboard= location.pathname === "/home" || location.pathname === "/home/";
// 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 (
<Flex direction="column" height="100%" width="100%">
<Flex css={homeStyle.root}>
<Flex as="header" css={homeStyle.header}>
{/* TABS CONTAINER */}
<Flex>
{HOME_TABS.map((tab) => {
const Icon = tab.icon;
return (
<Box key={tab.name} as={NavLink} to={tab.url} end={tab.exact} css={glassTabsStyle}> {/* CRITICAL: 'end' prop forces exact match so "/home" deactivates on sub-routes*/}
{/* flexShrink: 0 ensures the icon never squishes during the gooey animation */}
<Icon size={24} style={{flexShrink: 0}}/>
<Box as="span">{tab.name}</Box>
</Box>
);
})}
{/* 2. 🛠️ SUB HEADER: Matches your original padding and background */}
<Flex as="header" position="sticky" top="0" zIndex="dropdown" bg="base200" p={5} align="center" justify="space-between" width="100%">
{/* TABS CONTAINER - Replicates the Tabs.List line container */}
<Flex borderBottom="1px solid" borderColor="fg.muted" gap={2}>
<Box as={NavLink} to="/home" end css={tabStyles}>My Dashboard</Box>
<Box as={NavLink} to="/home/daily" css={tabStyles}>Daily Activity</Box>
<Box as={NavLink} to="/home/gas" css={tabStyles}>Gas</Box>
<Box as={NavLink} to="/home/remote" css={tabStyles}>Remote Sensing</Box>
<Box as={NavLink} to="/home/seismic" css={tabStyles}>Seismic</Box>
{/* The Settings button is now inline with the tabs and only renders on the personal dashboard */}
{isPersonalDashboard && <SettingsButton/>}
</Flex>
{/* EDIT MODE BUTTONS & CONTROLS */}
{/* Leaving this HStack here in case you plan to put the SearchBar or other global tools back on the right side */}
<HStack width="258px" justifyContent="flex-end">
{isPersonalDashboard ? (
<>
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
{/* This will eventually trigger your Zustand store's edit mode! */}
<SettingsButton />
</>
) : (
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
)}
{/* Future global controls go here */}
</HStack>
</Flex>
{/* 3. 🛠️ CANVAS AREA */}
<Flex flex="1" overflow="auto">
{/* TABS CONTENT */}
<Flex css={homeStyle.body}>
<Outlet/>
</Flex>
+39 -68
View File
@@ -1,9 +1,9 @@
import {Box, Text, Flex, IconButton, Spinner, Popover} from "@chakra-ui/react";
import {Box, Text, Flex, IconButton, Spinner, Popover, useSlotRecipe} from "@chakra-ui/react";
import {X, MoreVertical} from "lucide-react";
import {Responsive, WidthProvider} from "react-grid-layout";
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import {Suspense, useEffect, useRef} from 'react';
import {useColorModeValue} from "@/components/ui/color-mode";
// 1. Import database messenger
import useAuthStore from "@/store/authStore";
@@ -13,13 +13,8 @@ import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx';
// React-Grid-Layout requires this wrapper to automatically calculate screen width
const ResponsiveGridLayout = WidthProvider(Responsive);
export default function MyDashboard() {
// 🌗 Let's adjust for our light and dark mode
const frameBg = useColorModeValue("gray.100", "gray.800");
const titleColor = useColorModeValue("gray.800", "gray.100");
const iconColor = useColorModeValue("gray.600", "gray.400");
const iconHoverBg = useColorModeValue("gray.100", "gray.700");
export default function MyDashboard() {
// ☁️ Database Connection
const {user} = useAuthStore();
@@ -33,24 +28,26 @@ export default function MyDashboard() {
// 🔒 Safety lock to prevent infinite re-renders
const hasHydrated = useRef(false);
// ==========================================
// INITIAL LOAD: Grab data from Django
// ==========================================
// 1. Pull the global slot recipes
const widgetRecipe = useSlotRecipe({ key: "widget" });
const popoverRecipe = useSlotRecipe({ key: "glassPopover" });
// 2. Pass the editing state directly into the widget recipe!
const widgetStyles = widgetRecipe({ mode: isEditing ? "editing" : undefined });
const popoverStyles = popoverRecipe();
// 3. Populate widgets
useEffect(() => {
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
const savedLayout = user.preferences.dashboard_layout;
// Safety check: Only inject if it's actually an array of widgets.
// (Prevents a crash if MariaDB returns a default empty object {})
if (Array.isArray(savedLayout)) {
setWidgets(savedLayout);
}
if (Array.isArray(savedLayout)) setWidgets(savedLayout);
hasHydrated.current = true;
}
}, [user, setWidgets]);
// 🛠️ Format the data for RGL
const rglLayout = widgets.map(w => ({
i: w.id,
@@ -58,10 +55,10 @@ export default function MyDashboard() {
y: w.y,
w: w.w,
h: w.h,
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
isResizable: false // The magic lock! No resizing allowed globally.
}));
// 3. THE COMPONENT MAPPER
// 4. The component mapper
const renderWidget = (widget) => {
const registryEntry = WIDGET_REGISTRY[widget.type];
@@ -72,38 +69,24 @@ export default function MyDashboard() {
const SettingsUI = registryEntry.settingsComponent;
return (
// The "Frame" that holds both the Title and the Chart
<Flex direction="column" height="100%" width="100%" bg={frameBg}>
{/* THE TITLE BAR */}
<Flex
justify="space-between"
align="center"
px={3}
py={2}
>
<Text color={titleColor} fontWeight="semibold" fontSize="md" isTruncated>
<>
<Box css={widgetStyles.header}>
<Text>
{registryEntry.name}
</Text>
{/* THE SETTINGS ELLIPSIS */}
{SettingsUI ? (
{/* WIDGET SETTINGS BUTTON (ELLIPSES) */}
{SettingsUI && (
<Popover.Root positioning={{placement: "bottom-end"}}>
<Popover.Trigger asChild>
<IconButton
size="xs"
variant="ghost"
color="gray.400"
_hover={{color: "white", bg: "gray.700"}}
aria-label="Widget Settings"
>
<IconButton size="xs" variant="ghost" aria-label="Widget Settings">
<MoreVertical size={16}/>
</IconButton>
</Popover.Trigger>
{/* NEW: The Positioner lifts the content out of the Flexbox flow */}
<Popover.Positioner zIndex={10}>
<Popover.Content bg="gray.50" p={4} boxShadow="xl" borderRadius="md">
<Popover.Positioner zIndex={1}>
<Popover.Content css={popoverStyles.content}>
<Suspense fallback={<Spinner size="sm"/>}>
<SettingsUI
widgetId={widget.id}
@@ -113,25 +96,19 @@ export default function MyDashboard() {
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
) : (
<Box w="20px" h="20px"/>
)}
</Flex>
{/* THE CHART CANVAS */}
<Box flex="1" overflow="hidden" position="relative" p={1}>
{/* Pass the settings object into the actual chart component */}
{/* Pass widget ID to allow updating settings in widget component rather than just in settings component */}
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
</Box>
</Flex>
<Box css={widgetStyles.body}>
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
</Box>
</>
);
}
// Fallback if someone asks for a widget that doesn't exist
return (
<Flex width="100%" height="100%" align="center" justify="center" direction="column" bg="gray.800">
<Flex css={widgetStyles.body} align="center" justify="center" direction="column">
<Text fontWeight="bold" color="red.400">Error</Text>
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
</Flex>
@@ -148,36 +125,31 @@ export default function MyDashboard() {
rowHeight={30}
isDraggable={isEditing} // Only allows dragging when Settings tools toggle this
isResizable={false} // Double lock-down
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
compactType="vertical" // Auto-packs widgets to the top
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
>
{/* 3. 🎨 Paint the Widgets */}
{/* 🎨 Paint the Widgets */}
{widgets.map(widget => (
<Box
key={widget.id}
bg="white"
borderRadius="md"
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
border={isEditing ? "2px dashed gray" : "1px solid"}
borderColor={isEditing ? "gray.400" : "gray.200"}
overflow="hidden"
cursor={isEditing ? "grab" : "default"}
position="relative" // 3. REQUIRED so the absolute button stays inside this box
css={widgetStyles.root}
position="relative" // REQUIRED so the absolute button stays inside this box
>
{/* 4. THE DELETE BUTTON */}
{/* THE DELETE BUTTON */}
{isEditing && (
<IconButton
aria-label="Remove widget"
position="absolute"
top={2}
right={2}
size="xs"
bg="white"
color={iconColor}
color="black"
border="1px solid"
borderRadius="xl"
borderColor="gray.200"
_hover={{bg: "red.50", color: "red.500", borderColor: "red.200"}}
zIndex={2}
aria-label="Remove widget"
size="xs"
onClick={() => removeWidget(widget.id)}
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
@@ -186,15 +158,14 @@ export default function MyDashboard() {
</IconButton>
)}
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
{/* SUSPENSE BOUNDARY wrapping the render function */}
<Suspense fallback={
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
<Flex css={widgetStyles.body} align="center" justify="center">
<Spinner size="md" color="blue.400" thickness="3px"/>
</Flex>
}>
{renderWidget(widget)}
</Suspense>
</Box>
))}
</ResponsiveGridLayout>
+528 -105
View File
@@ -1,119 +1,549 @@
import {defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe} from "@chakra-ui/react";
// RECIPES
const headerRecipe = defineRecipe({
// REUSABLE GLASS SURFACE
const glassSurface = {
bg: {base: "rgb(253 253 253 / 0.4)", _dark: "rgb(37 38 46 / 0.8)"},
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid",
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
boxShadow: "sm",
};
/* ==========================================================================
RECIPES
========================================================================== */
// 1. Header
const headerRecipe = defineSlotRecipe({
slots: ["root", "search_bar", "user_info"],
base: {
bg: "{base200}",
root: {
bg: "transparent",
color: "text",
width: "100%",
height: "100%",
borderBottom: "1px solid",
borderBottomColor:"{border}",
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}
});
pl: 3,
},
search_bar: {
width: "100%",
// input element in search bar component
"& input": {
width: "410px",
},
},
user_info: {
flexDirection: "row",
justifyContent: "flex-end",
alignItems: "center",
gap: 3,
pr: 6,
const dashboardRecipe = defineRecipe({
base: {
bg: "{base200}"
}
});
const canvasRecipe = defineRecipe({
base: {
bg: "{base200}",
zIndex: "-1",
color: "{text}"
}
// The Text component
"& p": {
fontWeight: "600",
fontSize: "md",
},
},
},
});
// 2. Sidebar
const sidebarRecipe = defineSlotRecipe({
slots: ["root", "close_button", "buttons", "dark_mode_button"],
slots: [
"root",
"logo",
"logo_text",
"footer",
"button_text",
"buttons",
"collapse_button",
"color_mode_button",
],
base: {
root: {
bg: "{base200}",
borderRight: "1px solid",
borderColor: "{border}",
// 1. Semi-transparent backgrounds
bg: {
base: "linear-gradient(to bottom right, #97282880 20%, #EB7F3160 60%, #FCAD3840 100%)",
_dark: "linear-gradient(to bottom right, #9900003C 20%, #FF66003C 60%, #FFC0003C 100%)"
},
// 2. The glass blur effect
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
// 3. Subtle semi-transparent borders
border: "1px solid",
borderColor: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"},
// CRITICAL: Remove the right border so it doesn't draw a line through your cutout
borderRight: "none",
borderRadius: "2xl",
width: "100%",
height: "100%",
height: "calc(100% - 16px)",
margin: "8px",
display: "flex",
flexDirection: "column",
position: "relative",
justifyContent: "start",
alignItems: "start",
gap: 2,
p: 2,
alignItems: "stretch",
gap: 4,
transition: "width 0.5s ease",
// CRITICAL: Adjust padding and overflow to allow the button to touch the right edge
pl: 2, // Keep left padding
py: 2, // Keep top/bottom padding
pr: 0, // Remove right padding
pt: 4, // Added top padding to give the logo room to breathe
overflow: "visible", // Allows the curved corner patches to render outside the button
zIndex: 10,
},
buttons: {
position: "relative", // Required to anchor the corner patches
bg: "transparent",
_hover: {bg: "{primaryLt}", color: "{textInverted}"},
"&.active": {bg: "{primary}", color: "{textInverted}"},
fontSize: "xl",
justifyContent: "flex-start",
transition: "background-color 0.3s ease, color 0.3s ease",
"& svg": {
transition: "stroke-width 0.4s ease",
flexShrink: "0",
},
_hover: {
bg: "rgba(255, 255, 255, 0.2)",
color: "{text}",
"& svg": {
strokeWidth: "3",
},
"& span": {
fontWeight: "700",
},
},
fontSize: "l",
color: {base: "{text}"},
variant: "ghost",
width: "100%",
// Flat on the right, rounded on the left
borderRightRadius: "0",
borderLeftRadius: "4xl",
"&.active": {
bg: "{MainBackground}",
color: "{textInverted}",
_hover: {
bg: "{MainBackground}",
color: "{textInverted}",
"& svg": { strokeWidth: "2" },
"& span": { fontWeight: "500" },
},
// The Top Inverted Curve
_before: {
content: '""',
position: "absolute",
right: "-1px",
top: "-25px",
width: "25px",
height: "25px",
bg: "inherit",
WebkitMaskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
maskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
pointerEvents: "none",
},
// The Bottom Inverted Curve
_after: {
content: '""',
position: "absolute",
right: "-1px",
bottom: "-25px",
width: "25px",
height: "25px",
bg: "inherit",
WebkitMaskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
maskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
pointerEvents: "none",
},
},
},
logo: {
width: "100%",
justifyContent: "flex-start",
ml: 2,
textStyle: "4xl",
fontWeight: "bold",
overflow: "hidden",
px: 0,
},
logo_text: {
transition: "opacity 0.2s ease, visibility 0.2s ease, margin 0.2s ease",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
},
footer: {
flexDirection: "row",
justifyContent: "space-between",
width: "100%",
mt: "auto",
pl: 2,
pr: 2,
},
button_text: {
transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
fontWeight: "500",
},
collapse_button: {
"& svg": {flexShrink: "0"},
},
color_mode_button: {
"& button": {
_hover: {
bg: "transparent",
},
},
},
},
variants: {
state: {
open: {
root: {width: "190px"},
button_text: {ml: 3, opacity: 1, visibility: "visible"},
logo_text: {ml: 3, opacity: 1, visibility: "visible", transition: "opacity 0.5s ease, visibility 0.5s ease, margin 0.5s ease"},
color_mode_button: {
"& button": {
position: "static",
},
// the css below is equivalent to chakra's 'truncate' prop
// it makes sure the icon doesn't jump out of the bounds of the sidebar while it's transitioning from closed to open
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
},
},
closed: {
root: {width: "71px"},
button_text: {opacity: 0, visibility: "hidden"},
logo_text: {opacity: 0, visibility: "hidden", transition: "opacity 0.2s ease, visibility 0.2s ease, margin 0.2s ease"},
color_mode_button: {"& button": {position: "relative", bottom: "35px", right: "37px",}
},
}
}
}
});
const widgetRecipe = defineSlotRecipe({
slots: ["root", "header", "canvas"],
// 3. Dashboard Shell
const dashboardRecipe = defineRecipe({
base: {
root: {
flexDirection: "column",
gap: "1",
position: "relative",
bg: "base100",
borderRadius: "md",
p: "1",
paddingBottom: "2",
border: "1px solid",
borderColor: "{base300}",
textAlign: "center",
height: "100%",
position: "fixed"
},
header: {
flexShrink: "0",
bg: "{base300}",
color: "{text}",
cursor: "move",
borderRadius: "sm",
whiteSpace: "nowrap",
textAlign: "left",
pl: "2"
},
canvas: {
flex: "1 1 auto",
bg: "transparent",
color: "{text}",
cursor: "default",
borderRadius: "md",
height: "100vh",
width: "100vw",
bg: "{MainBackground}",
// selects the third div in the grid layout (the canvas)
"& > div:nth-of-type(3)": {
width: "100%",
overflow: "auto",
}
}
});
// 4. Home Page Tabs
const homeRecipe = defineSlotRecipe({
slots: ["root", "header", "body"],
base: {
root: {
flexDirection: "column",
height: "100%",
width: "100%",
},
header: {
// Flex contianer as a header that contains all the tabs
width: "100%",
align: "center",
justify: "space-between",
top: "0",
p: 5,
position: "sticky",
zIndex: "dropdown",
bg: "transparent",
// THE TABS
// Flex container renders as div for all the buttons within the header
"& > div": {
alignItems: "center",
gap: 2,
},
},
body: {
flex: 1,
overflow: "auto",
},
},
});
// 5. Dashboard Widget Card
const widgetRecipe = defineSlotRecipe({
slots: ["root", "header", "body"],
base: {
root: {
display: "flex",
flexDirection: "column",
position: "relative",
...glassSurface,
width: "100%",
height: "100%",
borderRadius: "3xl",
overflow: "hidden",
},
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
px: 3,
pt: 2,
pb: 0,
"& > p": {
color: "titleColor",
fontWeight: "500",
fontSize: "16px",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "clip",
},
"& > button": {
borderRadius: "xl",
_hover: {color: "white", bg: "gray.500"},
},
},
body: {flex: "1", overflow: "hidden", position: "relative", px: 1, pb: 1, pt: 0},
},
variants: {
mode: {
editing: {
root: {
boxShadow: "outline",
borderStyle: "dashed",
borderWidth: "2px",
borderColor: "orange.300",
cursor: "grab",
},
},
},
},
});
// 6. TRUE POPOVER: Compact floating card for 3-dots menus
const glassPopoverRecipe = defineSlotRecipe({
slots: ["content", "arrow", "positioner"],
base: {
content: {
...glassSurface,
borderRadius: "xl",
p: 3,
boxShadow: "lg",
bg: {base: "rgba(255, 255, 255, 0.95)", _dark: "#353742"},
backdropFilter: "blur(15px)",
WebkitBackdropFilter: "blur(15px)",
},
},
});
// 7. MODAL DIALOG: Large view for the Widget Shop
const glassDialogRecipe = defineSlotRecipe({
slots: ["content", "header", "body", "backdrop"],
base: {
backdrop: {
bg: "rgba(0, 0, 0, 0.4)",
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
},
content: {
display: "flex",
flexDirection: "column",
position: "relative",
...glassSurface,
borderRadius: "2xl",
p: 2,
boxShadow: "xl",
minW: "240px",
maxW: "60%",
minH: "80%",
overflow: "hidden",
bg: {base: "rgba(255, 255, 255, 0.75)", _dark: "rgb(22 23 28 / 0.9)"},
backdropFilter: "blur(15px)",
WebkitBackdropFilter: "blur(15px)",
},
header: {
bg: "transparent",
color: {base: "gray.800", _dark: "white"},
py: 4,
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
"& h2": {fontSize: "xl", fontWeight: "bold"},
},
body: {
p: 0,
"& div.chakra-tabs__root > div:first-child": {
position: "relative",
display: "flex",
maxW: "100%",
mx: 5,
mt: 4,
mb: 6
},
"& div.chakra-tabs__list": {
bg: {base: "rgba(0, 0, 0, 0.04)", _dark: "rgba(255, 255, 255, 0.05)"},
p: 1, borderRadius: "full", display: "flex", flexWrap: "nowrap", overflowX: "auto", maxW: "100%",
border: "1px solid", borderColor: {base: "blackAlpha.50", _dark: "whiteAlpha.100"},
scrollbarWidth: "none", "&::-webkit-scrollbar": {display: "none"},
"& button.chakra-tabs__trigger": {
px: 5, py: 2, borderRadius: "full", whiteSpace: "nowrap", flexShrink: 0,
color: {base: "gray.500", _dark: "gray.400"},
fontWeight: "medium", transition: "background-color 0.2s, color 0.2s, box-shadow 0.2s",
_hover: {color: {base: "gray.800", _dark: "gray.200"}},
_selected: {
bg: {base: "white", _dark: "rgba(255, 255, 255, 0.12)"},
color: {base: "blue.600", _dark: "blue.200"},
boxShadow: "sm",
fontWeight: "bold"
}
}
},
"& div.chakra-tabs__root > div:first-child div:has(svg)": {
position: "absolute", top: "0", bottom: "0", w: "50px", alignItems: "center", justifyContent: "center",
zIndex: 2, cursor: "pointer", bg: {base: "rgba(255, 255, 255, 0.7)", _dark: "rgba(30, 30, 30, 0.8)"},
color: {base: "gray.600", _dark: "gray.300"}, backdropFilter: "blur(1px)", transition: "background-color 0.2s",
_hover: {
color: {base: "blue.500", _dark: "blue.300"},
bg: {base: "rgba(255, 255, 255, 0.9)", _dark: "rgba(40, 40, 40, 0.95)"}
},
"&:has(.lucide-chevron-left)": {
left: 0,
borderTopLeftRadius: "full",
borderBottomLeftRadius: "full",
WebkitMaskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 19px)",
maskImage: "radial-gradient(circle at 100% 50%, transparent 30px, black 35px)"
},
"&:has(.lucide-chevron-right)": {
right: 0,
borderTopRightRadius: "full",
borderBottomRightRadius: "full",
WebkitMaskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 19px)",
maskImage: "radial-gradient(circle at 0% 50%, transparent 30px, black 35px)"
},
},
},
},
});
// 6. Pill/Glass Expanding Tab Button
const glassTabRecipe = defineRecipe({
base: {
display: "flex",
alignItems: "center",
height: "40px",
// The Circle State (Inactive)
maxWidth: "40px",
minWidth: "40px",
px: "8px",
gap: 2,
// CRITICAL: Locks the shape to a perfect circle/pill
borderRadius: "full",
overflow: "hidden",
whiteSpace: "nowrap",
textDecoration: "none",
fontWeight: "500",
color: "fg.muted",
// --- THE GLASS EFFECT (Inactive) ---
// Uses a highly transparent white/black depending on mode
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "#1e2027"},
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: "#191a22"},
borderColor: {base: "rgba(0, 0, 0, 0.1)", _dark: "rgba(255, 255, 255, 0.15)"},
color: "fg",
textDecoration: "none",
},
"&.active": {
maxWidth: "250px",
pr: "16px",
bg: "{gooeyButtonBackground}",
borderColor: "transparent",
color: "{textInverted}",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)", // Stronger shadow for the active tab
_hover: {
bg: "{gooeyButtonBackground}",
cursor: "default",
textDecoration: "none",
},
},
},
});
/* ==========================================================================
RAW WIDGET COLORS (For Canvas/Plotly libraries that reject CSS variables)
========================================================================== */
export const rawWidgetColors = {
light: {
text: "#000000",
muted: "#4A5568", // gray.600
grid: "rgba(0, 0, 0, 0.1)",
},
dark: {
text: "#FFFFFF",
muted: "#A0AEC0", // gray.400
grid: "rgba(255, 255, 255, 0.15)",
}
};
/* ==========================================================================
CONFIG & SYSTEM
========================================================================== */
const config = defineConfig({
globalCss: {
// the shadow box behind widgets when editing layout
".react-grid-placeholder": {
background: "#c0d2e3ff !important",
borderColor: "#a9b6c2ff !important",
boxShadow: "0 0 10px #c0d2e3ff !important",
opacity: "1 !important",
borderRadius: "5px",
borderRadius: "3xl",
_dark: {
background: "#393c3fff !important",
background: "#3B3D49FF !important",
borderColor: "#000000ff !important",
boxShadow: "0 0 10px #16181bff !important",
opacity: "1 !important",
},
},
".react-resizable-handle-se": {
zIndex: "1",
},
"*": {
scrollbarWidth: "thin",
scrollbarColor: "#a0aec0 transparent",
@@ -134,53 +564,43 @@ const config = defineConfig({
"::-webkit-scrollbar-track": {
background: "transparent",
},
// '.leaflet-tile': {
// _dark: {
// filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
// }
// },
'.leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container': {
// this is what changes the maps colors to 'dark mode'
".leaflet-control-zoom-in, .leaflet-control-zoom-out, .leaflet-control-attribution, .leaflet-container": {
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
},
'.leaflet-control-attribution': {
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
},
'.leaflet-container': {
".leaflet-container": {
fontFamily: "system-ui, sans-serif !important",
_dark: {
filter: "invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%)",
}
},
// ".leaflet-popup-content": {
// fontFamily: "georgia, system-ui, sans-serif !important"
// removes the animation of moving widgets, seems very harsh and jumpy w/o animation
// ".react-grid-item": {
// transition: "none !important"
// },
// ".leaflet-control a": {
// fontFamily: "georgia, system-ui, sans-serif !important"
// },
// ".leaflet-tooltip": {
// fontFamily: "georgia, system-ui, sans-serif !important"
// },
".react-grid-item": {
transition: "none !important"
},
},
theme: {
semanticTokens: {
colors: {
base100: { value: {base: "#f6f9faff", _dark: "#1617199e"}},
base200: { value: {base: "#e6ecf1ff", _dark: "#242424"}},
base300: { value: {base: "#d3dae4ff", _dark: "#0a0b0bff"}},
primary: { value: {base: "#2B98F8", _dark: "#2B98F8"}},
primaryLt: { value: {base: "#98cdffff", _dark: "#98cdffff"}},
secondaryBG: { value: {base: "#EDF1F1", _dark: "#121212"}},
border: {value: {base: "#000000", _dark: "#cbcbcb84"}},
text: {value: {base: "#000000", _dark: "lightgrey"}},
textInverted: {value: "white"},
icon: {value: {base: "#000000", _dark: "lightgrey"}},
text: { value: { base: "#131315", _dark: "rgb(234 234 234 / 0.94)" } },
textInverted: { value: { base: "#000000", _dark: "#FFFFFF" } }, // Fixed: Properly inverted
MainBackground: { value: { base: "#f1f1f1", _dark: "#16171c" } },
titleColor: { value: { base: "colors.gray.800", _dark: "#e1e1e1" } },
iconColor: { value: { base: "#191919", _dark: "rgb(255 255 255 / 0.82)" } }, // Fixed: Visible in dark mode
glassSeperator: { value: { base: "#0000001A", _dark: "#FFFFFF3F" } },
gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "#333540"}},
// WIDGET SPECIFIC VALUES
widgetItemBg: { value: { base: "rgb(115 132 166 / 0.17)", _dark: "rgb(51 53 64)" } },
widgetItemExpandedBg: { value: { base: "rgb(184 197 220 / 0.17)", _dark: "rgb(59 61 73)" } },
widgetBadgeBg: { value: { base: "rgb(154 163 179 / 0.45)", _dark: "rgb(75 77 90)" } },
widgetInnerBadgeBg: { value: { base: "rgb(154 163 179 / 0.45)", _dark: "rgb(100 103 119)" } },
widgetTextMuted: { value: { base: "#262627", _dark: "rgb(236 236 236 / 0.9)" } },
// ALERT COLORS
alertRed: { value: { base: "colors.red.600", _dark: "colors.red.400" } },
alertOrange: { value: { base: "colors.orange.600", _dark: "colors.orange.400" } },
alertYellow: { value: { base: "colors.yellow.600", _dark: "colors.yellow.400" } },
alertGreen: { value: { base: "colors.green.600", _dark: "colors.green.400" } },
},
},
tokens: {
@@ -189,13 +609,16 @@ const config = defineConfig({
},
},
recipes: {
header: headerRecipe,
dashboard: dashboardRecipe,
canvas: canvasRecipe,
glassTab: glassTabRecipe,
},
slotRecipes: {
header: headerRecipe,
sidebar: sidebarRecipe,
home: homeRecipe,
widget: widgetRecipe,
glassPopover: glassPopoverRecipe,
glassDialog: glassDialogRecipe,
},
},
});