adding working IVANS widget
This commit is contained in:
@@ -4,7 +4,7 @@ import { useColorMode } from "@/components/ui/color-mode.jsx";
|
|||||||
import Plot from 'react-plotly.js';
|
import Plot from 'react-plotly.js';
|
||||||
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
|
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
|
||||||
|
|
||||||
export default function ExplosionEvents({ settings }) {
|
export default function ExplosionEvents({ settings, widgetID }) {
|
||||||
// Plotly expects 'linear' for normal axes
|
// Plotly expects 'linear' for normal axes
|
||||||
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
|
const plotYAxisType = settings?.yAxisScale === 'log' ? 'log' : 'linear';
|
||||||
|
|
||||||
|
|||||||
@@ -1,164 +1,354 @@
|
|||||||
import VdapApi from '../../hooks/useVdapApi.js';
|
import VdapApi from '../../hooks/useVdapApi.js';
|
||||||
import {Flex, Spinner, Text, Button, Dialog, Box} from "@chakra-ui/react";
|
import {Button, Dialog, Flex, Grid, GridItem, Spinner, Text} from "@chakra-ui/react";
|
||||||
import * as url from "node:url"; // Adjust path as needed
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
import { useState } from 'react';
|
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||||
|
|
||||||
export default function IvansAlerts() {
|
export default function IvansAlerts({ settings, widgetID }) {
|
||||||
console.log("IvansAlerts");
|
console.log("IvansAlerts");
|
||||||
const [isSummaryOpen, setIsSummaryOpen] = useState(false);
|
const [activeSummaryItem, setActiveSummaryItem] = useState(false);
|
||||||
const [isFileOpen, setIsFileOpen] = useState(false);
|
const [activeFileItem, setActiveFileItem] = useState(false);
|
||||||
|
const [activeImageItem, setActiveImageItem] = useState(false);
|
||||||
|
const widgets = useDashboardStore(state => state.widgets);
|
||||||
|
console.log("widgets: ", widgets);
|
||||||
|
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||||
|
const isCopy = settings?.copyToClipboard === true; // acts as a failsafe if no default setting is set
|
||||||
|
const sorts = settings?.activeSorts;
|
||||||
|
|
||||||
function SummaryDialog({ open, onOpenChange, item }) {
|
// build url once on mount
|
||||||
|
const seven_day_url = useMemo(() => {
|
||||||
|
const base_url = '/api/v2/comm_coor_ivans'
|
||||||
|
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;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// VdapApi hook only calls api if the url changes
|
||||||
|
const {data: apiD, isLoading, error} = VdapApi(seven_day_url);
|
||||||
|
|
||||||
|
const apiData = [
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("sorted data: ", sorted);
|
||||||
|
return sorted;
|
||||||
|
}, [apiData, sorts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if(isCopy) {
|
||||||
|
navigator.clipboard.writeText(data)
|
||||||
|
.then(() => {
|
||||||
|
alert('Copied to clipboard!');
|
||||||
|
})
|
||||||
|
.catch((err => {
|
||||||
|
console.error('Failed to copy: ', err);
|
||||||
|
}));
|
||||||
|
updateSettings(widgetID, {copyToClipboard: false});
|
||||||
|
}
|
||||||
|
}, [isCopy]);
|
||||||
|
|
||||||
|
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 (data.length === 0) return (
|
||||||
|
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
||||||
|
<Text color="gray.400">No flight level data available this week.</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2} overflow="auto">
|
||||||
|
{/*<Grid w="100%" h="100%" templateColumns="repeat(6, 1fr)" alignItems="center">*/}
|
||||||
|
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={4}>Volcano</GridItem>*/}
|
||||||
|
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold">Report Number</GridItem>*/}
|
||||||
|
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={3}>Report Send Time</GridItem>*/}
|
||||||
|
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Preparer</GridItem>*/}
|
||||||
|
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Summary</GridItem>*/}
|
||||||
|
{/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Attachments</GridItem>*/}
|
||||||
|
{/*</Grid>*/}
|
||||||
|
{data.map((item, index) => {
|
||||||
|
const context_string = item.volcano_name + ' • ' + item.send_time;
|
||||||
return (
|
return (
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
<Grid w="100%" h="100%" templateColumns="repeat(8, 1fr)" key={index} borderRadius="lg" bg="gray.700" alignItems="center" justifyContent="center">
|
||||||
<Dialog.Backdrop />
|
<GridItem colSpan={2} color="gray.100" pl={4}>{item.volcano_name}</GridItem>
|
||||||
<Dialog.Positioner>
|
<GridItem colSpan={1} color="gray.100">{item.report_num}</GridItem>
|
||||||
<Dialog.Content>
|
<GridItem colSpan={2} color="gray.100">{item.send_time}</GridItem>
|
||||||
<Dialog.Header>
|
<GridItem colSpan={1} color="gray.100">{item.preparer}</GridItem>
|
||||||
<Dialog.Title>Summary</Dialog.Title>
|
<GridItem colSpan={1}>
|
||||||
</Dialog.Header>
|
<>
|
||||||
|
<Button w="7rem" h={0} mb={1} onClick={() => setActiveSummaryItem(item)} color="purple.300" variant="plain" textDecor="underline">View Summary</Button>
|
||||||
<Dialog.Body>
|
<SummaryDialog
|
||||||
<Text>{item.summary}</Text>
|
open={!!activeSummaryItem}
|
||||||
</Dialog.Body>
|
onOpenChange={() => setActiveSummaryItem(null)}
|
||||||
|
item={activeSummaryItem}
|
||||||
<Dialog.Footer>
|
context={context_string}
|
||||||
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
/>
|
||||||
Close
|
</>
|
||||||
</Button>
|
</GridItem>
|
||||||
</Dialog.Footer>
|
<GridItem colSpan={1}>
|
||||||
<Dialog.CloseTrigger />
|
<Flex direction="column" justify="center" alignItems="center" gap={0}>
|
||||||
</Dialog.Content>
|
{item.files.length === 0 ? (
|
||||||
</Dialog.Positioner>
|
<Text color="gray.600" fontSize="sm">No Attachments</Text>
|
||||||
</Dialog.Root>
|
):(
|
||||||
)
|
item.files.map((file, index) => {
|
||||||
}
|
if (file.type === "pdf") return (
|
||||||
|
<div key={index}>
|
||||||
function FileDialog({ open, onOpenChange, item, context }) {
|
<Button w="7rem" h={0} mb={1} onClick={() => setActiveFileItem(file)} color="purple.300" variant="plain" textDecor="underline">View PDF</Button>
|
||||||
return (
|
<FileDialog
|
||||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
open={!!activeFileItem}
|
||||||
<Dialog.Backdrop />
|
onOpenChange={() => setActiveFileItem(null)}
|
||||||
<Dialog.Positioner>
|
item={activeFileItem}
|
||||||
<Dialog.Content>
|
context={context_string}
|
||||||
<Dialog.Header>
|
/>
|
||||||
<Flex width="100%" direction="row" align="center" justifyContent="space-between">
|
</div>
|
||||||
<Flex direction="column">
|
|
||||||
<Dialog.Title>{item.name ? item.name : 'PDF Attachment'}</Dialog.Title>
|
|
||||||
<Text color="gray.200">{context}</Text>
|
|
||||||
</Flex>
|
|
||||||
<Flex>
|
|
||||||
<Text bg="gray.800" p="1" borderRadius="sm">{item.type}</Text>
|
|
||||||
</Flex>
|
|
||||||
</Flex>
|
|
||||||
</Dialog.Header>
|
|
||||||
|
|
||||||
<Dialog.Body h="75vh" p={0}>
|
|
||||||
<Box bg="gray.900" w="100%" h="100%" display="flex" alignItems="center" justifyContent="center">
|
|
||||||
<object display="block" data={`${item.url}#view=FitH`} type="application/pdf">
|
|
||||||
<Flex height="100%" color="gray.400" p={4}>
|
|
||||||
<Text mb={2} color="fg.muted">Your browser cannot display this PDF directly.</Text>
|
|
||||||
<a href={item.url} target="_blank">
|
|
||||||
Download PDF
|
|
||||||
</a>
|
|
||||||
</Flex>
|
|
||||||
</object>
|
|
||||||
</Box>
|
|
||||||
</Dialog.Body>
|
|
||||||
|
|
||||||
<Dialog.Footer>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Dialog.Footer>
|
|
||||||
<Dialog.CloseTrigger />
|
|
||||||
</Dialog.Content>
|
|
||||||
</Dialog.Positioner>
|
|
||||||
</Dialog.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const get_7_day_range_url = () => {
|
|
||||||
const base_url = '/api/v2/comm_coor_ivans'
|
|
||||||
let today = new Date();
|
|
||||||
let sevenDaysAgo = new Date(today);
|
|
||||||
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
|
|
||||||
|
|
||||||
sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA');
|
|
||||||
today = today.toLocaleDateString('en-CA');
|
|
||||||
|
|
||||||
const url_with_dates = base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today;
|
|
||||||
return url_with_dates;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seven_day_url = get_7_day_range_url();
|
|
||||||
|
|
||||||
const {data: apiData, isLoading, error} = VdapApi(seven_day_url);
|
|
||||||
|
|
||||||
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 (apiData.length === 0) return (
|
|
||||||
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
|
|
||||||
<Text color="gray.400">No flight level data available.</Text>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex w="100%" h="100%" direction="column" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}>
|
|
||||||
<Flex direction="row" gap={4}>
|
|
||||||
<Text color="gray.400">Volcano</Text>
|
|
||||||
<Text color="gray.400">Report Number</Text>
|
|
||||||
<Text color="gray.400">Report Send Time</Text>
|
|
||||||
<Text color="gray.400">Preparer</Text>
|
|
||||||
<Text color="gray.400">Summary</Text>
|
|
||||||
<Text color="gray.400">Attachments</Text>
|
|
||||||
</Flex>
|
|
||||||
{apiData.map((item, index) => {
|
|
||||||
const context_str = item.volcano_name + " " + "•" + " " + item.send_time;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Flex direction="row" key={index} gap={4}>
|
|
||||||
<Text color="gray.400">{item.volcano_name}</Text>
|
|
||||||
<Text color="gray.400">{item.report_num}</Text>
|
|
||||||
<Text color="gray.400">{item.send_time}</Text>
|
|
||||||
<Text color="gray.400">{item.preparer}</Text>
|
|
||||||
<Button onClick={() => setIsSummaryOpen(true)} color="gray.400">View</Button>
|
|
||||||
<SummaryDialog
|
|
||||||
open={isSummaryOpen}
|
|
||||||
onOpenChange={(e) => setIsSummaryOpen(e.open)}
|
|
||||||
item={item}
|
|
||||||
/>
|
|
||||||
{item.files.map((item, index) => {
|
|
||||||
const $file_name = item.name;
|
|
||||||
const $url = item.url;
|
|
||||||
const $type = item.type;
|
|
||||||
|
|
||||||
if ($type === "pdf") return (
|
|
||||||
<>
|
|
||||||
<Button onClick={() => setIsFileOpen(true)}>{$type}</Button>
|
|
||||||
<FileDialog
|
|
||||||
open={isFileOpen}
|
|
||||||
onOpenChange={(e) => setIsFileOpen(e.open)}
|
|
||||||
item={item}
|
|
||||||
context={context_str}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
)
|
)
|
||||||
})}
|
else if (file.type === "image") return (
|
||||||
<Flex>
|
<div key={index}>
|
||||||
|
<Button w="7rem" onClick={() => setActiveImageItem(file)} color="purple.300" variant="plain" textDecor="underline">View Image</Button>
|
||||||
|
<ImageDialog
|
||||||
|
open={!!activeImageItem}
|
||||||
|
onOpenChange={() => setActiveImageItem(null)}
|
||||||
|
item={activeImageItem}
|
||||||
|
context={context_string}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
else if (file.type === "download") return (
|
||||||
|
<Button key={index} w="7rem" onClick={() => downloadReport(file.url, file.name) } color="purple.300" variant="plain" textDecor="underline">Download</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</GridItem>
|
||||||
|
</Grid>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryDialog({ open, onOpenChange, item }) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Backdrop />
|
||||||
|
<Dialog.Positioner>
|
||||||
|
<Dialog.Content w="100vw" h="40vh">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title w="100%">
|
||||||
|
<Flex w="100%" h="100%" direction="column" textAlign="left">
|
||||||
|
<Flex direction="row" justify="space-between" align="center">
|
||||||
|
<Text fontSize="md">{item?.volcano_name}</Text>
|
||||||
|
<Text bg="gray.800" fontSize="xs" borderRadius="sm" color="gray.500" pl={2} pr={2}>Preparer: {item?.preparer}</Text>
|
||||||
|
</Flex>
|
||||||
|
<Text fontSize="xs" color="blue.400">{item?.send_time}</Text>
|
||||||
|
</Flex>
|
||||||
|
</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<Dialog.Body bg="gray.800" overflow="auto">
|
||||||
|
<Text textAlign="left">{item?.summary}</Text>
|
||||||
|
</Dialog.Body>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
<Dialog.CloseTrigger />
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Positioner>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileDialog({ open, onOpenChange, item, context }) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Backdrop />
|
||||||
|
<Dialog.Positioner>
|
||||||
|
<Dialog.Content w="100vw" h="75vh">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Flex height="100%" width="100%" direction="row" align="center" justifyContent="space-between">
|
||||||
|
<Flex direction="column" textAlign="left">
|
||||||
|
<Dialog.Title>{item?.name ? item?.name : 'PDF Attachment'}</Dialog.Title>
|
||||||
|
<Text color="gray.200">{context}</Text>
|
||||||
|
</Flex>
|
||||||
|
<Text bg="gray.800" p="1" borderRadius="sm">{(item?.type || '').toUpperCase()}</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Dialog.Header>
|
||||||
);
|
|
||||||
|
<Dialog.Body p={0}>
|
||||||
|
<Flex bg="gray.800" w="100%" h="100%" alignItems="center" justifyContent="center">
|
||||||
|
<object data={`${item?.url}#view=FitH`} type="application/pdf" style={{ width: '100%', height: '100%', display: 'block' }}>
|
||||||
|
<Flex w="100%" h="100%" color="gray.400" p={4}>
|
||||||
|
<Text mb={2} color="fg.muted">Your browser cannot display this PDF directly.</Text>
|
||||||
|
<a href={item?.url} target="_blank">
|
||||||
|
Download PDF
|
||||||
|
</a>
|
||||||
|
</Flex>
|
||||||
|
</object>
|
||||||
|
</Flex>
|
||||||
|
</Dialog.Body>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
<Dialog.CloseTrigger />
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Positioner>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageDialog({ open, onOpenChange, item, context }) {
|
||||||
|
return (
|
||||||
|
<Dialog.Root lazyMount open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Backdrop />
|
||||||
|
<Dialog.Positioner>
|
||||||
|
<Dialog.Content w="100vw" h="40vh">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{item?.name}</Dialog.Title>
|
||||||
|
<Text color="gray.200">{context}</Text>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<Dialog.Body bg="gray.800" overflow="auto">
|
||||||
|
<img src={item?.url} alt={item?.name} />
|
||||||
|
</Dialog.Body>
|
||||||
|
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange({ open: false })}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
<Dialog.CloseTrigger />
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Positioner>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadReport = ( url, name ) => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = name;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
@@ -67,6 +67,11 @@ const mockData = [
|
|||||||
volcano_name: "Mayon",
|
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).",
|
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: [
|
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",
|
name: "275-273030-Mayon-2026-02.pdf",
|
||||||
url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
url: "https://vdap.org/ivans/sitrep/275-273030-Mayon-2026-02.pdf",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Box, Flex, Spinner, Text } from '@chakra-ui/react';
|
|||||||
import Plot from 'react-plotly.js';
|
import Plot from 'react-plotly.js';
|
||||||
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
|
import VdapApi from '../../hooks/useVdapApi.js'; // Adjust path as needed
|
||||||
|
|
||||||
export default function VaaActivity7Day({ settings }) {
|
export default function VaaActivity7Day({ settings, widgetID }) {
|
||||||
console.log("3. Chart received settings:", settings);
|
console.log("3. Chart received settings:", settings);
|
||||||
// Plotly expects 'linear' for normal axes
|
// Plotly expects 'linear' for normal axes
|
||||||
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
|
const plotYAxisType = settings?.yAxisScale === 'symlog' ? 'symlog' : 'linear';
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {Button, Flex, Switch, Text} from "@chakra-ui/react";
|
||||||
|
import {useDashboardStore} from "../../../store/useDashboardStore.jsx";
|
||||||
|
|
||||||
|
export default function IvansAlertsSettings({ widgetId, currentSettings }) {
|
||||||
|
const updateSettings = useDashboardStore(state => state.updateWidgetSettings);
|
||||||
|
|
||||||
|
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}>
|
||||||
|
<Button onClick={handleCopyToClipboard}>Copy All IVANS</Button>
|
||||||
|
<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>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -41,10 +41,17 @@ export const WIDGET_REGISTRY = {
|
|||||||
name: "IVANS",
|
name: "IVANS",
|
||||||
category: "Communications",
|
category: "Communications",
|
||||||
description: "Feed of recent IVANS reports and reviewer statuses.",
|
description: "Feed of recent IVANS reports and reviewer statuses.",
|
||||||
component: lazy(() => import('../components/widgets/IvansAlerts.jsx')),
|
component: lazy(() => import('../components/Widgets/IvansAlerts.jsx')),
|
||||||
|
settingsComponent: lazy(() => import('../components/Widgets/settings/IvansAlertsSettings.jsx')),
|
||||||
|
defaultSettings: {
|
||||||
|
copyToClipboard: false,
|
||||||
|
activeSorts: {
|
||||||
|
timeSort: "desc",
|
||||||
|
volcanoSort: false
|
||||||
|
}
|
||||||
|
},
|
||||||
allowedSizes: {
|
allowedSizes: {
|
||||||
small: { w: 3, h: 12 },
|
small: { w: 6, h: 9 }
|
||||||
medium: { w: 6, h: 24 }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -89,8 +96,7 @@ export const WIDGET_REGISTRY = {
|
|||||||
"llm_al_changes_quad_plot": {
|
"llm_al_changes_quad_plot": {
|
||||||
name: "Alert Level Changes Quad Plot",
|
name: "Alert Level Changes Quad Plot",
|
||||||
category: "Computer Science",
|
category: "Computer Science",
|
||||||
description: "Percentage of True-Positive, True-Negative, False-Positive, and False-Negative changes in all " +
|
description: "Percentage of TP, TN, FP, and FN changes in viable alert levels.",
|
||||||
"alert levels.",
|
|
||||||
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
|
component: lazy(() => import('../components/Widgets/LlmAlChangesQuadPlot.jsx')),
|
||||||
allowedSizes: {
|
allowedSizes: {
|
||||||
small: { w: 3, h: 12 }
|
small: { w: 3, h: 12 }
|
||||||
|
|||||||
@@ -1,209 +1,210 @@
|
|||||||
import { Box, Text, Flex, IconButton, Spinner, Popover } from "@chakra-ui/react";
|
import {Box, Text, Flex, IconButton, Spinner, Popover} from "@chakra-ui/react";
|
||||||
import { X, MoreVertical } from "lucide-react";
|
import {X, MoreVertical} from "lucide-react";
|
||||||
import { Responsive, WidthProvider } from "react-grid-layout";
|
import {Responsive, WidthProvider} from "react-grid-layout";
|
||||||
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
|
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
|
||||||
import { Suspense, useEffect, useRef } from 'react';
|
import {Suspense, useEffect, useRef} from 'react';
|
||||||
import { useColorModeValue } from "@/components/ui/color-mode";
|
import {useColorModeValue} from "@/components/ui/color-mode";
|
||||||
// 1. Import database messenger
|
// 1. Import database messenger
|
||||||
import useAuthStore from "@/store/authStore";
|
import useAuthStore from "@/store/authStore";
|
||||||
|
|
||||||
// 2. Import registry
|
// 2. Import registry
|
||||||
import { WIDGET_REGISTRY } from '@/constants/widgetRegistry.jsx';
|
import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx';
|
||||||
|
|
||||||
// React-Grid-Layout requires this wrapper to automatically calculate screen width
|
// React-Grid-Layout requires this wrapper to automatically calculate screen width
|
||||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||||
|
|
||||||
export default function MyDashboard() {
|
export default function MyDashboard() {
|
||||||
// 🌗 Let's adjust for our light and dark mode
|
// 🌗 Let's adjust for our light and dark mode
|
||||||
const frameBg = useColorModeValue("gray.100", "gray.800");
|
const frameBg = useColorModeValue("gray.100", "gray.800");
|
||||||
const titleColor = useColorModeValue("gray.800", "gray.100");
|
const titleColor = useColorModeValue("gray.800", "gray.100");
|
||||||
const iconColor = useColorModeValue("gray.600", "gray.400");
|
const iconColor = useColorModeValue("gray.600", "gray.400");
|
||||||
const iconHoverBg = useColorModeValue("gray.100", "gray.700");
|
const iconHoverBg = useColorModeValue("gray.100", "gray.700");
|
||||||
|
|
||||||
// ☁️ Database Connection
|
// ☁️ Database Connection
|
||||||
const { user } = useAuthStore();
|
const {user} = useAuthStore();
|
||||||
|
|
||||||
// ☁️ Local UI Connections
|
// ☁️ Local UI Connections
|
||||||
const widgets = useDashboardStore(state => state.widgets);
|
const widgets = useDashboardStore(state => state.widgets);
|
||||||
const isEditing = useDashboardStore(state => state.isEditing);
|
const isEditing = useDashboardStore(state => state.isEditing);
|
||||||
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
|
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
|
||||||
const removeWidget = useDashboardStore(state => state.removeWidget);
|
const removeWidget = useDashboardStore(state => state.removeWidget);
|
||||||
const setWidgets = useDashboardStore(state => state.setWidgets); // The injection action
|
const setWidgets = useDashboardStore(state => state.setWidgets); // The injection action
|
||||||
|
|
||||||
// 🔒 Safety lock to prevent infinite re-renders
|
// 🔒 Safety lock to prevent infinite re-renders
|
||||||
const hasHydrated = useRef(false);
|
const hasHydrated = useRef(false);
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// INITIAL LOAD: Grab data from Django
|
// INITIAL LOAD: Grab data from Django
|
||||||
// ==========================================
|
// ==========================================
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
|
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
|
||||||
const savedLayout = user.preferences.dashboard_layout;
|
const savedLayout = user.preferences.dashboard_layout;
|
||||||
|
|
||||||
// Safety check: Only inject if it's actually an array of widgets.
|
// Safety check: Only inject if it's actually an array of widgets.
|
||||||
// (Prevents a crash if MariaDB returns a default empty object {})
|
// (Prevents a crash if MariaDB returns a default empty object {})
|
||||||
if (Array.isArray(savedLayout)) {
|
if (Array.isArray(savedLayout)) {
|
||||||
setWidgets(savedLayout);
|
setWidgets(savedLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
hasHydrated.current = true;
|
hasHydrated.current = true;
|
||||||
}
|
}
|
||||||
}, [user, setWidgets]);
|
}, [user, setWidgets]);
|
||||||
|
|
||||||
|
|
||||||
// 🛠️ Format the data for RGL
|
// 🛠️ Format the data for RGL
|
||||||
const rglLayout = widgets.map(w => ({
|
const rglLayout = widgets.map(w => ({
|
||||||
i: w.id,
|
i: w.id,
|
||||||
x: w.x,
|
x: w.x,
|
||||||
y: w.y,
|
y: w.y,
|
||||||
w: w.w,
|
w: w.w,
|
||||||
h: w.h,
|
h: w.h,
|
||||||
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
|
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 3. THE COMPONENT MAPPER
|
// 3. THE COMPONENT MAPPER
|
||||||
const renderWidget = (widget) => {
|
const renderWidget = (widget) => {
|
||||||
const registryEntry = WIDGET_REGISTRY[widget.type];
|
const registryEntry = WIDGET_REGISTRY[widget.type];
|
||||||
|
|
||||||
if (registryEntry && registryEntry.component) {
|
if (registryEntry && registryEntry.component) {
|
||||||
const Component = registryEntry.component;
|
const Component = registryEntry.component;
|
||||||
|
|
||||||
// 1. Check if this widget type has a settings component registered
|
// 1. Check if this widget type has a settings component registered
|
||||||
const SettingsUI = registryEntry.settingsComponent;
|
const SettingsUI = registryEntry.settingsComponent;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// The "Frame" that holds both the Title and the Chart
|
// The "Frame" that holds both the Title and the Chart
|
||||||
<Flex direction="column" height="100%" width="100%" bg={frameBg}>
|
<Flex direction="column" height="100%" width="100%" bg={frameBg}>
|
||||||
|
|
||||||
{/* THE TITLE BAR */}
|
{/* THE TITLE BAR */}
|
||||||
<Flex
|
<Flex
|
||||||
justify="space-between"
|
justify="space-between"
|
||||||
align="center"
|
align="center"
|
||||||
px={3}
|
px={3}
|
||||||
py={2}
|
py={2}
|
||||||
>
|
>
|
||||||
<Text color={titleColor} fontWeight="semibold" fontSize="md" isTruncated>
|
<Text color={titleColor} fontWeight="semibold" fontSize="md" isTruncated>
|
||||||
{registryEntry.name}
|
{registryEntry.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* THE SETTINGS ELLIPSIS */}
|
{/* THE SETTINGS ELLIPSIS */}
|
||||||
{SettingsUI ? (
|
{SettingsUI ? (
|
||||||
<Popover.Root positioning={{ placement: "bottom-end" }}>
|
<Popover.Root positioning={{placement: "bottom-end"}}>
|
||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="gray.400"
|
color="gray.400"
|
||||||
_hover={{ color: "white", bg: "gray.700" }}
|
_hover={{color: "white", bg: "gray.700"}}
|
||||||
aria-label="Widget Settings"
|
aria-label="Widget Settings"
|
||||||
>
|
>
|
||||||
<MoreVertical size={16} />
|
<MoreVertical size={16}/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
|
|
||||||
{/* NEW: The Positioner lifts the content out of the Flexbox flow */}
|
{/* NEW: The Positioner lifts the content out of the Flexbox flow */}
|
||||||
<Popover.Positioner zIndex={10}>
|
<Popover.Positioner zIndex={10}>
|
||||||
<Popover.Content bg="gray.50" p={4} boxShadow="xl" borderRadius="md">
|
<Popover.Content bg="gray.50" p={4} boxShadow="xl" borderRadius="md">
|
||||||
<Suspense fallback={<Spinner size="sm" />}>
|
<Suspense fallback={<Spinner size="sm"/>}>
|
||||||
<SettingsUI
|
<SettingsUI
|
||||||
widgetId={widget.id}
|
widgetId={widget.id}
|
||||||
currentSettings={widget.settings || {}}
|
currentSettings={widget.settings || {}}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Positioner>
|
</Popover.Positioner>
|
||||||
</Popover.Root>
|
</Popover.Root>
|
||||||
) : (
|
) : (
|
||||||
<Box w="20px" h="20px" />
|
<Box w="20px" h="20px"/>
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* THE CHART CANVAS */}
|
|
||||||
<Box flex="1" overflow="hidden" position="relative" p={1}>
|
|
||||||
{/* Pass the settings object into the actual chart component */}
|
|
||||||
<Component settings={widget.settings || {}} />
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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">
|
|
||||||
<Text fontWeight="bold" color="red.400">Error</Text>
|
|
||||||
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box width="100%" height="100%">
|
|
||||||
<ResponsiveGridLayout
|
|
||||||
className="layout"
|
|
||||||
layouts={{ lg: rglLayout }} // We feed it the formatted layout map
|
|
||||||
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
|
|
||||||
cols={{ lg: 12, md: 10, sm: 6, xs: 4, xxs: 2 }} // Our 12-column master grid
|
|
||||||
rowHeight={30}
|
|
||||||
isDraggable={isEditing} // Only allows dragging when Settings tools toggle this
|
|
||||||
isResizable={false} // Double lock-down
|
|
||||||
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
|
|
||||||
compactType="vertical" // Auto-packs widgets to the top
|
|
||||||
>
|
|
||||||
{/* 3. 🎨 Paint the Widgets */}
|
|
||||||
{widgets.map(widget => (
|
|
||||||
<Box
|
|
||||||
key={widget.id}
|
|
||||||
bg="white"
|
|
||||||
borderRadius="md"
|
|
||||||
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
|
|
||||||
border={isEditing ? "2px dashed gray" : "1px solid"}
|
|
||||||
borderColor={isEditing ? "gray.400" : "gray.200"}
|
|
||||||
overflow="hidden"
|
|
||||||
cursor={isEditing ? "grab" : "default"}
|
|
||||||
position="relative" // 3. REQUIRED so the absolute button stays inside this box
|
|
||||||
>
|
|
||||||
{/* 4. THE DELETE BUTTON */}
|
|
||||||
{isEditing && (
|
|
||||||
<IconButton
|
|
||||||
aria-label="Remove widget"
|
|
||||||
position="absolute"
|
|
||||||
top={2}
|
|
||||||
right={2}
|
|
||||||
size="xs"
|
|
||||||
bg="white"
|
|
||||||
color={iconColor}
|
|
||||||
border="1px solid"
|
|
||||||
borderColor="gray.200"
|
|
||||||
_hover={{ bg: "red.50", color: "red.500", borderColor: "red.200" }}
|
|
||||||
zIndex={2}
|
|
||||||
onClick={() => removeWidget(widget.id)}
|
|
||||||
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
|
|
||||||
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
|
|
||||||
<Suspense fallback={
|
|
||||||
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
|
|
||||||
<Spinner size="md" color="blue.400" thickness="3px" />
|
|
||||||
</Flex>
|
|
||||||
}>
|
|
||||||
{renderWidget(widget)}
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</ResponsiveGridLayout>
|
|
||||||
|
|
||||||
{/* Empty State Helper */}
|
|
||||||
{widgets.length === 0 && (
|
|
||||||
<Flex width="100%" height="200px" align="center" justify="center">
|
|
||||||
<Text color="gray.400">Your dashboard is empty. Click Settings to add widgets.</Text>
|
|
||||||
</Flex>
|
|
||||||
)}
|
)}
|
||||||
</Box>
|
</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.widgetID || {}}/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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">
|
||||||
|
<Text fontWeight="bold" color="red.400">Error</Text>
|
||||||
|
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
|
||||||
|
</Flex>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box width="100%" height="100%">
|
||||||
|
<ResponsiveGridLayout
|
||||||
|
className="layout"
|
||||||
|
layouts={{lg: rglLayout}} // We feed it the formatted layout map
|
||||||
|
breakpoints={{lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0}}
|
||||||
|
cols={{lg: 12, md: 10, sm: 6, xs: 4, xxs: 2}} // Our 12-column master grid
|
||||||
|
rowHeight={30}
|
||||||
|
isDraggable={isEditing} // Only allows dragging when Settings tools toggle this
|
||||||
|
isResizable={false} // Double lock-down
|
||||||
|
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
|
||||||
|
compactType="vertical" // Auto-packs widgets to the top
|
||||||
|
>
|
||||||
|
{/* 3. 🎨 Paint the Widgets */}
|
||||||
|
{widgets.map(widget => (
|
||||||
|
<Box
|
||||||
|
key={widget.id}
|
||||||
|
bg="white"
|
||||||
|
borderRadius="md"
|
||||||
|
boxShadow={isEditing ? "outline" : "sm"} // Visual cue when editing
|
||||||
|
border={isEditing ? "2px dashed gray" : "1px solid"}
|
||||||
|
borderColor={isEditing ? "gray.400" : "gray.200"}
|
||||||
|
overflow="hidden"
|
||||||
|
cursor={isEditing ? "grab" : "default"}
|
||||||
|
position="relative" // 3. REQUIRED so the absolute button stays inside this box
|
||||||
|
>
|
||||||
|
{/* 4. THE DELETE BUTTON */}
|
||||||
|
{isEditing && (
|
||||||
|
<IconButton
|
||||||
|
aria-label="Remove widget"
|
||||||
|
position="absolute"
|
||||||
|
top={2}
|
||||||
|
right={2}
|
||||||
|
size="xs"
|
||||||
|
bg="white"
|
||||||
|
color={iconColor}
|
||||||
|
border="1px solid"
|
||||||
|
borderColor="gray.200"
|
||||||
|
_hover={{bg: "red.50", color: "red.500", borderColor: "red.200"}}
|
||||||
|
zIndex={2}
|
||||||
|
onClick={() => removeWidget(widget.id)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
|
||||||
|
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
|
||||||
|
>
|
||||||
|
<X size={14}/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 4. SUSPENSE BOUNDARY wrapping the render function */}
|
||||||
|
<Suspense fallback={
|
||||||
|
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
|
||||||
|
<Spinner size="md" color="blue.400" thickness="3px"/>
|
||||||
|
</Flex>
|
||||||
|
}>
|
||||||
|
{renderWidget(widget)}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</ResponsiveGridLayout>
|
||||||
|
|
||||||
|
{/* Empty State Helper */}
|
||||||
|
{widgets.length === 0 && (
|
||||||
|
<Flex width="100%" height="200px" align="center" justify="center">
|
||||||
|
<Text color="gray.400">Your dashboard is empty. Click Settings to add widgets.</Text>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,109 +1,110 @@
|
|||||||
import { create } from 'zustand';
|
import {create} from 'zustand';
|
||||||
import { v4 as uuid } from 'uuid';
|
import {v4 as uuid} from 'uuid';
|
||||||
import { WIDGET_REGISTRY } from '../constants/widgetRegistry.jsx';
|
import {WIDGET_REGISTRY} from '../constants/widgetRegistry.jsx';
|
||||||
|
|
||||||
// 1. Import your database messenger
|
// 1. Import your database messenger
|
||||||
import useAuthStore from './authStore';
|
import useAuthStore from './authStore';
|
||||||
|
|
||||||
export const useDashboardStore = create((set, get) => ({
|
export const useDashboardStore = create((set, get) => ({
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. STATE (The Data)
|
// 1. STATE (The Data)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
isEditing: false,
|
||||||
|
widgets: [], // The master array of what is currently on the screen
|
||||||
|
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 2. ACTIONS (The Logic)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
enterEditMode: () => set({
|
||||||
|
isEditing: true,
|
||||||
|
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
|
||||||
|
}),
|
||||||
|
|
||||||
|
cancelEdit: () => set({
|
||||||
isEditing: false,
|
isEditing: false,
|
||||||
widgets: [], // The master array of what is currently on the screen
|
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
|
||||||
backupWidgets: [], // Holds the layout snapshot for the "Cancel" button
|
}),
|
||||||
|
|
||||||
// ==========================================
|
saveEdit: async () => {
|
||||||
// 2. ACTIONS (The Logic)
|
// 1. Grab the exact current state of the grid
|
||||||
// ==========================================
|
const currentLayout = get().widgets;
|
||||||
|
|
||||||
enterEditMode: () => set({
|
// 2. Lock the UI immediately so the user feels zero lag
|
||||||
isEditing: true,
|
set({isEditing: false, backupWidgets: []});
|
||||||
backupWidgets: get().widgets // 📸 Take a snapshot of current positions
|
|
||||||
}),
|
|
||||||
|
|
||||||
cancelEdit: () => set({
|
// 3. Reach outside this store and trigger the Django API call!
|
||||||
isEditing: false,
|
try {
|
||||||
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
|
await useAuthStore.getState().saveDashboardLayout(currentLayout);
|
||||||
}),
|
} catch (error) {
|
||||||
|
console.error("Failed to sync layout to MariaDB:", error);
|
||||||
|
// If the database save fails, you could technically revert the UI here if needed
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
saveEdit: async () => {
|
updateWidgetSettings: (id, newSettings) => set((state) => {
|
||||||
// 1. Grab the exact current state of the grid
|
// console.log("2. Zustand updating widget:", id, "with:", newSettings);
|
||||||
const currentLayout = get().widgets;
|
return {
|
||||||
|
widgets: state.widgets.map(widget =>
|
||||||
|
widget.id === id
|
||||||
|
? {...widget, settings: {...widget.settings, ...newSettings}}
|
||||||
|
: widget
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
// 2. Lock the UI immediately so the user feels zero lag
|
addWidget: (widgetId, sizeKey) => {
|
||||||
set({ isEditing: false, backupWidgets: [] });
|
const registryEntry = WIDGET_REGISTRY[widgetId];
|
||||||
|
|
||||||
// 3. Reach outside this store and trigger the Django API call!
|
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
|
||||||
try {
|
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
|
||||||
await useAuthStore.getState().saveDashboardLayout(currentLayout);
|
return;
|
||||||
} catch (error) {
|
}
|
||||||
console.error("Failed to sync layout to MariaDB:", error);
|
|
||||||
// If the database save fails, you could technically revert the UI here if needed
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
updateWidgetSettings: (id, newSettings) => set((state) => {
|
const dimensions = registryEntry.allowedSizes[sizeKey];
|
||||||
console.log("2. Zustand updating widget:", id, "with:", newSettings);
|
const widgets = get().widgets;
|
||||||
return {
|
|
||||||
widgets: state.widgets.map(widget =>
|
|
||||||
widget.id === id
|
|
||||||
? { ...widget, settings: { ...widget.settings, ...newSettings } }
|
|
||||||
: widget
|
|
||||||
)
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
|
|
||||||
addWidget: (widgetId, sizeKey) => {
|
let startX = 0;
|
||||||
const registryEntry = WIDGET_REGISTRY[widgetId];
|
let startY = Infinity;
|
||||||
|
|
||||||
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
|
if (widgets.length > 0) {
|
||||||
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
|
const bottomRowY = Math.max(...widgets.map(w => w.y));
|
||||||
return;
|
const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
|
||||||
}
|
const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
|
||||||
|
|
||||||
const dimensions = registryEntry.allowedSizes[sizeKey];
|
if (rightmostEdge + dimensions.w <= 12) {
|
||||||
const widgets = get().widgets;
|
startX = rightmostEdge; // ...tuck it right next to the last widget!
|
||||||
|
startY = bottomRowY; // ...and keep it on the same row.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newWidget = {
|
||||||
|
id: uuid(),
|
||||||
|
type: widgetId,
|
||||||
|
size: sizeKey,
|
||||||
|
settings: registryEntry.defaultSettings,
|
||||||
|
x: startX,
|
||||||
|
y: startY,
|
||||||
|
w: dimensions.w,
|
||||||
|
h: dimensions.h
|
||||||
|
};
|
||||||
|
console.log("newWidget: ", newWidget);
|
||||||
|
set((state) => ({widgets: [...state.widgets, newWidget]}));
|
||||||
|
},
|
||||||
|
|
||||||
let startX = 0;
|
removeWidget: (id) => set((state) => ({
|
||||||
let startY = Infinity;
|
widgets: state.widgets.filter(w => w.id !== id)
|
||||||
|
})),
|
||||||
|
|
||||||
if (widgets.length > 0) {
|
updateLayoutPositions: (rglLayout) => {
|
||||||
const bottomRowY = Math.max(...widgets.map(w => w.y));
|
set((state) => ({
|
||||||
const bottomRowWidgets = widgets.filter(w => w.y === bottomRowY);
|
widgets: state.widgets.map(widget => {
|
||||||
const rightmostEdge = Math.max(...bottomRowWidgets.map(w => w.x + w.w));
|
const gridItem = rglLayout.find(l => l.i === widget.id);
|
||||||
|
return gridItem ? {...widget, x: gridItem.x, y: gridItem.y} : widget;
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
if (rightmostEdge + dimensions.w <= 12) {
|
// NEW: Action to hydrate the store on initial page load
|
||||||
startX = rightmostEdge; // ...tuck it right next to the last widget!
|
setWidgets: (savedWidgets) => set({widgets: savedWidgets || []})
|
||||||
startY = bottomRowY; // ...and keep it on the same row.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newWidget = {
|
|
||||||
id: uuid(),
|
|
||||||
type: widgetId,
|
|
||||||
size: sizeKey,
|
|
||||||
x: startX,
|
|
||||||
y: startY,
|
|
||||||
w: dimensions.w,
|
|
||||||
h: dimensions.h
|
|
||||||
};
|
|
||||||
|
|
||||||
set((state) => ({ widgets: [...state.widgets, newWidget] }));
|
|
||||||
},
|
|
||||||
|
|
||||||
removeWidget: (id) => set((state) => ({
|
|
||||||
widgets: state.widgets.filter(w => w.id !== id)
|
|
||||||
})),
|
|
||||||
|
|
||||||
updateLayoutPositions: (rglLayout) => {
|
|
||||||
set((state) => ({
|
|
||||||
widgets: state.widgets.map(widget => {
|
|
||||||
const gridItem = rglLayout.find(l => l.i === widget.id);
|
|
||||||
return gridItem ? { ...widget, x: gridItem.x, y: gridItem.y } : widget;
|
|
||||||
})
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// NEW: Action to hydrate the store on initial page load
|
|
||||||
setWidgets: (savedWidgets) => set({ widgets: savedWidgets || [] })
|
|
||||||
}));
|
}));
|
||||||
Reference in New Issue
Block a user