Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60f71485da | ||
|
|
8e9b17f6dd | ||
|
|
c45bc778ec | ||
|
|
816046d05a | ||
|
|
12e540b61b | ||
|
|
1e1130f965 | ||
|
|
8ddf9ff393 | ||
|
|
9abcb78327 | ||
|
|
f5f09e31fe | ||
|
|
510c63e7b0 | ||
|
|
3017535d02 | ||
|
|
9b1ddf6dcc | ||
|
|
2b0300b075 | ||
|
|
a6ebe5af1e | ||
|
|
6d0af310ae | ||
|
|
2283d156ea | ||
|
|
3697575f54 | ||
|
|
9fbf22bc32 | ||
|
|
7568d0dba4 | ||
|
|
022d4169b5 |
@@ -24,6 +24,6 @@ export default defineConfig([globalIgnores(['dist']), {
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
}, ...storybook.configs["flat/recommended"]])
|
||||
|
||||
+4
-4
@@ -33,12 +33,12 @@ function AppFoundation() {
|
||||
height="100vh"
|
||||
width="100vw"
|
||||
>
|
||||
<GridItem rowSpan={1} colSpan={2}>
|
||||
<Header />
|
||||
</GridItem>
|
||||
<GridItem colSpan={1}>
|
||||
<GridItem rowSpan={2} colSpan={1}>
|
||||
<Sidebar />
|
||||
</GridItem>
|
||||
<GridItem rowSpan={1} colSpan={1}>
|
||||
<Header />
|
||||
</GridItem>
|
||||
<GridItem colSpan={1} width="100%" overflow="auto">
|
||||
<Outlet />
|
||||
</GridItem>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import VdapApi from "../../hooks/useVdapApi.js";
|
||||
import useBuildUrl from "../../hooks/useBuildUrl.js";
|
||||
import { Footprints, Plane, Sailboat, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import {Box, Collapsible, Flex, Spinner, Text, Timeline, Center} from "@chakra-ui/react";
|
||||
import { useMemo, useCallback } from "react";
|
||||
|
||||
|
||||
export default function AlertChanges({ settings, widgetID}) {
|
||||
// Find this specific widgets object in the state list of active & displayed widgets
|
||||
const thisWidget = useDashboardStore(useCallback((state) =>
|
||||
state.widgets.find((widget) => widget.id === widgetID), [widgetID]
|
||||
));
|
||||
|
||||
// build url, default is 7 days past
|
||||
const url = useBuildUrl('/api/v2/alert_changes', settings?.startDate, settings?.endDate);
|
||||
|
||||
// call api hook to retrieve data
|
||||
const {data: apiData, isLoading, error} = VdapApi(url);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (apiData.length === 0) return [];
|
||||
|
||||
// .reduce() is a built-in array method used to transform an entire array into a single value (like an object)
|
||||
// here it returns a single massive object w/ volcano names as keys
|
||||
const grouped = apiData.reduce((accumulator, item) => {
|
||||
if (!accumulator[item.volcano]) accumulator[item.volcano] = [];
|
||||
accumulator[item.volcano].push(item);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
// converts the 'grouped' object back into an array of [key, value] pairs
|
||||
const group_arr = Object.entries(grouped);
|
||||
|
||||
// sort data newest to oldest within each volcano
|
||||
const sorted = group_arr.map((item) =>
|
||||
[
|
||||
item[0], // volcano name
|
||||
[...(item[1])].sort((a, b) =>
|
||||
new Date(b.alert_start_date_local) - new Date(a.alert_start_date_local))
|
||||
]
|
||||
);
|
||||
|
||||
// volcano with most recent alert level change appears at top of list
|
||||
const sorted_entries = [...sorted].sort((a, b) => new Date(b[1][0].alert_start_date_local) - new Date(a[1][0].alert_start_date_local));
|
||||
|
||||
if (thisWidget?.size === "small") {
|
||||
return (sorted_entries.map((volcano) => {
|
||||
const domain = volcano[1].reduce((acc, curr) => {
|
||||
if (!acc[curr.alert_scheme_domain]) acc[curr.alert_scheme_domain] = [];
|
||||
acc[curr.alert_scheme_domain].push(curr);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const domain_arr = Object.entries(domain);
|
||||
return [volcano[0], domain_arr];
|
||||
}));
|
||||
}
|
||||
|
||||
return sorted_entries;
|
||||
}, [apiData, thisWidget]);
|
||||
|
||||
// list of alert colors used for coloring text below
|
||||
const alertColors = {
|
||||
Red: "red.400",
|
||||
Orange: "orange.400",
|
||||
Yellow: "yellow.400",
|
||||
Green: "green.400",
|
||||
};
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data.length === 0) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" gap={2} p={2} w="100%" h="100%" borderRadius="lg" bg="gray.800" overflowY="auto">
|
||||
{data.map((volcano, index) => {
|
||||
if (thisWidget?.size === "large") return (
|
||||
<Collapsible.Root key={index} w="100%">
|
||||
{/* Using context to dynamically change the border radii of the triggers */}
|
||||
<Collapsible.Context>
|
||||
{(collapsible) => (
|
||||
<>
|
||||
<Collapsible.Trigger w="100%">
|
||||
<Flex direction="row"
|
||||
w="100%"
|
||||
justify="space-between" align="center"
|
||||
p={1} pl={3} pr={3}
|
||||
borderTopRadius="lg"
|
||||
borderBottomRadius={collapsible.open ? "0" : "lg"}
|
||||
bg="gray.600"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Text truncate >{volcano[0]}</Text> {/* volcano name */}
|
||||
<Text truncate fontSize="xs" color="gray.300/90">{volcano[1][0]?.alert_start_date_local}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontWeight="semibold">{volcano[1][0]?.alert_level_name}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content w="100%" p={3} borderBottomRadius="lg" bg="gray.700">
|
||||
<Timeline.Root size="md" variant="subtle">
|
||||
{volcano[1]?.map((object, index) => (
|
||||
<Timeline.Item key={index} w="100%" h="100%">
|
||||
<Timeline.Connector >
|
||||
{/*<Timeline.Separator borderWidth="1px" borderColor="gray.600"/>*/}
|
||||
<Timeline.Indicator outline="none" bg="gray.200" color="gray.500" size="lg">
|
||||
{object?.alert_scheme_domain === "terrestrial" && <Footprints size={15} />}
|
||||
{object?.alert_scheme_domain === "aviation" && <Plane size={15} />}
|
||||
{object?.alert_scheme_domain === "maritime" && <Sailboat size={15} />}
|
||||
</Timeline.Indicator>
|
||||
<Timeline.Indicator h="50%" outline="none" bg="gray.700" color="gray.100" size="lg">
|
||||
{object?.change_type === "increase" ? <TrendingUp /> : <TrendingDown />}
|
||||
</Timeline.Indicator>
|
||||
</Timeline.Connector>
|
||||
<Timeline.Content w="100%" h="100%">
|
||||
{/*<Timeline.Title>{object?.volcano}</Timeline.Title>*/}
|
||||
<Timeline.Description w="100%" h="100%" gap={1}>
|
||||
<Flex direction="column" gap={1}>
|
||||
<Text w="100%" h="100%" color="gray.200">{object?.alert_start_date_local}</Text>
|
||||
<Text w="100%" h="100%"
|
||||
color={alertColors[object?.alert_level_name] || "gray.200"}
|
||||
fontSize="md"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
{object?.alert_level_name}
|
||||
</Text>
|
||||
<Flex w="100%" h="20px" direction="row" justify="left" align="center">
|
||||
<Text color="gray.300">Alert Scale: </Text>
|
||||
{(Array.from({ length: Number(object?.alert_scheme_highest) - Number(object?.alert_scheme_lowest) + 1}, (v, i) => Number(object?.alert_scheme_lowest) + i))
|
||||
.map((num) => (
|
||||
<Center
|
||||
key={num}
|
||||
w="20px" h="100%"
|
||||
color="gray.300"
|
||||
bg={num === Number(object?.alert_level_rank) ? "gray.300/20" : null} // alert level rank is a string for some reason
|
||||
borderRadius={num === Number(object?.alert_level_rank) ? "xl" : null}
|
||||
textAlign="center"
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
>
|
||||
{num}
|
||||
</Center>
|
||||
))
|
||||
}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Timeline.Description>
|
||||
</Timeline.Content>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
</Timeline.Root>
|
||||
</Collapsible.Content>
|
||||
</>
|
||||
)}
|
||||
</Collapsible.Context>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
else if (thisWidget?.size === "small") return (
|
||||
volcano[1]?.map((domain, index) => (
|
||||
<Flex
|
||||
key={index}
|
||||
direction="row"
|
||||
w="100%"
|
||||
gap={2}
|
||||
justify="space-between" align="center"
|
||||
p={1} pl={3} pr={3}
|
||||
borderRadius="lg"
|
||||
bg="gray.600"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Flex direction="row" w="100%" gap={2} align="center">
|
||||
<Flex color="gray.300">
|
||||
{domain[0] === "aviation" && <Plane size={15}/>}
|
||||
{domain[0] === "terrestrial" && <Footprints size={15}/>}
|
||||
{domain[0] === "maritime" && <Sailboat size={15}/>}
|
||||
</Flex>
|
||||
<Text truncate>{volcano[0] || ""}</Text> {/* volcano name */}
|
||||
</Flex>
|
||||
<Text truncate w="100%" fontSize="xs" color="gray.300/90">{domain[1]?.[0]?.alert_start_date_local || ""}</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="row" w="100%" minW={0} justify="right" gap={2} color="gray.200">
|
||||
<Flex w="25px" justify="right">
|
||||
{domain[1]?.[0]?.change_type === "increase" ? <TrendingUp/> : <TrendingDown/>}
|
||||
</Flex>
|
||||
<Text
|
||||
truncate
|
||||
pl={2} pr={2}
|
||||
borderRadius="lg"
|
||||
bg="gray.800/50"
|
||||
color={alertColors[domain[1]?.[0]?.alert_level_name] || "gray.100"}
|
||||
fontWeight="semibold"
|
||||
>
|
||||
{domain[1]?.[0]?.alert_level_name}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -2,10 +2,9 @@ import { useMemo } from 'react';
|
||||
import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
|
||||
import { useColorMode } from "@/components/ui/color-mode.jsx";
|
||||
import Plot from 'react-plotly.js';
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
|
||||
|
||||
export default function ExplosionEvents({ settings, widgetID }) {
|
||||
export default function ExplosionEvents({ settings }) {
|
||||
// Plotly expects 'linear' for normal axes
|
||||
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
|
||||
|
||||
@@ -13,11 +12,8 @@ export default function ExplosionEvents({ settings, widgetID }) {
|
||||
const { colorMode } = useColorMode();
|
||||
const isDark = colorMode === 'dark';
|
||||
|
||||
// Generate url with input dates, default to 7 day range
|
||||
const url = useBuildUrl('/api/v2/explosion_events', settings?.startDate, settings?.endDate);
|
||||
|
||||
// Clean, single-line data fetching
|
||||
const { data: apiData, isLoading, error } = VdapApi(url);
|
||||
const { data: apiData, isLoading, error } = VdapApi('/api/v2/explosion_events?start_date=2025-05-01&end_date=2025-05-30');
|
||||
|
||||
// 1. Build the Traces (Data parsing)
|
||||
const plotTraces = useMemo(() => {
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react";
|
||||
import {useEffect, useMemo } from 'react';
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
export default function IvansAlerts({ settings, widgetID }) {
|
||||
console.log("IvansAlerts widget ID: ", widgetID);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
// build url, default is 7 days past
|
||||
const url = useBuildUrl('/api/v2/comm_coor_ivans', settings?.startDate, settings?.endDate);
|
||||
|
||||
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
|
||||
const {data: apiData, isLoading, error} = VdapApi(url);
|
||||
|
||||
// TEST DATA
|
||||
// const apiData = useMemo(() => {
|
||||
// return ([
|
||||
// {
|
||||
// id: 277,
|
||||
// volcano_number: 268010,
|
||||
// report_num: "2026-01",
|
||||
// report_date: "2026-05-08T22:05:44Z",
|
||||
// preparer: "hwright",
|
||||
// reviewer: "dramsey",
|
||||
// send_time: "2026-05-08T22:05:52Z",
|
||||
// volcano_name: "Dukono",
|
||||
// summary: "At 07:41 local time on 8 May 2026, an explosion of persistently active Dukono volcano occurred, producing an ash plume that rose over 15 km (52,000 feet) above sea level and ejecting ballistic blocks of lava beyond the crater rim. The eruption killed three tourists who were hiking within the official exclusion zone; other hikers were rescued and returned to safety.",
|
||||
// files: [
|
||||
// {
|
||||
// name: "277-268010-Dukono-2026-01.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/277-268010-Dukono-2026-01.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 279,
|
||||
// volcano_number: 250030,
|
||||
// report_num: "2026-01",
|
||||
// report_date: "2026-05-15T05:11:53Z",
|
||||
// preparer: "charpel",
|
||||
// reviewer: "jlwnstrn",
|
||||
// send_time: "2026-05-15T05:12:01Z",
|
||||
// volcano_name: "Central Bismarck Sea",
|
||||
// summary: "A M5.4 earthquake occurred on 14 May 2026 at 17:13 (UTC) during the ongoing eruption of the submarine Central Bismarck Sea volcano that began 8 May 2026. The volcano's current activity does not threaten any populated areas, but today’s earthquake indicates that eruptive activity will continue or increase.",
|
||||
// files: [
|
||||
// {
|
||||
// name: "279-250030-Central Bismarck Sea-2026-01.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/279-250030-Central Bismarck Sea-2026-01.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 275,
|
||||
// volcano_number: 273030,
|
||||
// report_num: "2026-02",
|
||||
// report_date: "2026-05-03T05:28:12Z",
|
||||
// preparer: "dramsey",
|
||||
// reviewer: "hwright",
|
||||
// send_time: "2026-05-03T05:28:20Z",
|
||||
// volcano_name: "Mayon",
|
||||
// summary: "At around 5:30 PM local time on May 2, 2026, a pyroclastic flow (cloud of searing ash and rock particles) from erupting Mayon Volcano traveled around 4 km (~2.5 miles) down the southwestern slope of the volcano and produced an ash plume that drifted to the west-southwest. At least 52 villages in various towns in Albay province were heavily affected by thick ash fall, reducing visibility to near zero and making travel extremely difficult. There were no reports of casualties and the volcano alert level remained at Level 3 (on a 0-5 level scale).",
|
||||
// files: [
|
||||
// {
|
||||
// name: "275-273030-Mayon-2026-02.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
||||
// type: "pdf"
|
||||
// },
|
||||
// {
|
||||
// name: "275-273030-Mayon-2026-02.pdf",
|
||||
// url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// ])
|
||||
// }, []);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (apiData?.length === 0) return [];
|
||||
let sorted;
|
||||
|
||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeVolcanoSort);
|
||||
} else if (sorts?.volcanoSort && sorts?.timeSort === "desc") {
|
||||
sorted = [...apiData].sort(descTimeVolcanoSort);
|
||||
} else if (sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeSort);
|
||||
} else {
|
||||
sorted = [...apiData].sort(descTimeSort);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [apiData, sorts]);
|
||||
|
||||
useEffect(() => {
|
||||
if(isCopy) {
|
||||
const dataText = data.map((item) => {
|
||||
return `${item.volcano_name}\n` +
|
||||
`Report Number: ${item.report_num}\n` +
|
||||
`Send Time: ${item.send_time}\n` +
|
||||
`Preparer: ${item.preparer}\n` +
|
||||
`Summary:\n${item.summary}\n`;
|
||||
}).join('\n\n');
|
||||
navigator.clipboard.writeText(dataText)
|
||||
.then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
})
|
||||
.catch((err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [isCopy, data, widgetID, updateSettings]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data.length === 0) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="lg" bg="gray.800">
|
||||
<Text textAlign="center" color="gray.400">No flight level data available for the selected dates.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column"
|
||||
w="100%" h="100%"
|
||||
pos="relative"
|
||||
justify="top" align="center"
|
||||
p={2} gap={2}
|
||||
overflow="auto"
|
||||
borderRadius="xl"
|
||||
bg="gray.800"
|
||||
>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<Collapsible.Root key={index} w="100%">
|
||||
{/* Using context to dynamically change the border radii of the triggers */}
|
||||
<Collapsible.Context>
|
||||
{(collapsible) => (
|
||||
<>
|
||||
<Collapsible.Trigger w="100%">
|
||||
<Flex direction="row"
|
||||
w="100%" h="100%"
|
||||
justify="space-between" align="center"
|
||||
p={1} pl={3} pr={3}
|
||||
gap={2}
|
||||
borderTopRadius="lg"
|
||||
borderBottomRadius={collapsible.open ? "0" : "lg"}
|
||||
bg="gray.700"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Text truncate>{item.volcano_name}</Text>
|
||||
<Text truncate fontSize="xs" color="gray.300/90">{item.send_time}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100">{item.report_num}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content w="100%" h="auto">
|
||||
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
|
||||
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
|
||||
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
|
||||
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
|
||||
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Preparer: {item?.preparer}</Text>
|
||||
</Flex>
|
||||
{item?.files?.map((file, index) => {
|
||||
return (
|
||||
<Box w="100%" h="100%" key={index} p={1}>
|
||||
<br />
|
||||
<Flex mb={2}>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
</Flex>
|
||||
<object
|
||||
data={`${file?.url}#view=FitH&toolbar=1`}
|
||||
type="application/pdf"
|
||||
style={{ width: '100%', height: '100%', display: 'block' }}
|
||||
>
|
||||
<Flex w="100%" h="100%" p={4} color="gray.400">
|
||||
<Text mb={2} color="fg.muted">
|
||||
Your browser cannot display this PDF directly.
|
||||
</Text>
|
||||
<a href={file?.url} target="_blank">
|
||||
Download PDF
|
||||
</a>
|
||||
</Flex>
|
||||
</object>
|
||||
</Box>
|
||||
)})}
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</>
|
||||
)}
|
||||
</Collapsible.Context>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ascTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
else return ascTimeSort(a, b);
|
||||
}
|
||||
|
||||
function descTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
else return descTimeSort(a, b);
|
||||
}
|
||||
|
||||
function ascTimeSort(a, b) {
|
||||
const aTime = new Date(a.send_time);
|
||||
const bTime = new Date(b.send_time);
|
||||
|
||||
if (aTime > bTime) return 1;
|
||||
if (aTime < bTime) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function descTimeSort(a, b) {
|
||||
const aTime = new Date(a.send_time);
|
||||
const bTime = new Date(b.send_time);
|
||||
|
||||
if (aTime > bTime) return -1;
|
||||
if (aTime < bTime) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { http, HttpResponse } from 'msw'; // Import MSW
|
||||
import IvansAlerts from './IvansAlerts';
|
||||
|
||||
export default {
|
||||
title: 'Dashboard Widgets/IVANS Activity',
|
||||
component: IvansAlerts,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box
|
||||
w="100%" maxW="1800px" h="400px"
|
||||
border="2px dashed" borderColor="gray.600" p={2}
|
||||
resize="both" overflow="auto"
|
||||
>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
id: 279,
|
||||
volcano_number: 250030,
|
||||
report_num: "2026-01",
|
||||
report_date: "2026-05-15T05:11:53Z",
|
||||
preparer: "charpel",
|
||||
reviewer: "jlwnstrn",
|
||||
send_time: "2026-05-15T05:12:01Z",
|
||||
volcano_name: "Central Bismarck Sea",
|
||||
summary: "A M5.4 earthquake occurred on 14 May 2026 at 17:13 (UTC) during the ongoing eruption of the submarine Central Bismarck Sea volcano that began 8 May 2026. The volcano's current activity does not threaten any populated areas, but today’s earthquake indicates that eruptive activity will continue or increase.",
|
||||
files: [
|
||||
{
|
||||
name: "279-250030-Central Bismarck Sea-2026-01.pdf",
|
||||
url: "https://vdap.org/ivans/sitrep/279-250030-Central Bismarck Sea-2026-01.pdf",
|
||||
type: "pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 277,
|
||||
volcano_number: 268010,
|
||||
report_num: "2026-01",
|
||||
report_date: "2026-05-08T22:05:44Z",
|
||||
preparer: "hwright",
|
||||
reviewer: "dramsey",
|
||||
send_time: "2026-05-08T22:05:52Z",
|
||||
volcano_name: "Dukono",
|
||||
summary: "At 07:41 local time on 8 May 2026, an explosion of persistently active Dukono volcano occurred, producing an ash plume that rose over 15 km (52,000 feet) above sea level and ejecting ballistic blocks of lava beyond the crater rim. The eruption killed three tourists who were hiking within the official exclusion zone; other hikers were rescued and returned to safety.",
|
||||
files: [
|
||||
{
|
||||
name: "277-268010-Dukono-2026-01.pdf",
|
||||
url: "https://vdap.org/ivans/sitrep/277-268010-Dukono-2026-01.pdf",
|
||||
type: "pdf"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 275,
|
||||
volcano_number: 273030,
|
||||
report_num: "2026-02",
|
||||
report_date: "2026-05-03T05:28:12Z",
|
||||
preparer: "dramsey",
|
||||
reviewer: "hwright",
|
||||
send_time: "2026-05-03T05:28:20Z",
|
||||
volcano_name: "Mayon",
|
||||
summary: "At around 5:30 PM local time on May 2, 2026, a pyroclastic flow (cloud of searing ash and rock particles) from erupting Mayon Volcano traveled around 4 km (~2.5 miles) down the southwestern slope of the volcano and produced an ash plume that drifted to the west-southwest. At least 52 villages in various towns in Albay province were heavily affected by thick ash fall, reducing visibility to near zero and making travel extremely difficult. There were no reports of casualties and the volcano alert level remained at Level 3 (on a 0-5 level scale).",
|
||||
files: [
|
||||
{
|
||||
name: "275-273030-Mayon-2026-02.pdf",
|
||||
url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
||||
type: "pdf"
|
||||
},
|
||||
{
|
||||
name: "275-273030-Mayon-2026-02.pdf",
|
||||
url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
||||
type: "pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Scenario LIVE: call our API for real
|
||||
export const LivePopulated = {};
|
||||
|
||||
// Scenario 1: Successful API Call
|
||||
export const Populated = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
// Intercept the exact URL your component is trying to fetch
|
||||
http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
|
||||
// Return a 200 OK with the mock JSON
|
||||
return HttpResponse.json(mockData);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Scenario 2: The 500 Server Crash
|
||||
export const ServerCrash = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
|
||||
// Return a 500 Error
|
||||
return new HttpResponse(null, { status: 500 });
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Scenario 3: Empty State (API works, but no data returned)
|
||||
export const EmptyData = {
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v2/comm_coor_ivans?start_date=2026-01-01&end_date=2026-07-22', () => {
|
||||
return HttpResponse.json([]);
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,143 +0,0 @@
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
import {Box, Flex, Spinner, Text} from "@chakra-ui/react";
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
export default function PlumeEvents({ settings, widgetID}) {
|
||||
// Build url, defaults to 7 days past
|
||||
const url = useBuildUrl('/api/v2/plume_events', settings?.startDate, settings?.endDate);
|
||||
|
||||
// Retrieve data from api
|
||||
const { data: apiData, isLoading, error } = VdapApi(url);
|
||||
|
||||
const plotTraces = useMemo(() => {
|
||||
if (!apiData || apiData.length === 0) return [];
|
||||
|
||||
// Array of distinct Plotly shapes
|
||||
const Symbolway = [
|
||||
'circle', 'square', 'diamond',
|
||||
'triangle-up', 'triangle-down',
|
||||
'star', 'hexagram', 'pentagon',
|
||||
'hexagon', 'octagon', 'cross', 'x'
|
||||
];
|
||||
|
||||
// Sort data descending by plume height so smaller dots draw on top of larger ones
|
||||
const sortedData = [...apiData].sort((a, b) => b.h_avs_m - a.h_avs_m);
|
||||
|
||||
// Efficiently group into Plotly traces by volcano
|
||||
const groupedData = sortedData.reduce((acc, curr) => {
|
||||
const name = curr.volcano_name || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { x: [], y: [] };
|
||||
|
||||
// Map the explosion-specific keys
|
||||
acc[name].x.push(new Date(curr.ts_epoch));
|
||||
acc[name].y.push(curr.h_avs_m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(groupedData).map((volcanoName, index) => ({
|
||||
name: volcanoName,
|
||||
x: groupedData[volcanoName].x,
|
||||
y: groupedData[volcanoName].y,
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
hovertemplate:
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Plume Height (m): %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#d1d5db' }, // gray.300
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
// title: {
|
||||
// text: '7 Day Consolidated Flight Levels',
|
||||
// font: { size: 16, color: 'white' },
|
||||
// x: 0.05
|
||||
// },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: 'white', size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#4b5563',
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Plume Height',
|
||||
font: { color: 'white', size: 12 }
|
||||
},
|
||||
type: 'linear',
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#ffffff',
|
||||
automargin: true,
|
||||
},
|
||||
// legend: {
|
||||
// orientation: 'h',
|
||||
// y: -0.2,
|
||||
// x: 0.5,
|
||||
// xanchor: 'center'
|
||||
// },
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: 0,
|
||||
x1: 1,
|
||||
xref: 'paper', // Spans entire chart width
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: 'white', width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (plotTraces.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
|
||||
<Plot
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import {Button, Flex, Spinner, Text, Box, Collapsible} from "@chakra-ui/react";
|
||||
import {useEffect, useMemo } from 'react';
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
export default function RemoteSensing({ settings, widgetID }) {
|
||||
console.log("RS widget ID: ", widgetID);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const isCopy = settings?.copyToClipboard === true; // acts as a fail-safe if no default setting is set
|
||||
const sorts = settings?.activeSorts;
|
||||
|
||||
// build url, defaults to 7 days past
|
||||
const url = useBuildUrl('/api/v2/remote_sensing', settings?.startDate, settings?.endDate);
|
||||
|
||||
// VdapApi hook only calls api if the url changes and apiData remains stable since vdapApi uses state to track the data
|
||||
const {data: apiData, isLoading, error} = VdapApi(url);
|
||||
|
||||
// TEST DATA
|
||||
// const apiData = useMemo(() => {
|
||||
// return ([
|
||||
// {
|
||||
// id: 1848,
|
||||
// volcano_name: "Lewotolok",
|
||||
// country: "Indonesia",
|
||||
// report_time: "2026-01-13T20:39:39Z",
|
||||
// summary: "Dear friends,\n\nOur latest satellite view from 13 January (~0900 local time) shows that the lava has began to flow over the crater rim onto the northwest flank (see attached). The lava flow spreads into three lobes from the single source vent on the northwest flank of the active cone. The longest portion of the flow was about 530 meters from the vent with the toe draping about 30-40 meters over the rim of the main crater just north of the May 2024 lava flow. A narrow deposit of volcanoclastic debris extends about 300 meters below the toe of the lava flow (white dashed outline on sketch).\n\nPlease let us know if you have any questions.\nBest wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: [
|
||||
// {
|
||||
// name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// type: "image"
|
||||
// },
|
||||
// {
|
||||
// name: "Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1848-Lewotolok-ID-sketch-20260113_Jan_lavas.jpg",
|
||||
// type: "image"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 1847,
|
||||
// volcano_name: "Mayon",
|
||||
// country: "Philippines",
|
||||
// report_time: "2026-01-13T17:56:46Z",
|
||||
// summary: "Dear PHIVOLCS friends,\nOur most recent satellite data from 13 January (1500 local) show the new lava flow extending southeast from the summit and splitting into two lobes (see attached pdf). The total length of the longest lobe was about 1 kilometer. Compared to data from 12 January, the longest part of the flow advanced almost 200 meters over 11 hours while the shorter section advanced about 80 meters over the same time. Debris falling from the front of both lava flow lobes extend another several hundred meters below each lobe. We continue to watch the activity at Mayon volcano and will share anything that might help. \n\nPlease let us know if you have any questions, requests, or comments.\n\nBest wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: [
|
||||
// {
|
||||
// name: "Mayon-RP-images-20260113_ICEYE_lava.pdf",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1847-Mayon-RP-images-20260113_ICEYE_lava.pdf",
|
||||
// type: "pdf"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 1889,
|
||||
// volcano_name: "Bagana",
|
||||
// country: "Papua New Guinea",
|
||||
// report_time: "2026-06-24T21:28:58Z",
|
||||
// summary: "Hi Steve and RVO,\nI'm now back in the office after some time away and have looked closely at the recent data for Bagana Volcano. \nSatellite data from 15 June show that an increase in lava effusion caused the upper part of the active lava flow at the summit to inflate and likely contributed to the 19-20 June collapse. By 16 June, the inflated area was about 260-meters-long by 140-meters-wide. The northeast edge of the inflated section of the lava flow was the source of rockfalls down the northeast flank where some of the larger blocks tumbled over 1.3 kilometer from the flow. The 22 June data that Julie mentioned shows that all of the inflated area was removed by the collapse (~36,000 square meter area of the upper flow removed). The collapse PDC deposits scoured a deep gorge and removed a broad area of vegetation near the lake. The distal portion of the collapse deposits formed a small delta extending into the lake and possibly raising the lake level slightly based on overall shore outline changes.\n\nI've attached a sketch map showing the features described above. Please let us know if you have any questions.\n\nBest wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: [
|
||||
// {
|
||||
// name: "Bagana-PG-sketch-20260622_collapse+lava.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_collapse+lava.jpg",
|
||||
// type: "image"
|
||||
// },
|
||||
// {
|
||||
// name: "Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana-PG-sketch-20260622_3Dcollapse+lava.jpg",
|
||||
// type: "image"
|
||||
// },
|
||||
// {
|
||||
// name: "Bagana_22_Jun_2026_collapse2.kml",
|
||||
// url: "https://vdap.org/commcoordashboard/rs/attachments/1889-Bagana_22_Jun_2026_collapse2.kml",
|
||||
// type: "download"
|
||||
// }
|
||||
// ]
|
||||
// },
|
||||
// {
|
||||
// id: 1845,
|
||||
// volcano_name: "Lewotolok",
|
||||
// country: "Indonesia",
|
||||
// report_time: "2026-01-07T21:42:17Z",
|
||||
// summary: "Dear friends,\nThe increased activity at Lewotolok Volcano over the past week reminded me that we have not sent you a Remote Sensing Summary since June 2025. Over the two days from 3 to 5 January 2026, explosions from the central cone intensified, doubling the area impacted by proximal ballistics and tephra to over 20 hectares, while increased effusion has produced a new ~20,000 m² lava flow on the west–northwest flank that extends ~270 meters from the vent. So far, all recent lava flows have been confined to the summit crater.\n\nPlease let us know if you have any questions or additions.\n\nBest Wishes,\nRick",
|
||||
// sender: "rwessels@usgs.gov",
|
||||
// files: []
|
||||
// },
|
||||
// ])
|
||||
// }, []);
|
||||
|
||||
const data = useMemo(() => {
|
||||
if (apiData?.length === 0) return [];
|
||||
let sorted;
|
||||
|
||||
if (sorts?.volcanoSort && sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeVolcanoSort);
|
||||
} else if (sorts?.volcanoSort && sorts?.timeSort === "desc") {
|
||||
sorted = [...apiData].sort(descTimeVolcanoSort);
|
||||
} else if (sorts?.timeSort === "asc") {
|
||||
sorted = [...apiData].sort(ascTimeSort);
|
||||
} else {
|
||||
sorted = [...apiData].sort(descTimeSort);
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [apiData, sorts]);
|
||||
|
||||
useEffect(() => {
|
||||
if(isCopy) {
|
||||
const dataText = data.map((item) => {
|
||||
return `${item.country}, ${item.volcano_name}\n` +
|
||||
`Report Time: ${item.report_time}\n` +
|
||||
`Sender: ${item.sender}\n` +
|
||||
`Summary:\n${item.summary}\n`;
|
||||
}).join('\n\n');
|
||||
navigator.clipboard.writeText(dataText)
|
||||
.then(() => {
|
||||
alert('Copied to clipboard!');
|
||||
})
|
||||
.catch((err => {
|
||||
console.error('Failed to copy: ', err);
|
||||
}));
|
||||
updateSettings(widgetID, {copyToClipboard: false});
|
||||
}
|
||||
}, [isCopy, data, widgetID, updateSettings]);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" borderRadius="md" bg="gray.800">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px"/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center" p={4} borderRadius="md" bg="gray.800">
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (data.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No Remote Sensing data available this week.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex direction="column"
|
||||
w="100%" h="100%"
|
||||
pos="relative"
|
||||
justify="top" align="center"
|
||||
p={2} gap={2}
|
||||
overflow="auto"
|
||||
borderRadius="xl"
|
||||
bg="gray.800"
|
||||
>
|
||||
{data.map((item, index) => {
|
||||
return (
|
||||
<Collapsible.Root key={index} w="100%">
|
||||
{/* Using context to dynamically change the border radii of the triggers */}
|
||||
<Collapsible.Context>
|
||||
{(collapsible) => (
|
||||
<>
|
||||
<Collapsible.Trigger w="100%" overflow="hidden">
|
||||
<Flex
|
||||
direction="row"
|
||||
w="100%" h="100%"
|
||||
justify="space-between" align="center"
|
||||
gap={2}
|
||||
p={1} pl={3} pr={3}
|
||||
borderTopRadius="lg"
|
||||
borderBottomRadius={collapsible.open ? "0" : "lg"}
|
||||
bg="gray.700"
|
||||
>
|
||||
<Flex direction="column" w="100%" minW={0} textAlign="left" color="gray.100">
|
||||
<Text truncate >{item?.volcano_name}</Text>
|
||||
<Text truncate fontSize="xs" color="gray.300/90">{item?.country}</Text>
|
||||
</Flex>
|
||||
<Flex w="100%" minW={0} justify="right">
|
||||
<Text truncate pl={2} pr={2} borderRadius="lg" bg="gray.800/50" color="gray.100" fontSize="sm">{item?.report_time}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content w="100%" h="auto">
|
||||
{/* This box needs to be at h="100%" for the pdf toolbar to appear */}
|
||||
<Box w="100%" h="100%" p={1} borderBottomRadius="lg" bg="gray.700/45">
|
||||
<Flex direction="column" gap={2} bg="gray.800/75" borderRadius="lg" p={2} align="end">
|
||||
<Text w="100%" h="100%" fontSize="sm" color="gray.100" textAlign="left" whiteSpace="pre-line">{item?.summary}</Text>
|
||||
<Text w="fit" fontSize="sm" color="gray.200" pl={1} pr={1} bg="gray.700/50" borderRadius="md">Source: {item?.sender}</Text>
|
||||
</Flex>
|
||||
{item?.files?.map((file, index) => {
|
||||
|
||||
// PDF FILE TYPE
|
||||
if (file?.type === "pdf") return (
|
||||
<Box w="100%" h="100%" key={index} p={1}>
|
||||
<br />
|
||||
<Flex dir="row" justify="space-between" align="center" mb={2}>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PDF</Text>
|
||||
</Flex>
|
||||
<object
|
||||
data={`${file?.url}#view=FitH&toolbar=1`}
|
||||
type="application/pdf"
|
||||
style={{width: '100%', height: '100%', display: 'block'}}
|
||||
>
|
||||
<Flex w="100%" h="100%" p={4} color="gray.400">
|
||||
<Text mb={2} color="fg.muted">
|
||||
Your browser cannot display this PDF directly.
|
||||
</Text>
|
||||
<a href={file?.url} target="_blank">
|
||||
Download PDF
|
||||
</a>
|
||||
</Flex>
|
||||
</object>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// IMAGE FILE TYPE
|
||||
else if (file?.type === "image") return (
|
||||
<Box key={index} w="100%" h="100%" p={1}>
|
||||
<br />
|
||||
<Flex dir="row" justify="space-between" align="center" mb={2}>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300/90">PNG</Text>
|
||||
</Flex>
|
||||
<a href={file?.url} target="_blank" rel="noopener noreferrer">
|
||||
<img src={file?.url} alt={file?.name} />
|
||||
</a>
|
||||
<br />
|
||||
</Box>
|
||||
);
|
||||
|
||||
// DOWNLOADABLE LINK
|
||||
else if (file?.type === "download") return (
|
||||
<Flex key={index} dir="row" w="100%" h="100%" p={1} align="center" justify="space-between">
|
||||
<br />
|
||||
<Text pl={1} pr={1} bg="gray.600/50" borderRadius="md" fontSize="xs" color="gray.300">{file?.name}</Text>
|
||||
<Button asChild variant="surface" size="xxs" fontSize="xs" pl={2} pr={2}>
|
||||
<a href={file?.url} target="_blank" rel="noopener noreferrer">Download</a>
|
||||
</Button>
|
||||
</Flex>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
</Collapsible.Content>
|
||||
</>
|
||||
)}
|
||||
</Collapsible.Context>
|
||||
</Collapsible.Root>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function ascTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
else return ascTimeSort(a, b);
|
||||
}
|
||||
|
||||
function descTimeVolcanoSort(a, b) {
|
||||
if (a.volcano_name < b.volcano_name) return -1
|
||||
else if (a.volcano_name > b.volcano_name) return 1
|
||||
else return descTimeSort(a, b);
|
||||
}
|
||||
|
||||
function ascTimeSort(a, b) {
|
||||
const aTime = new Date(a.report_time);
|
||||
const bTime = new Date(b.report_time);
|
||||
|
||||
if (aTime > bTime) return 1;
|
||||
if (aTime < bTime) return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function descTimeSort(a, b) {
|
||||
const aTime = new Date(a.report_time);
|
||||
const bTime = new Date(b.report_time);
|
||||
|
||||
if (aTime > bTime) return -1;
|
||||
if (aTime < bTime) return 1;
|
||||
return 0;
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import VdapApi from '../../hooks/useVdapApi.js';
|
||||
import { useMemo } from 'react';
|
||||
import Plot from 'react-plotly.js';
|
||||
import {Box, Flex, Spinner, Text} from "@chakra-ui/react";
|
||||
import useBuildUrl from "@/hooks/useBuildUrl.js";
|
||||
|
||||
export default function SeismicEvents({ settings, widgetID}) {
|
||||
// build url, defaults to 7 days past
|
||||
const url = useBuildUrl('/api/v2/seismic_events', settings?.startDate, settings?.endDate);
|
||||
|
||||
// retrieve data from api
|
||||
const { data: apiData, isLoading, error } = VdapApi(url);
|
||||
|
||||
const plotTraces = useMemo(() => {
|
||||
if (!apiData || apiData.length === 0) return [];
|
||||
|
||||
// Array of distinct Plotly shapes
|
||||
const Symbolway = [
|
||||
'circle', 'square', 'diamond',
|
||||
'triangle-up', 'triangle-down',
|
||||
'star', 'hexagram', 'pentagon',
|
||||
'hexagon', 'octagon', 'cross', 'x'
|
||||
];
|
||||
|
||||
// Sort data descending by seismic events count so smaller dots draw on top of larger ones
|
||||
const sortedData = [...apiData].sort((a, b) => b.number_events - a.number_events);
|
||||
|
||||
// Efficiently group into Plotly traces by volcano
|
||||
const groupedData = sortedData.reduce((acc, curr) => {
|
||||
const name = curr.volcano_name || 'Unknown';
|
||||
if (!acc[name]) acc[name] = { x: [], y: [] };
|
||||
|
||||
// Map the seismic-specific keys
|
||||
acc[name].x.push(new Date(curr.ts_epoch));
|
||||
acc[name].y.push(curr.number_events);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(groupedData).map((volcanoName, index) => ({
|
||||
name: volcanoName,
|
||||
x: groupedData[volcanoName].x,
|
||||
y: groupedData[volcanoName].y,
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
marker: {
|
||||
size: 7,
|
||||
line: { width: .5, color: 'rgba(255, 255, 255, 0.7)' },
|
||||
symbol: Symbolway[index % Symbolway.length],
|
||||
},
|
||||
// Replicating the clean D3/Observable tooltip
|
||||
hovertemplate:
|
||||
`<b>${volcanoName}</b><br>` +
|
||||
'Date (UTC): %{x|%b %d %Y %H:%M}<br>' +
|
||||
'Number of Seismic Events: %{y}<extra></extra>'
|
||||
}));
|
||||
}, [apiData]);
|
||||
|
||||
const layout = useMemo(() => {
|
||||
|
||||
return {
|
||||
autosize: true,
|
||||
paper_bgcolor: 'transparent', // Let Chakra UI background show through
|
||||
plot_bgcolor: 'transparent',
|
||||
font: { color: '#d1d5db' }, // gray.300
|
||||
margin: { t: 40, r: 20, l: 20, b: 20 },
|
||||
// title: {
|
||||
// text: '',
|
||||
// font: { size: 16, color: 'white' },
|
||||
// x: 0.05
|
||||
// },
|
||||
xaxis: {
|
||||
title: {
|
||||
text: 'Date (UTC)',
|
||||
font: { color: 'white', size: 12 }
|
||||
},
|
||||
type: 'date',
|
||||
tickformat: '%b %d<br>%Y', // D3 requested %b %d \n %Y formatting
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#4b5563',
|
||||
automargin: true,
|
||||
},
|
||||
yaxis: {
|
||||
title: {
|
||||
text: 'Seismic Count',
|
||||
font: { color: 'white', size: 12 }
|
||||
},
|
||||
type: 'linear',
|
||||
gridcolor: '#4b5563',
|
||||
zerolinecolor: '#ffffff',
|
||||
automargin: true,
|
||||
},
|
||||
// legend: {
|
||||
// orientation: 'h',
|
||||
// y: -0.2,
|
||||
// x: 0.5,
|
||||
// xanchor: 'center'
|
||||
// },
|
||||
hovermode: 'closest',
|
||||
shapes: [
|
||||
{
|
||||
type: 'line',
|
||||
x0: 0,
|
||||
x1: 1,
|
||||
xref: 'paper', // Spans entire chart width
|
||||
y0: 0,
|
||||
y1: 0,
|
||||
yref: 'y',
|
||||
line: { color: 'white', width: 1 }
|
||||
}
|
||||
],
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (isLoading) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
|
||||
<Spinner size="xl" color="blue.400" thickness="4px" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (error) return (
|
||||
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
||||
<Text color="red.400">{error}</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (plotTraces.length === 0) return (
|
||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||
<Text color="gray.400">No data available for current filter.</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box height="100%" width="100%" bg="transparent" borderRadius="md" p={2} overflow="hidden">
|
||||
<Plot
|
||||
data={plotTraces}
|
||||
layout={layout}
|
||||
useResizeHandler={true} // Essential for dashboard grid resizing!
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
config={{ responsive: true, displayModeBar: true, displaylogo: false }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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
|
||||
|
||||
export default function VaaActivity7Day({ settings, widgetID }) {
|
||||
export default function VaaActivity7Day({ settings }) {
|
||||
console.log("3. Chart received settings:", settings);
|
||||
// Plotly expects 'linear' for normal axes
|
||||
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import {Flex, Slider, Switch} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
|
||||
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" gap={2} pb={2}>
|
||||
{/* SLIDER DATE RANGE SELECTION */}
|
||||
<Slider.Root
|
||||
width="80%" h="100%"
|
||||
min={1} max={14}
|
||||
defaultValue={[7]}
|
||||
step={1}
|
||||
onValueChangeEnd={(e) => {
|
||||
const day = new Date(Date.now() - (e.value[0])*86400000).toLocaleDateString('en-CA');
|
||||
console.log("new start: ", day);
|
||||
updateSettings(widgetId, {...currentSettings, startDate: day});
|
||||
}}
|
||||
>
|
||||
<Slider.Label>{currentSettings?.endDate} to {currentSettings?.startDate}</Slider.Label>
|
||||
<Slider.Control>
|
||||
<Slider.Track>
|
||||
<Slider.Range />
|
||||
</Slider.Track>
|
||||
<Slider.Thumbs />
|
||||
<Slider.MarkerGroup p={3}>
|
||||
<Slider.Marker value={1} whiteSpace="nowrap">today</Slider.Marker>
|
||||
<Slider.Marker value={7} whiteSpace="nowrap">1 week ago</Slider.Marker>
|
||||
<Slider.Marker value={14} whiteSpace="nowrap">2 weeks ago</Slider.Marker>
|
||||
</Slider.MarkerGroup>
|
||||
</Slider.Control>
|
||||
</Slider.Root>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import {Box, Text, Switch, Flex, Field, Input} from "@chakra-ui/react";
|
||||
import { Box, Text, Switch } from "@chakra-ui/react";
|
||||
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
// 1. Convert the saved string into a boolean for the toggle UI
|
||||
const isSymlog = currentSettings?.yAxisScale === 'log';
|
||||
@@ -21,75 +18,20 @@ export default function ExplosionEventsSettings({ widgetId, currentSettings }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
|
||||
{/* Y-AXIS SETTING*/}
|
||||
<Text fontWeight="bold" mb={3} fontSize="sm">Y-Axis Settings</Text>
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3} fontSize="sm">Y-Axis Settings</Text>
|
||||
|
||||
<Switch.Root
|
||||
checked={isSymlog}
|
||||
onCheckedChange={handleToggle}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Use Log Scale</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
{/* DATE RANGE SELECTION */}
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.startDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, startDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
|
||||
else setStartError("");
|
||||
}}
|
||||
/>
|
||||
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.endDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, endDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
|
||||
else setEndError("");
|
||||
}}
|
||||
/>
|
||||
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
<Switch.Root
|
||||
checked={isSymlog}
|
||||
onCheckedChange={handleToggle}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Use Log Scale</Switch.Label>
|
||||
</Switch.Root>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "../../../store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
updateSettings(widgetId, {copyToClipboard: true});
|
||||
}
|
||||
const handleVolcanoSort = (details) => {
|
||||
const isSort = currentSettings?.activeSorts?.volcanoSort === true;
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, volcanoSort: !isSort }});
|
||||
}
|
||||
const handleTimeSort = (details) => {
|
||||
const sort = currentSettings?.activeSorts?.timeSort === "desc" ? "asc" : "desc";
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, timeSort: sort }});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" gap={2} overflowY="auto">
|
||||
{/* COPY TO CLIPBOARD */}
|
||||
<Button w="fit" size="xs" onClick={handleCopyToClipboard}>Copy All IVANS</Button>
|
||||
|
||||
{/* SORTS */}
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.timeSort === "asc"}
|
||||
onCheckedChange={handleTimeSort}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Sort Oldest to Newest</Switch.Label>
|
||||
</Switch.Root>
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.volcanoSort}
|
||||
onCheckedChange={handleVolcanoSort}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Sort by Volcano</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
{/* SELECTING DATE RANGE */}
|
||||
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.startDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, startDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
|
||||
else setStartError("");
|
||||
}}
|
||||
/>
|
||||
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.endDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, endDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
|
||||
else setEndError("");
|
||||
}}
|
||||
/>
|
||||
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {Input, Field, Text, Flex} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function PlumeEventsSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
return (
|
||||
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.startDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, startDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
|
||||
else setStartError("");
|
||||
}}
|
||||
/>
|
||||
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.endDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, endDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
|
||||
else setEndError("");
|
||||
}}
|
||||
/>
|
||||
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import {Button, Field, Flex, Input, Switch, Text} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import {useState} from "react";
|
||||
|
||||
export default function RemoteSensingSettings({ widgetId, currentSettings }) {
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
updateSettings(widgetId, {copyToClipboard: true});
|
||||
}
|
||||
const handleVolcanoSort = (details) => {
|
||||
const isSort = currentSettings?.activeSorts?.volcanoSort === true;
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, volcanoSort: !isSort }});
|
||||
}
|
||||
const handleTimeSort = (details) => {
|
||||
const sort = currentSettings?.activeSorts?.timeSort === "desc" ? "asc" : "desc";
|
||||
updateSettings(widgetId, { activeSorts: { ...currentSettings?.activeSorts, timeSort: sort }});
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex flexDirection="column" justifyContent="space-between" gap={2} overflowY="auto">
|
||||
{/* COPYING TO CLIPBOARD */}
|
||||
<Button w="fit" size="xs" onClick={handleCopyToClipboard}>Copy All RS</Button>
|
||||
|
||||
{/* SORTING */}
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.timeSort === "asc"}
|
||||
onCheckedChange={handleTimeSort}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Sort Oldest to Newest</Switch.Label>
|
||||
</Switch.Root>
|
||||
<Switch.Root
|
||||
checked={currentSettings?.activeSorts?.volcanoSort}
|
||||
onCheckedChange={handleVolcanoSort}
|
||||
colorPalette="blue"
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label>Sort by Volcano</Switch.Label>
|
||||
</Switch.Root>
|
||||
|
||||
{/* SELECTING DATE RANGE */}
|
||||
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.startDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, startDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
|
||||
else setStartError("");
|
||||
}}
|
||||
/>
|
||||
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.endDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, endDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
|
||||
else setEndError("");
|
||||
}}
|
||||
/>
|
||||
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {Input, Field, Text, Flex} from "@chakra-ui/react";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function SeismicEventsSettings({ widgetId, currentSettings }) {
|
||||
const [startError, setStartError] = useState(null);
|
||||
const [endError, setEndError] = useState(null);
|
||||
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||
|
||||
return (
|
||||
<Flex direction="column" w="100%" h="100%" align="center" justify="center" gap="2">
|
||||
<Field.Root invalid={!!startError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
Start Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.startDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, startDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setStartError("Can't Choose a Future Date");
|
||||
else setStartError("");
|
||||
}}
|
||||
/>
|
||||
{startError && <Field.ErrorText>{startError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
<Field.Root invalid={!!endError}>
|
||||
<Flex direction="row" w="100%" h="100%" align="center" justify="center">
|
||||
<Text w="50%" align="center" justify="center" mt={2} color="gray.700">
|
||||
End Date
|
||||
</Text>
|
||||
<Flex direction="column" w="100%">
|
||||
<Input
|
||||
w="100%"
|
||||
type="date"
|
||||
value={currentSettings?.endDate || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
updateSettings(widgetId, {...currentSettings, endDate: value});
|
||||
|
||||
const dateSelected = new Date(value);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (value && (dateSelected > today)) setEndError("Can't Choose a Future Date");
|
||||
else setEndError("");
|
||||
}}
|
||||
/>
|
||||
{endError && <Field.ErrorText>{endError}</Field.ErrorText>}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Field.Root>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +1,375 @@
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import {useState, useMemo, useRef, useEffect, useCallback} from "react";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Box,
|
||||
Text,
|
||||
Flex,
|
||||
Grid,
|
||||
Dialog,
|
||||
Tabs,
|
||||
Badge,
|
||||
Menu,
|
||||
HStack
|
||||
Button,
|
||||
Box,
|
||||
Text,
|
||||
Flex,
|
||||
Grid,
|
||||
Dialog,
|
||||
Tabs,
|
||||
Badge,
|
||||
Menu,
|
||||
HStack,
|
||||
useRecipe,
|
||||
useSlotRecipe
|
||||
} from "@chakra-ui/react";
|
||||
import { Settings, Plus, LayoutGrid, Move, Save, X } from 'lucide-react';
|
||||
import { useDashboardStore } from "../../store/useDashboardStore.jsx";
|
||||
import { WIDGET_REGISTRY } from "../../constants/widgetRegistry.jsx";
|
||||
import {Settings, LayoutGrid, Move, Save, X, ChevronRight, ChevronLeft} from 'lucide-react';
|
||||
import {useDashboardStore} from "../../store/useDashboardStore.jsx";
|
||||
import {WIDGET_REGISTRY} from "../../constants/widgetRegistry.jsx";
|
||||
|
||||
// 🚨 NEW: Tiny helper to convert size keys into abbreviations
|
||||
const getSizeAbbreviation = (key) => {
|
||||
const mapping = {small: "S", medium: "M", large: "L", xlarge: "XL"};
|
||||
return mapping[key.toLowerCase()] || key.charAt(0).toUpperCase();
|
||||
};
|
||||
|
||||
export function SettingsButton() {
|
||||
// 1. ☁Store connections
|
||||
const isEditing = useDashboardStore(state => state.isEditing);
|
||||
const enterEditMode = useDashboardStore(state => state.enterEditMode);
|
||||
// 1. ☁Store connections
|
||||
const isEditing = useDashboardStore(state => state.isEditing);
|
||||
const enterEditMode = useDashboardStore(state => state.enterEditMode);
|
||||
const saveEdit = useDashboardStore(state => state.saveEdit);
|
||||
const cancelEdit = useDashboardStore(state => state.cancelEdit);
|
||||
const addWidget = useDashboardStore(state => state.addWidget);
|
||||
|
||||
// This now handles both local UI lock AND database saving!
|
||||
const saveEdit = useDashboardStore(state => state.saveEdit);
|
||||
const recipe = useRecipe({key: "glassTab"});
|
||||
const tabStyles = recipe();
|
||||
|
||||
const cancelEdit = useDashboardStore(state => state.cancelEdit);
|
||||
const addWidget = useDashboardStore(state => state.addWidget);
|
||||
// Pull the glass container styles for the dropdown and modal
|
||||
const panelRecipe = useSlotRecipe({key: "glassPanel"});
|
||||
const panelStyles = panelRecipe({layout: "dropdown"});
|
||||
const modalStyles = panelRecipe({layout: "popover"});
|
||||
|
||||
const [isLibraryOpen, setIsLibraryOpen] = useState(false);
|
||||
const [isLibraryOpen, setIsLibraryOpen] = useState(false);
|
||||
|
||||
// Smart Add Handler
|
||||
const handleAddWidget = (widgetId, sizeKey) => {
|
||||
enterEditMode();
|
||||
addWidget(widgetId, sizeKey);
|
||||
};
|
||||
// ⬅️ ➡️ This is the callbacks and scroll control to add the chevron arrows in the tab list
|
||||
const tabsRef = useRef(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const categorizedWidgets = useMemo(() => {
|
||||
return Object.entries(WIDGET_REGISTRY).reduce((acc, [id, widget]) => {
|
||||
const cat = widget.category || 'General';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push({ id, ...widget });
|
||||
return acc;
|
||||
}, {});
|
||||
}, []);
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const categories = Object.keys(categorizedWidgets);
|
||||
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]);
|
||||
|
||||
// use ref to capture html node for placing menu accordingly
|
||||
const menuRef = useRef(null);
|
||||
const getAnchorRect = () => {
|
||||
if (!menuRef.current) return null;
|
||||
return menuRef.current.getBoundingClientRect();
|
||||
};
|
||||
// Smart Add Handler
|
||||
const handleAddWidget = (widgetId, sizeKey) => {
|
||||
enterEditMode();
|
||||
addWidget(widgetId, sizeKey);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* THE STATIC DROPDOWN MENU */}
|
||||
<Menu.Root positioning={{ getAnchorRect }}>
|
||||
<Menu.Trigger asChild>
|
||||
<IconButton
|
||||
ref={menuRef}
|
||||
aria-label="Settings"
|
||||
bg="vdap.darkGreen"
|
||||
color="white"
|
||||
size="sm"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: "vdap.darkGreenHover" }}
|
||||
const categorizedWidgets = useMemo(() => {
|
||||
return Object.entries(WIDGET_REGISTRY).reduce((acc, [id, widget]) => {
|
||||
const cat = widget.category || 'General';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push({id, ...widget});
|
||||
return acc;
|
||||
}, {});
|
||||
}, []);
|
||||
|
||||
const categories = Object.keys(categorizedWidgets);
|
||||
|
||||
// use ref to capture html node for placing menu accordingly
|
||||
const menuRef = useRef(null);
|
||||
const getAnchorRect = () => {
|
||||
if (!menuRef.current) return null;
|
||||
return menuRef.current.getBoundingClientRect();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isEditing ? (
|
||||
// 📝 EDIT MODE: The 3-Pill Layout
|
||||
<HStack gap={2}>
|
||||
{/* 1. The Add Widget Button */}
|
||||
<Box
|
||||
as="button"
|
||||
css={tabStyles}
|
||||
data-state="open"
|
||||
onClick={() => setIsLibraryOpen(true)}
|
||||
color={{base: "blue.500", _dark: "blue.300"}}
|
||||
_hover={{bg: "rgba(66, 153, 225, 0.1)"}}
|
||||
>
|
||||
<LayoutGrid size={20} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Widget Library</Box>
|
||||
</Box>
|
||||
|
||||
{/* 2. The Save Button */}
|
||||
<Box
|
||||
as="button"
|
||||
css={tabStyles}
|
||||
data-state="open"
|
||||
onClick={saveEdit}
|
||||
color={{base: "green.600", _dark: "green.400"}}
|
||||
_hover={{bg: "rgba(72, 187, 120, 0.1)"}}
|
||||
>
|
||||
<Save size={20} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Save Layout</Box>
|
||||
</Box>
|
||||
|
||||
{/* 3. The Discard Button */}
|
||||
<Box
|
||||
as="button"
|
||||
css={tabStyles}
|
||||
data-state="open"
|
||||
onClick={cancelEdit}
|
||||
color={{base: "red.500", _dark: "red.400"}}
|
||||
_hover={{bg: "rgba(245, 101, 101, 0.1)"}}
|
||||
>
|
||||
<X size={20} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Discard</Box>
|
||||
</Box>
|
||||
</HStack>
|
||||
) : (
|
||||
// 👀 VIEW MODE: The Static Dropdown Menu
|
||||
<Menu.Root positioning={{getAnchorRect}}>
|
||||
<Menu.Trigger asChild>
|
||||
<Box ref={menuRef} as="button" css={tabStyles}>
|
||||
{/* size increased to 24 to match tabs. flexShrink: 0 is CRITICAL */}
|
||||
<Settings size={24} style={{flexShrink: 0}}/>
|
||||
<Box as="span">Dashboard Settings</Box>
|
||||
</Box>
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Positioner>
|
||||
<Menu.Content css={panelStyles.root} zIndex="dropdown" minW="200px">
|
||||
<Menu.Item
|
||||
onClick={enterEditMode}
|
||||
cursor="pointer"
|
||||
_hover={{WebkitTextStroke: "0.5px currentColor"}}
|
||||
>
|
||||
<Move size={16} style={{marginRight: '8px'}}/> Edit Layout
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Separator my={1} borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}/>
|
||||
|
||||
<Menu.Item
|
||||
onClick={() => setIsLibraryOpen(true)}
|
||||
cursor="pointer"
|
||||
_hover={{
|
||||
bg: {base: "rgba(0, 0, 0, 0.05)", _dark: "rgba(255, 255, 255, 0.1)"},
|
||||
WebkitTextStroke: "0.5px currentColor"
|
||||
}}
|
||||
>
|
||||
<LayoutGrid size={16} style={{marginRight: '8px'}}/> Widget Library
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Menu.Root>
|
||||
)}
|
||||
|
||||
{/* 📚📚📚 THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
|
||||
<Dialog.Root
|
||||
open={isLibraryOpen}
|
||||
onOpenChange={(e) => setIsLibraryOpen(e.open)}
|
||||
size="xl"
|
||||
placement="center"
|
||||
closeOnInteractOutside={true}
|
||||
closeOnEscapeKeyDown={true}
|
||||
>
|
||||
{/* Soft blur can be added to the background behind the modal */}
|
||||
{/*<Dialog.Backdrop bg="blackAlpha.100" backdropFilter="blur(2px)"/>*/}
|
||||
<Dialog.Positioner>
|
||||
|
||||
<Dialog.Content css={modalStyles.root} overflow="hidden" maxW="60%" minH="80%" p={0}>
|
||||
|
||||
<Dialog.Header bg="transparent" color={{base: "gray.800", _dark: "white"}} py={4}
|
||||
borderColor={{base: "gray.200", _dark: "whiteAlpha.200"}}>
|
||||
<Dialog.Title fontSize="xl" fontWeight="bold">Widgets Shop</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<Dialog.Body p={0}>
|
||||
<Tabs.Root defaultValue={categories[0]} variant="unstyled">
|
||||
<Box position="relative" display="flex" mx="auto" maxWidth="100%" mx={5} mt={4} mb={6}>
|
||||
|
||||
{/* LEFT GLASS CRESCENT */}
|
||||
{canScrollLeft && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
left="0" top="0" bottom="0"
|
||||
w="50px"
|
||||
align="center" justify="center"
|
||||
zIndex={2}
|
||||
cursor="pointer"
|
||||
onClick={() => tabsRef.current.scrollBy({left: -200, behavior: 'smooth'})}
|
||||
bg={{base: "rgba(255, 255, 255, 0.7)", _dark: "rgba(30, 30, 30, 0.8)"}}
|
||||
backdropFilter="blur(1px)"
|
||||
borderTopLeftRadius="full"
|
||||
borderBottomLeftRadius="full"
|
||||
|
||||
// THE MAGIC: Bites a transparent circle out of the flat right side!
|
||||
WebkitMaskImage="radial-gradient(circle at 100% 50%, transparent 30px, black 19px)"
|
||||
maskImage="radial-gradient(circle at 100% 50%, transparent 30px, black 35px)"
|
||||
|
||||
color={{base: "gray.600", _dark: "gray.300"}}
|
||||
transition="background-color 0.2s"
|
||||
_hover={{
|
||||
color: {base: "blue.500", _dark: "blue.300"},
|
||||
bg: {base: "rgba(255, 255, 255, 0.9)", _dark: "rgba(40, 40, 40, 0.95)"}
|
||||
}}
|
||||
>
|
||||
<Settings size={18} />
|
||||
</IconButton>
|
||||
</Menu.Trigger>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content bg="white" boxShadow="lg" borderRadius="md" p={1} zIndex="dropdown">
|
||||
{isEditing ? (
|
||||
<>
|
||||
{/* WIRE DIRECTLY TO saveEdit */}
|
||||
<Menu.Item onClick={saveEdit} color="green.600" fontWeight="bold" cursor="pointer" _hover={{ bg: "green.50" }}>
|
||||
<Save size={16} style={{ marginRight: '8px' }} /> Save Layout
|
||||
</Menu.Item>
|
||||
{/* Shifted slightly left to optically center it in the crescent mass */}
|
||||
<ChevronLeft size={18} style={{ marginLeft: "-30px" }}/>
|
||||
</Flex>
|
||||
)}
|
||||
<Tabs.List
|
||||
ref={tabsRef}
|
||||
onScroll={checkScroll}
|
||||
bg={{base: "rgba(0, 0, 0, 0.04)", _dark: "rgba(255, 255, 255, 0.05)"}}
|
||||
p={1}
|
||||
borderRadius="full"
|
||||
display="flex"
|
||||
flexWrap="nowrap"
|
||||
overflowX="auto"
|
||||
maxWidth="100%"
|
||||
border="1px solid"
|
||||
borderColor={{base: "blackAlpha.50", _dark: "whiteAlpha.100"}}
|
||||
css={{
|
||||
scrollbarWidth: "none",
|
||||
"&::-webkit-scrollbar": {display: "none"},
|
||||
}}
|
||||
// This is for desktop users not on trackpad to scroll the
|
||||
onWheel={(e) => {
|
||||
if (e.currentTarget) {
|
||||
e.currentTarget.scrollLeft += e.deltaY;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<Tabs.Trigger
|
||||
key={cat}
|
||||
value={cat}
|
||||
px={5}
|
||||
py={2}
|
||||
borderRadius="full"
|
||||
whiteSpace="nowrap"
|
||||
flexShrink={0}
|
||||
color={{base: "gray.500", _dark: "gray.400"}}
|
||||
fontWeight="medium"
|
||||
transition="background-color 0.2s, color 0.2s, box-shadow 0.2s"
|
||||
_hover={{
|
||||
color: {base: "gray.800", _dark: "gray.200"}
|
||||
}}
|
||||
_selected={{
|
||||
bg: {base: "white", _dark: "rgba(255, 255, 255, 0.12)"},
|
||||
color: {base: "blue.600", _dark: "blue.200"},
|
||||
boxShadow: "sm",
|
||||
fontWeight: "bold",
|
||||
}}
|
||||
>
|
||||
{cat}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{canScrollRight && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
right="0" top="0" bottom="0"
|
||||
w="50px"
|
||||
align="center" justify="center"
|
||||
zIndex={2}
|
||||
cursor="pointer"
|
||||
onClick={() => tabsRef.current.scrollBy({left: 200, behavior: 'smooth'})}
|
||||
bg={{base: "rgba(255, 255, 255, 0.7)", _dark: "rgba(30, 30, 30, 0.8)"}}
|
||||
backdropFilter="blur(1px)"
|
||||
borderTopRightRadius="full"
|
||||
borderBottomRightRadius="full"
|
||||
|
||||
<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" }}>
|
||||
<Move size={16} style={{ marginRight: '8px' }} /> Edit Layout
|
||||
</Menu.Item>
|
||||
)}
|
||||
// THE MAGIC: Bites a transparent circle out of the flat left side!
|
||||
WebkitMaskImage="radial-gradient(circle at 0% 50%, transparent 30px, black 19px)"
|
||||
maskImage="radial-gradient(circle at 0% 50%, transparent 30px, black 35px)"
|
||||
|
||||
<Menu.Separator my={1} borderColor="gray.200" />
|
||||
color={{base: "gray.600", _dark: "gray.300"}}
|
||||
transition="background-color 0.2s"
|
||||
_hover={{
|
||||
color: {base: "blue.500", _dark: "blue.300"},
|
||||
bg: {base: "rgba(255, 255, 255, 0.9)", _dark: "rgba(40, 40, 40, 0.95)"}
|
||||
}}
|
||||
>
|
||||
{/* Shifted slightly right to optically center it in the crescent mass */}
|
||||
<ChevronRight size={18} style={{ marginRight: "-30px" }}/>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Menu.Item onClick={() => setIsLibraryOpen(true)} cursor="pointer" _hover={{ bg: "gray.100" }}>
|
||||
<LayoutGrid size={16} style={{ marginRight: '8px' }} /> Widget Library
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Menu.Root>
|
||||
</Box>
|
||||
{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={{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>
|
||||
|
||||
{/* 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" />
|
||||
<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.Header>
|
||||
<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 => (
|
||||
<Box
|
||||
key={sizeKey}
|
||||
as="button"
|
||||
css={tabStyles}
|
||||
onClick={() => handleAddWidget(widget.id, sizeKey)}
|
||||
color={{base: "blue.600", _dark: "blue.300"}}
|
||||
>
|
||||
{/* THE ABBREVIATION ICON: Always visible */}
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
w="24px"
|
||||
h="24px"
|
||||
flexShrink={0}
|
||||
borderRadius="full"
|
||||
bg={{base: "blue.50", _dark: "whiteAlpha.200"}}
|
||||
fontWeight="black"
|
||||
fontSize="xs"
|
||||
bg='transparent'
|
||||
>
|
||||
{getSizeAbbreviation(sizeKey)}
|
||||
</Flex>
|
||||
|
||||
<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">
|
||||
{categories.map(cat => (
|
||||
<Tabs.Trigger key={cat} value={cat} pb={2}>{cat}</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
|
||||
{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">
|
||||
<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>
|
||||
<HStack gap={2} flexWrap="wrap">
|
||||
{Object.keys(widget.allowedSizes).map(sizeKey => (
|
||||
<Badge
|
||||
key={sizeKey}
|
||||
as="button"
|
||||
onClick={() => handleAddWidget(widget.id, sizeKey)}
|
||||
colorScheme="gray"
|
||||
variant="subtle"
|
||||
px={3}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: "blue.50", color: "blue.600", transform: "scale(1.05)" }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Plus size={12} style={{ display: 'inline', marginRight: '4px' }}/>
|
||||
{sizeKey.charAt(0).toUpperCase() + sizeKey.slice(1)}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
))}
|
||||
</Grid>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
</Dialog.Body>
|
||||
<Dialog.CloseTrigger position="absolute" top={3} right={3} color="white" />
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Dialog.Root>
|
||||
</>
|
||||
);
|
||||
{/* THE EXPANDED TEXT: Only visible on hover */}
|
||||
<Box as="span">Add Widget</Box>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
))}
|
||||
</Grid>
|
||||
</Tabs.Content>
|
||||
))}
|
||||
</Tabs.Root>
|
||||
</Dialog.Body>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { useRecipe, Button, Stack, 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";
|
||||
@@ -13,25 +14,19 @@ export default function Header() {
|
||||
|
||||
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} >
|
||||
<SearchBar placeholder="Enter a volcano to search" size="sm" width="410px" />
|
||||
{/* 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>
|
||||
<Text fontWeight="bold" textStyle="md" >{displayName}</Text>
|
||||
<UserAvatarMenu
|
||||
name={displayName}
|
||||
name={avatarname}
|
||||
// avatar={user.avatar}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useSlotRecipe, Box, Button, IconButton } from "@chakra-ui/react";
|
||||
import { ColorModeButton } from "@/components/ui/color-mode.jsx";
|
||||
import { useState } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {useSlotRecipe, Box, Button, IconButton} from "@chakra-ui/react";
|
||||
import {ColorModeButton} from "@/components/ui/color-mode.jsx";
|
||||
import {useState} from "react";
|
||||
import {NavLink} from "react-router-dom";
|
||||
import Tooltip from "../ui/Tooltip.jsx";
|
||||
import { Mountain, House, Earth, MapPin, ShieldUser, Menu } from 'lucide-react';
|
||||
import {Mountain, House, Earth, MapPin, ShieldUser, Menu, PanelLeftClose, PanelLeftOpen} from 'lucide-react';
|
||||
|
||||
const SIDEBAR_DEFAULT_BUTTONS = [
|
||||
{ icon: House, name: "Home", url: "/home" },
|
||||
{ icon: Earth, name: "Global Map", url: "/global-map" },
|
||||
{ icon: MapPin, name: "Regional Map", url: "/regional-map" },
|
||||
{ icon: Mountain, name: "Volcano", url: "/volcano" },
|
||||
{ icon: ShieldUser, name: "Admin", url: "/admin" } // TODO remove in the future as default should not have admin
|
||||
{icon: House, name: "Home", url: "/home"},
|
||||
{icon: Earth, name: "Global Map", url: "/global-map"},
|
||||
{icon: MapPin, name: "Regional Map", url: "/regional-map"},
|
||||
{icon: Mountain, name: "Volcano", url: "/volcano"},
|
||||
{icon: ShieldUser, name: "Admin", url: "/admin"} // TODO remove in the future as default should not have admin
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const recipe = useSlotRecipe({ key: "sidebar" });
|
||||
const styles = recipe();
|
||||
|
||||
const recipe = useSlotRecipe({key: "sidebar"});
|
||||
const styles = recipe({state: isOpen ? "open" : "closed"});
|
||||
|
||||
// TODO we will need to call buttons here by authorized user, for now we update the visible buttons as simply the default buttons
|
||||
const visibleButtons = SIDEBAR_DEFAULT_BUTTONS;
|
||||
@@ -24,51 +26,50 @@ export default function Sidebar() {
|
||||
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}
|
||||
<Box css={styles.root}>
|
||||
{/* Home Link Logo */}
|
||||
<Button
|
||||
as={NavLink}
|
||||
to="/home"
|
||||
css={styles.logo}
|
||||
variant="plain"
|
||||
>
|
||||
{isOpen ? "🌋 VDAP" : "🌋"}
|
||||
</Button>
|
||||
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
{visibleButtons.map((button) => {
|
||||
const Icon = button.icon;
|
||||
return (
|
||||
<Button
|
||||
as={NavLink}
|
||||
to={button.url}
|
||||
css={styles.buttons}
|
||||
>
|
||||
{/* Navigation buttons are [icon text] */}
|
||||
<Icon/>
|
||||
<Box as="span" css={styles.button_text}>
|
||||
{button.name}
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Box css={styles.footer}>
|
||||
{/* Menu Trigger */}
|
||||
<IconButton aria-label="Toggle Sidebar" variant="plain" size="md" onClick={toggleSidebar} width="54px" alignSelf="flex-start"
|
||||
<IconButton
|
||||
aria-label="Toggle Sidebar"
|
||||
variant="plain"
|
||||
size="md"
|
||||
onClick={toggleSidebar}
|
||||
css={styles.collapse_button}
|
||||
>
|
||||
<Menu />
|
||||
{isOpen ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||
</IconButton>
|
||||
|
||||
{/* 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.name}
|
||||
</Box>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Theme Toggler */}
|
||||
<ColorModeButton
|
||||
position="absolute"
|
||||
bottom="4"
|
||||
left="4"
|
||||
/>
|
||||
{/* Theme Toggler */}
|
||||
<ColorModeButton _hover={{ bg: "transparent" }} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -7,9 +7,6 @@ import { lazy } from 'react';
|
||||
// w: 4 = one-third screen width
|
||||
// w: 3 = one-quarter screen width
|
||||
|
||||
const getStartDate = () => (new Date(Date.now() - 604800000)).toLocaleDateString('en-CA'); // 7 days = 604800000 = 7*24*60*60*1000
|
||||
const getEndDate = () => (new Date()).toLocaleDateString('en-CA'); // 'en-CA' formats the date as YYYY-MM-DD
|
||||
|
||||
export const WIDGET_REGISTRY = {
|
||||
"vaa_activity": {
|
||||
name: "VAA 7 Day Activity",
|
||||
@@ -22,9 +19,7 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
// The baseline data every new VAA widget starts with
|
||||
defaultSettings: {
|
||||
// startDate: getStartDate(),
|
||||
// endDate: getEndDate(),
|
||||
yAxisScale: 'normal', // Options: 'normal' | 'symlog'
|
||||
yAxisScale: 'normal' // Options: 'normal' | 'symlog'
|
||||
},
|
||||
|
||||
allowedSizes: {
|
||||
@@ -46,21 +41,9 @@ export const WIDGET_REGISTRY = {
|
||||
name: "IVANS",
|
||||
category: "Communications",
|
||||
description: "Feed of recent IVANS reports and reviewer statuses.",
|
||||
component: lazy(() => import('../components/Widgets/IvansAlerts.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
copyToClipboard: false,
|
||||
activeSorts: {
|
||||
timeSort: "desc",
|
||||
volcanoSort: false
|
||||
}
|
||||
},
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 6 },
|
||||
medium: { w: 3, h: 9 },
|
||||
large: { w: 3, h: 12 },
|
||||
small: { w: 3, h: 12 },
|
||||
medium: { w: 6, h: 24 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -68,21 +51,8 @@ export const WIDGET_REGISTRY = {
|
||||
name: "Remote Sensing",
|
||||
category: "Remote Sensing",
|
||||
description: "Feed of recent remote sensing reports.",
|
||||
component: lazy(() => import('../components/Widgets/RemoteSensing.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/Settings/RemoteSensingSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
copyToClipboard: false,
|
||||
activeSorts: {
|
||||
timeSort: "desc",
|
||||
volcanoSort: false
|
||||
}
|
||||
},
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 6 },
|
||||
medium: { w: 3, h: 9 },
|
||||
large: { w: 3, h: 12 },
|
||||
small: { w: 3, h: 12 },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -90,15 +60,9 @@ export const WIDGET_REGISTRY = {
|
||||
name: "Alert Level Changes",
|
||||
category: "Daily Alerts",
|
||||
description: "List of 7-day alert level changes with trend arrows.",
|
||||
component: lazy(() => import('../components/Widgets/AlertChanges.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/AlertChangeSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
},
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 6 },
|
||||
large: { w: 3, h: 12 }
|
||||
small: { w: 3, h: 12 },
|
||||
large: { w: 9, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -114,39 +78,7 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
// The baseline data every new VAA widget starts with
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
yAxisScale: 'normal' // Options: 'normal' | 'symlog'
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
"plume_events": {
|
||||
name: "Plume Events",
|
||||
category: "Daily Volcanic Activity",
|
||||
description: "Plot of explosion heights over a weeks time.",
|
||||
component: lazy(() => import('../components/Widgets/PlumeEvents.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/PlumeEventsSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
}
|
||||
},
|
||||
|
||||
"seismic_events": {
|
||||
name: "Seismic Events",
|
||||
category: "Daily Volcanic Activity",
|
||||
description: "Plot of number of seismic events over a weeks time.",
|
||||
component: lazy(() => import('../components/Widgets/SeismicEvents.jsx')),
|
||||
settingsComponent: lazy(() => import('../components/Widgets/settings/SeismicEventsSettings.jsx')),
|
||||
defaultSettings: {
|
||||
startDate: getStartDate(),
|
||||
endDate: getEndDate(),
|
||||
yAxisScale: 'log' // Options: 'normal' | 'symlog'
|
||||
},
|
||||
allowedSizes: {
|
||||
medium: { w: 6, h: 12 }
|
||||
@@ -156,7 +88,8 @@ export const WIDGET_REGISTRY = {
|
||||
"llm_al_changes_quad_plot": {
|
||||
name: "Alert Level Changes Quad Plot",
|
||||
category: "Computer Science",
|
||||
description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
|
||||
description: "Percentage of True-Positive, True-Negative, False-Positive, and False-Negative changes in all " +
|
||||
"alert levels.",
|
||||
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 12 }
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export default function useBuildUrl(base_url, start, end) {
|
||||
return useMemo(() =>{
|
||||
if (start && end)
|
||||
return `${base_url}?start_date=${start}&end_date=${end}`;
|
||||
else {
|
||||
let today = new Date();
|
||||
let sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
||||
sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA');
|
||||
today = today.toLocaleDateString('en-CA');
|
||||
return `${base_url}?start_date=${sevenDaysAgo}&end_date=${today}`;
|
||||
}
|
||||
}, [base_url, start, end]);
|
||||
}
|
||||
@@ -1,64 +1,69 @@
|
||||
import { HStack, Flex, Box } from "@chakra-ui/react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { SearchBar } from "../../components/dashboard/SearchBar.jsx";
|
||||
import { SettingsButton } from "../../components/dashboard/SettingsButton.jsx";
|
||||
import {HStack, Flex, Box, useRecipe} 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 recipe = useRecipe({key: "glassTab"});
|
||||
const tabStyles = recipe();
|
||||
|
||||
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 direction="column" height="100%" width="100%">
|
||||
|
||||
{/* 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%">
|
||||
<Flex as="header" position="sticky" top="0" zIndex="dropdown" bg="transparent" 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>
|
||||
</Flex>
|
||||
{/* TABS CONTAINER */}
|
||||
<Flex gap={2} alignItems="center">
|
||||
{HOME_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<Box
|
||||
key={tab.name}
|
||||
as={NavLink}
|
||||
to={tab.url}
|
||||
end={tab.exact} // CRITICAL: Forces exact match so "/home" deactivates on sub-routes
|
||||
css={tabStyles}
|
||||
>
|
||||
{/* flexShrink: 0 ensures the icon never squishes during the gooey animation */}
|
||||
<Icon size={24} style={{flexShrink: 0}}/>
|
||||
<Box as="span">{tab.name}</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* EDIT MODE BUTTONS & CONTROLS */}
|
||||
<HStack width="258px" justifyContent="flex-end">
|
||||
{isPersonalDashboard ? (
|
||||
<>
|
||||
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
|
||||
{/* This will eventually trigger your Zustand store's edit mode! */}
|
||||
<SettingsButton />
|
||||
</>
|
||||
) : (
|
||||
<SearchBar placeholder="Enter a volcano to search" size="sm" width="210px" />
|
||||
)}
|
||||
</HStack>
|
||||
{/* The Settings button is now inline with the tabs and only renders on the personal dashboard */}
|
||||
{isPersonalDashboard && <SettingsButton/>}
|
||||
|
||||
</Flex>
|
||||
|
||||
{/* 3. 🛠️ CANVAS AREA */}
|
||||
<Flex flex="1" overflow="auto">
|
||||
<Outlet />
|
||||
</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">
|
||||
{/* Future global controls go here */}
|
||||
</HStack>
|
||||
|
||||
</Flex>
|
||||
|
||||
<Flex flex="1" overflow="auto">
|
||||
<Outlet/>
|
||||
</Flex>
|
||||
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Box, Text, Flex, IconButton, Spinner, Popover} from "@chakra-ui/react";
|
||||
import {Box, Text, Flex, IconButton, Spinner, Popover, useSlotRecipe} from "@chakra-ui/react";
|
||||
import {X, MoreVertical} from "lucide-react";
|
||||
import {Responsive, WidthProvider} from "react-grid-layout";
|
||||
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||
import {Suspense, useEffect, useRef} from 'react';
|
||||
import {useColorModeValue} from "@/components/ui/color-mode";
|
||||
|
||||
// 1. Import database messenger
|
||||
import useAuthStore from "@/store/authStore";
|
||||
|
||||
@@ -14,11 +14,6 @@ import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx';
|
||||
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");
|
||||
|
||||
// ☁️ Database Connection
|
||||
const {user} = useAuthStore();
|
||||
@@ -33,9 +28,11 @@ export default function MyDashboard() {
|
||||
// 🔒 Safety lock to prevent infinite re-renders
|
||||
const hasHydrated = useRef(false);
|
||||
|
||||
// ==========================================
|
||||
// INITIAL LOAD: Grab data from Django
|
||||
// ==========================================
|
||||
// 1. Pull the new global slot recipe
|
||||
const recipe = useSlotRecipe({key: "glassPanel"});
|
||||
const styles = recipe({ layout: "widget" });
|
||||
const popoverStyles = recipe({ layout: "popover" });
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
|
||||
const savedLayout = user.preferences.dashboard_layout;
|
||||
@@ -45,7 +42,6 @@ export default function MyDashboard() {
|
||||
if (Array.isArray(savedLayout)) {
|
||||
setWidgets(savedLayout);
|
||||
}
|
||||
|
||||
hasHydrated.current = true;
|
||||
}
|
||||
}, [user, setWidgets]);
|
||||
@@ -72,17 +68,9 @@ 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={styles.header}>
|
||||
<Text color="titleColor" fontWeight="semibold" fontSize="md" isTruncated>
|
||||
{registryEntry.name}
|
||||
</Text>
|
||||
|
||||
@@ -94,6 +82,7 @@ export default function MyDashboard() {
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
borderRadius="xl"
|
||||
_hover={{color: "white", bg: "gray.700"}}
|
||||
aria-label="Widget Settings"
|
||||
>
|
||||
@@ -103,7 +92,7 @@ export default function MyDashboard() {
|
||||
|
||||
{/* 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.Content css={popoverStyles.root}>
|
||||
<Suspense fallback={<Spinner size="sm"/>}>
|
||||
<SettingsUI
|
||||
widgetId={widget.id}
|
||||
@@ -116,22 +105,18 @@ export default function MyDashboard() {
|
||||
) : (
|
||||
<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={styles.body}>
|
||||
<Component settings={widget.settings || {}}/>
|
||||
</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={styles.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>
|
||||
@@ -155,12 +140,12 @@ export default function MyDashboard() {
|
||||
{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"
|
||||
css={styles.root}
|
||||
// 3. Grid-specific state overrides remain inline
|
||||
boxShadow={isEditing ? "outline" : styles.root.boxShadow}
|
||||
borderStyle={isEditing ? "dashed" : "solid"}
|
||||
borderWidth={isEditing ? "2px" : "1px"}
|
||||
borderColor={isEditing ? "orange.300" : styles.root.borderColor}
|
||||
cursor={isEditing ? "grab" : "default"}
|
||||
position="relative" // 3. REQUIRED so the absolute button stays inside this box
|
||||
>
|
||||
@@ -173,8 +158,9 @@ export default function MyDashboard() {
|
||||
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}
|
||||
@@ -188,13 +174,12 @@ export default function MyDashboard() {
|
||||
|
||||
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
|
||||
<Suspense fallback={
|
||||
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
|
||||
<Flex css={styles.body} align="center" justify="center">
|
||||
<Spinner size="md" color="blue.400" thickness="3px"/>
|
||||
</Flex>
|
||||
}>
|
||||
{renderWidget(widget)}
|
||||
</Suspense>
|
||||
|
||||
</Box>
|
||||
))}
|
||||
</ResponsiveGridLayout>
|
||||
|
||||
@@ -1,110 +1,109 @@
|
||||
import {create} from 'zustand';
|
||||
import {v4 as uuid} from 'uuid';
|
||||
import {WIDGET_REGISTRY} from '../constants/widgetRegistry.jsx';
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { WIDGET_REGISTRY } from '../constants/widgetRegistry.jsx';
|
||||
|
||||
// 1. Import your database messenger
|
||||
import useAuthStore from './authStore';
|
||||
|
||||
export const useDashboardStore = create((set, get) => ({
|
||||
// ==========================================
|
||||
// 1. STATE (The Data)
|
||||
// ==========================================
|
||||
isEditing: false,
|
||||
widgets: [], // The master array of what is currently on the screen
|
||||
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
|
||||
|
||||
// ==========================================
|
||||
// 2. ACTIONS (The Logic)
|
||||
// ==========================================
|
||||
|
||||
enterEditMode: () => set({
|
||||
isEditing: true,
|
||||
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
|
||||
}),
|
||||
|
||||
cancelEdit: () => set({
|
||||
// ==========================================
|
||||
// 1. STATE (The Data)
|
||||
// ==========================================
|
||||
isEditing: false,
|
||||
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
|
||||
}),
|
||||
widgets: [], // The master array of what is currently on the screen
|
||||
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
|
||||
|
||||
saveEdit: async () => {
|
||||
// 1. Grab the exact current state of the grid
|
||||
const currentLayout = get().widgets;
|
||||
// ==========================================
|
||||
// 2. ACTIONS (The Logic)
|
||||
// ==========================================
|
||||
|
||||
// 2. Lock the UI immediately so the user feels zero lag
|
||||
set({isEditing: false, backupWidgets: []});
|
||||
enterEditMode: () => set({
|
||||
isEditing: true,
|
||||
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
|
||||
}),
|
||||
|
||||
// 3. Reach outside this store and trigger the Django API call!
|
||||
try {
|
||||
await useAuthStore.getState().saveDashboardLayout(currentLayout);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync layout to MariaDB:", error);
|
||||
// If the database save fails, you could technically revert the UI here if needed
|
||||
}
|
||||
},
|
||||
cancelEdit: () => set({
|
||||
isEditing: false,
|
||||
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
|
||||
}),
|
||||
|
||||
updateWidgetSettings: (id, newSettings) => set((state) => {
|
||||
// console.log("2. Zustand updating widget:", id, "with:", newSettings);
|
||||
return {
|
||||
widgets: state.widgets.map(widget =>
|
||||
widget.id === id
|
||||
? {...widget, settings: {...widget.settings, ...newSettings}}
|
||||
: widget
|
||||
)
|
||||
};
|
||||
}),
|
||||
saveEdit: async () => {
|
||||
// 1. Grab the exact current state of the grid
|
||||
const currentLayout = get().widgets;
|
||||
|
||||
addWidget: (widgetId, sizeKey) => {
|
||||
const registryEntry = WIDGET_REGISTRY[widgetId];
|
||||
// 2. Lock the UI immediately so the user feels zero lag
|
||||
set({ isEditing: false, backupWidgets: [] });
|
||||
|
||||
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
|
||||
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
|
||||
return;
|
||||
}
|
||||
// 3. Reach outside this store and trigger the Django API call!
|
||||
try {
|
||||
await useAuthStore.getState().saveDashboardLayout(currentLayout);
|
||||
} catch (error) {
|
||||
console.error("Failed to sync layout to MariaDB:", error);
|
||||
// If the database save fails, you could technically revert the UI here if needed
|
||||
}
|
||||
},
|
||||
|
||||
const dimensions = registryEntry.allowedSizes[sizeKey];
|
||||
const widgets = get().widgets;
|
||||
updateWidgetSettings: (id, newSettings) => set((state) => {
|
||||
console.log("2. Zustand updating widget:", id, "with:", newSettings);
|
||||
return {
|
||||
widgets: state.widgets.map(widget =>
|
||||
widget.id === id
|
||||
? { ...widget, settings: { ...widget.settings, ...newSettings } }
|
||||
: widget
|
||||
)
|
||||
};
|
||||
}),
|
||||
|
||||
let startX = 0;
|
||||
let startY = Infinity;
|
||||
addWidget: (widgetId, sizeKey) => {
|
||||
const registryEntry = WIDGET_REGISTRY[widgetId];
|
||||
|
||||
if (widgets.length > 0) {
|
||||
const bottomRowY = Math.max(...widgets.map(w => w.y));
|
||||
const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
|
||||
const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
|
||||
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
|
||||
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rightmostEdge + dimensions.w <= 12) {
|
||||
startX = rightmostEdge; // ...tuck it right next to the last widget!
|
||||
startY = bottomRowY; // ...and keep it on the same row.
|
||||
}
|
||||
}
|
||||
const newWidget = {
|
||||
id: uuid(),
|
||||
type: widgetId,
|
||||
size: sizeKey,
|
||||
settings: registryEntry.defaultSettings,
|
||||
x: startX,
|
||||
y: startY,
|
||||
w: dimensions.w,
|
||||
h: dimensions.h
|
||||
};
|
||||
console.log("newWidget: ", newWidget);
|
||||
set((state) => ({widgets: [...state.widgets, newWidget]}));
|
||||
},
|
||||
const dimensions = registryEntry.allowedSizes[sizeKey];
|
||||
const widgets = get().widgets;
|
||||
|
||||
removeWidget: (id) => set((state) => ({
|
||||
widgets: state.widgets.filter(w => w.id !== id)
|
||||
})),
|
||||
let startX = 0;
|
||||
let startY = Infinity;
|
||||
|
||||
updateLayoutPositions: (rglLayout) => {
|
||||
set((state) => ({
|
||||
widgets: state.widgets.map(widget => {
|
||||
const gridItem = rglLayout.find(l => l.i === widget.id);
|
||||
return gridItem ? {...widget, x: gridItem.x, y: gridItem.y} : widget;
|
||||
})
|
||||
}));
|
||||
},
|
||||
if (widgets.length > 0) {
|
||||
const bottomRowY = Math.max(...widgets.map(w => w.y));
|
||||
const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
|
||||
const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
|
||||
|
||||
// NEW: Action to hydrate the store on initial page load
|
||||
setWidgets: (savedWidgets) => set({widgets: savedWidgets || []})
|
||||
if (rightmostEdge + dimensions.w <= 12) {
|
||||
startX = rightmostEdge; // ...tuck it right next to the last widget!
|
||||
startY = bottomRowY; // ...and keep it on the same row.
|
||||
}
|
||||
}
|
||||
const newWidget = {
|
||||
id: uuid(),
|
||||
type: widgetId,
|
||||
size: sizeKey,
|
||||
x: startX,
|
||||
y: startY,
|
||||
w: dimensions.w,
|
||||
h: dimensions.h
|
||||
};
|
||||
|
||||
set((state) => ({ widgets: [...state.widgets, newWidget] }));
|
||||
},
|
||||
|
||||
removeWidget: (id) => set((state) => ({
|
||||
widgets: state.widgets.filter(w => w.id !== id)
|
||||
})),
|
||||
|
||||
updateLayoutPositions: (rglLayout) => {
|
||||
set((state) => ({
|
||||
widgets: state.widgets.map(widget => {
|
||||
const gridItem = rglLayout.find(l => l.i === widget.id);
|
||||
return gridItem ? { ...widget, x: gridItem.x, y: gridItem.y } : widget;
|
||||
})
|
||||
}));
|
||||
},
|
||||
|
||||
// NEW: Action to hydrate the store on initial page load
|
||||
setWidgets: (savedWidgets) => set({ widgets: savedWidgets || [] })
|
||||
}));
|
||||
+312
-53
@@ -1,14 +1,12 @@
|
||||
import { defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe } from "@chakra-ui/react";
|
||||
import {defineSlotRecipe, createSystem, defaultConfig, defineConfig, defineRecipe} from "@chakra-ui/react";
|
||||
|
||||
// RECIPES
|
||||
const headerRecipe = defineRecipe({
|
||||
base: {
|
||||
bg: "{base200}",
|
||||
bg: "{transparent}",
|
||||
color: "text",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderBottom: "1px solid",
|
||||
borderBottomColor:"{border}",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
@@ -16,48 +14,177 @@ const headerRecipe = defineRecipe({
|
||||
}
|
||||
});
|
||||
|
||||
const sidebarRecipe = defineSlotRecipe({
|
||||
slots: ["root", "logo", "footer", "button_text","buttons", "collapse_button"],
|
||||
base: {
|
||||
root: {
|
||||
// 1. Semi-transparent backgrounds
|
||||
bg: {
|
||||
base: "linear-gradient(to bottom right, #99000040 20%, #FF660040 60%, #EEEE0040 100%)",
|
||||
_dark: "linear-gradient(to bottom right, #9900003C 20%, #FF66003C 60%, #FFC0003C 100%)"
|
||||
},
|
||||
|
||||
// 2. The glass blur effect
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
|
||||
// 3. Subtle semi-transparent borders
|
||||
border: "1px solid",
|
||||
borderColor: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"},
|
||||
|
||||
// CRITICAL: Remove the right border so it doesn't draw a line through your cutout
|
||||
borderRight: "none",
|
||||
|
||||
borderRadius: "2xl",
|
||||
// boxShadow: "0 4px 30px rgba(0, 0, 0, 0.1)",
|
||||
// boxShadow: "-10px 4px 30px rgba(0, 0, 0, 0.1)",
|
||||
width: "100%",
|
||||
height: "calc(100% - 16px)",
|
||||
margin: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
justifyContent: "start",
|
||||
alignItems: "stretch",
|
||||
gap: 4,
|
||||
transition: "width 0.4s ease",
|
||||
|
||||
// CRITICAL: Adjust padding and overflow to allow the button to touch the right edge
|
||||
pl: 2, // Keep left padding
|
||||
py: 2, // Keep top/bottom padding
|
||||
pr: 0, // Remove right padding
|
||||
pt: 4, // Added top padding to give the logo room to breathe
|
||||
overflow: "visible", // Allows the curved corner patches to render outside the button
|
||||
zIndex: 10,
|
||||
},
|
||||
buttons: {
|
||||
position: "relative", // Required to anchor the corner patches
|
||||
bg: "transparent",
|
||||
|
||||
justifyContent: "flex-start",
|
||||
transition: "background-color 0.4s ease, color 0.4s ease",
|
||||
|
||||
"& svg": {
|
||||
transition: "stroke-width 0.4s ease",
|
||||
flexShrink: "0"
|
||||
},
|
||||
|
||||
_hover: {
|
||||
bg: "rgba(255, 255, 255, 0.1)",
|
||||
color: "{text}",
|
||||
fontWeight: "semibold",
|
||||
"& svg": {
|
||||
strokeWidth: "3"
|
||||
}
|
||||
},
|
||||
|
||||
fontSize: "l",
|
||||
color: {base: "{text}"},
|
||||
variant: "ghost",
|
||||
width: "100%",
|
||||
|
||||
// Flat on the right, rounded on the left
|
||||
borderRightRadius: "0",
|
||||
borderLeftRadius: "4xl",
|
||||
|
||||
"&.active": {
|
||||
bg: "{MainBackground}",
|
||||
color: "{textInverted}",
|
||||
|
||||
_hover: {
|
||||
bg: "{MainBackground}",
|
||||
color: "{textInverted}",
|
||||
fontWeight: "normal",
|
||||
"& svg": {
|
||||
strokeWidth: "2" // Keeps the active icon at normal thickness on hover
|
||||
}
|
||||
},
|
||||
|
||||
// The Top Inverted Curve
|
||||
_before: {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
right: "-1px",
|
||||
top: "-25px",
|
||||
width: "25px",
|
||||
height: "25px",
|
||||
bg: "inherit",
|
||||
WebkitMaskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
|
||||
maskImage: "radial-gradient(circle at top left, transparent 25px, black 25.5px)",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
|
||||
// The Bottom Inverted Curve
|
||||
_after: {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
right: "-1px",
|
||||
bottom: "-25px",
|
||||
width: "25px",
|
||||
height: "25px",
|
||||
bg: "inherit",
|
||||
WebkitMaskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
|
||||
maskImage: "radial-gradient(circle at bottom left, transparent 25px, black 25.5px)",
|
||||
pointerEvents: "none",
|
||||
}
|
||||
},
|
||||
},
|
||||
logo: {
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
overflow: "hidden",
|
||||
fontWeight: "bold",
|
||||
px: 0,
|
||||
},
|
||||
footer: {
|
||||
mt: "auto",
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
pb: 4,
|
||||
pr: 2,
|
||||
transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease"
|
||||
},
|
||||
button_text: {
|
||||
transition: "opacity 0.3s ease, visibility 0.3s ease, margin 0.3s ease",
|
||||
whiteSpace: "nowrap"
|
||||
},
|
||||
collapse_button: {
|
||||
"& svg": { flexShrink: "0" },
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
state: {
|
||||
open: {
|
||||
root: { width: "190px" },
|
||||
logo: { justifyContent: "flex-start", ml: 2, textStyle: "4xl" },
|
||||
footer: { gap: 16, flexDirection: "row" },
|
||||
button_text: {ml: 3, opacity: 1, visibility: "visible"},
|
||||
},
|
||||
closed: {
|
||||
root: { width: "71px" },
|
||||
logo: { justifyContent: "center", ml: -1, textStyle: "3xl" },
|
||||
footer: { gap: 2, flexDirection: "column-reverse" },
|
||||
button_text: {ml: 0, opacity: 0, visibility: "hidden"},
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const dashboardRecipe = defineRecipe({
|
||||
base: {
|
||||
bg: "{base200}"
|
||||
bg: "{MainBackground}"
|
||||
}
|
||||
});
|
||||
|
||||
const canvasRecipe = defineRecipe({
|
||||
base: {
|
||||
bg: "{base200}",
|
||||
bg: "{transparent}",
|
||||
zIndex: "-1",
|
||||
color: "{text}"
|
||||
}
|
||||
});
|
||||
|
||||
const sidebarRecipe = defineSlotRecipe({
|
||||
slots: ["root", "close_button", "buttons", "dark_mode_button"],
|
||||
base: {
|
||||
root: {
|
||||
bg: "{base200}",
|
||||
borderRight: "1px solid",
|
||||
borderColor: "{border}",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "start",
|
||||
alignItems: "start",
|
||||
gap: 2,
|
||||
p: 2,
|
||||
},
|
||||
buttons: {
|
||||
bg: "transparent",
|
||||
_hover: {bg: "{primaryLt}", color: "{textInverted}"},
|
||||
"&.active": {bg: "{primary}", color: "{textInverted}"},
|
||||
fontSize: "xl",
|
||||
color: {base: "{text}"},
|
||||
variant: "ghost",
|
||||
width: "100%",
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const widgetRecipe = defineSlotRecipe({
|
||||
slots: ["root", "header", "canvas"],
|
||||
base: {
|
||||
@@ -96,19 +223,144 @@ const widgetRecipe = defineSlotRecipe({
|
||||
}
|
||||
});
|
||||
|
||||
const glassPanelRecipe = defineSlotRecipe({
|
||||
slots: ["root", "header", "body"],
|
||||
|
||||
// 🧊 THE GLASS MATH (Applies to everything)
|
||||
base: {
|
||||
root: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "rgba(255, 255, 255, 0.04)"},
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid",
|
||||
borderColor: {base: "gray.200", _dark: "whiteAlpha.200"},
|
||||
boxShadow: "sm",
|
||||
}
|
||||
},
|
||||
|
||||
// 📏 THE LAYOUTS (Applies selectively)
|
||||
variants: {
|
||||
layout: {
|
||||
widget: {
|
||||
root: {width: "100%", height: "100%", borderRadius: "3xl"},
|
||||
header: {display: "flex", justifyContent: "space-between", alignItems: "center", px: 3, pt: 2, pb: 0},
|
||||
body: {flex: "1", overflow: "hidden", position: "relative", px: 1, pb: 1, pt: 0}
|
||||
},
|
||||
dropdown: {
|
||||
root: {borderRadius: "xl", p: 1}
|
||||
// (Header and body slots are ignored here because dropdowns don't use them)
|
||||
},
|
||||
popover: {
|
||||
root: {
|
||||
borderRadius: "2xl",
|
||||
p: 4,
|
||||
boxShadow: "xl",
|
||||
minW: "240px",
|
||||
// OVERRIDES: Make the background 85-90% opaque instead of 40%
|
||||
bg: { base: "rgba(255, 255, 255, 0.75)", _dark: "rgba(20, 22, 25, 0.8)" },
|
||||
|
||||
// OVERRIDES: Increase the blur just to smooth out any stubborn gridlines
|
||||
backdropFilter: "blur(15px)",
|
||||
WebkitBackdropFilter: "blur(15px)",
|
||||
}
|
||||
}
|
||||
},
|
||||
mode: {
|
||||
editing: {
|
||||
root: {
|
||||
boxShadow: "outline",
|
||||
borderStyle: "dashed",
|
||||
borderWidth: "2px",
|
||||
borderColor: "orange.300",
|
||||
cursor: "grab",
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
defaultVariants: {
|
||||
layout: "widget"
|
||||
}
|
||||
});
|
||||
|
||||
const glassTabRecipe = defineRecipe({
|
||||
base: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
height: "40px",
|
||||
|
||||
// The Circle State (Inactive)
|
||||
maxWidth: "40px",
|
||||
minWidth: "40px",
|
||||
px: "8px",
|
||||
gap: 2,
|
||||
|
||||
// CRITICAL: Locks the shape to a perfect circle/pill
|
||||
borderRadius: "full",
|
||||
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
textDecoration: "none",
|
||||
fontWeight: "bold",
|
||||
color: "fg.muted",
|
||||
|
||||
// --- THE GLASS EFFECT (Inactive) ---
|
||||
// Uses a highly transparent white/black depending on mode
|
||||
bg: {base: "rgba(255, 255, 255, 0.4)", _dark: "rgba(255, 255, 255, 0.03)"},
|
||||
backdropFilter: "blur(12px)",
|
||||
WebkitBackdropFilter: "blur(12px)",
|
||||
border: "1px solid",
|
||||
borderColor: {base: "rgba(0, 0, 0, 0.05)", _dark: "rgba(255, 255, 255, 0.08)"},
|
||||
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.02)", // Tiny shadow to lift it off the canvas
|
||||
|
||||
transition: "all 0.85s cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
|
||||
"&:hover, &[data-state='open'], &[aria-expanded='true']": {
|
||||
maxWidth: "250px",
|
||||
pr: "16px",
|
||||
// --- THE GLASS EFFECT (Hover) ---
|
||||
// Becomes slightly more opaque when you mouse over
|
||||
bg: {base: "rgba(255, 255, 255, 0.8)", _dark: "rgba(255, 255, 255, 0.1)"},
|
||||
borderColor: {base: "rgba(0, 0, 0, 0.1)", _dark: "rgba(255, 255, 255, 0.15)"},
|
||||
color: "fg",
|
||||
textDecoration: "none",
|
||||
},
|
||||
|
||||
"&.active": {
|
||||
maxWidth: "250px",
|
||||
pr: "16px",
|
||||
|
||||
bg: "{gooeyButtonBackground}",
|
||||
borderColor: "transparent",
|
||||
color: "{textInverted}",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.1)", // Stronger shadow for the active tab
|
||||
|
||||
_hover: {
|
||||
bg: "{gooeyButtonBackground}",
|
||||
cursor: "default",
|
||||
textDecoration: "none",
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const config = defineConfig({
|
||||
globalCss: {
|
||||
".react-grid-placeholder": {
|
||||
background: "#c0d2e3ff !important",
|
||||
borderColor: "#a9b6c2ff !important",
|
||||
boxShadow: "0 0 10px #c0d2e3ff !important",
|
||||
opacity: "1 !important",
|
||||
borderRadius: "5px",
|
||||
_dark: {
|
||||
background: "#393c3fff !important",
|
||||
borderColor: "#000000ff !important",
|
||||
boxShadow: "0 0 10px #16181bff !important",
|
||||
background: "#c0d2e3ff !important",
|
||||
borderColor: "#a9b6c2ff !important",
|
||||
boxShadow: "0 0 10px #c0d2e3ff !important",
|
||||
opacity: "1 !important",
|
||||
borderRadius: "5px",
|
||||
_dark: {
|
||||
background: "#393c3fff !important",
|
||||
borderColor: "#000000ff !important",
|
||||
boxShadow: "0 0 10px #16181bff !important",
|
||||
opacity: "1 !important",
|
||||
},
|
||||
},
|
||||
".react-resizable-handle-se": {
|
||||
@@ -171,31 +423,38 @@ const config = defineConfig({
|
||||
theme: {
|
||||
semanticTokens: {
|
||||
colors: {
|
||||
base100: { value: {base: "#f6f9faff", _dark: "#1617199e"}},
|
||||
base200: { value: {base: "#e6ecf1ff", _dark: "#242424"}},
|
||||
base300: { value: {base: "#d3dae4ff", _dark: "#0a0b0bff"}},
|
||||
primary: { value: {base: "#2B98F8", _dark: "#2B98F8"}},
|
||||
primaryLt: { value: {base: "#98cdffff", _dark: "#98cdffff"}},
|
||||
secondaryBG: { value: {base: "#EDF1F1", _dark: "#121212"}},
|
||||
base100: {value: {base: "#f6f9faff", _dark: "#1617199e"}},
|
||||
base200: {value: {base: "#e6ecf1ff", _dark: "#242424"}},
|
||||
base300: {value: {base: "#d3dae4ff", _dark: "#0a0b0bff"}},
|
||||
primary: {value: {base: "#2B98F8", _dark: "#2B98F8"}},
|
||||
primaryLt: {value: {base: "#98cdffff", _dark: "#98cdffff"}},
|
||||
secondaryBG: {value: {base: "#EDF1F1", _dark: "#121212"}},
|
||||
border: {value: {base: "#000000", _dark: "#cbcbcb84"}},
|
||||
text: {value: {base: "#000000", _dark: "lightgrey"}},
|
||||
textInverted: {value: "white"},
|
||||
icon: {value: {base: "#000000", _dark: "lightgrey"}},
|
||||
text: {value: {base: "#000000", _dark: "#FFFFFF"}},
|
||||
textInverted: {value: {base: "#000000", _dark: "#FFFFFF"}},
|
||||
icon: {value: {base: "#000000", _dark: "#FFFFFF"}},
|
||||
gooeyButtonBackground: {value: {base: "rgba(255, 255, 255, 0.1)", _dark: "rgba(255, 255, 255, 0.1)"}},
|
||||
MainBackground: {value: {base: "#F9F9F9", _dark: "#141414"}},
|
||||
titleColor: {value: {base: "gray.800", _dark: "#FFFFFF"}},
|
||||
iconColor: {value: {base: "gray.600", _dark: "#000000"}},
|
||||
glassSeperator: {value: {base: "#0000001A", _dark: "#FFFFFF3F"}},
|
||||
},
|
||||
},
|
||||
tokens: {
|
||||
fonts: {
|
||||
body: { value: "system-ui, sans-serif" },
|
||||
body: {value: "system-ui, sans-serif"},
|
||||
},
|
||||
},
|
||||
recipes: {
|
||||
header: headerRecipe,
|
||||
dashboard: dashboardRecipe,
|
||||
canvas: canvasRecipe,
|
||||
glassTab: glassTabRecipe
|
||||
},
|
||||
slotRecipes: {
|
||||
sidebar: sidebarRecipe,
|
||||
widget: widgetRecipe,
|
||||
glassPanel: glassPanelRecipe,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user