adding working IVANS widget

This commit is contained in:
2026-07-30 09:18:47 -07:00
parent bb1e55d796
commit 8525dcc71c
8 changed files with 684 additions and 435 deletions
@@ -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';
+312 -122
View File
@@ -1,83 +1,21 @@
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
return ( const seven_day_url = useMemo(() => {
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>Summary</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Text>{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 open={open} onOpenChange={onOpenChange}>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Flex width="100%" direction="row" align="center" justifyContent="space-between">
<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' const base_url = '/api/v2/comm_coor_ivans'
let today = new Date(); let today = new Date();
let sevenDaysAgo = new Date(today); let sevenDaysAgo = new Date(today);
@@ -86,13 +24,104 @@ export default function IvansAlerts() {
sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA'); sevenDaysAgo = sevenDaysAgo.toLocaleDateString('en-CA');
today = today.toLocaleDateString('en-CA'); today = today.toLocaleDateString('en-CA');
const url_with_dates = base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today; return base_url + '?start_date=' + sevenDaysAgo + '&end_date=' + today;
return url_with_dates; }, []);
// 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);
} }
const seven_day_url = get_7_day_range_url(); console.log("sorted data: ", sorted);
return sorted;
}, [apiData, sorts]);
const {data: apiData, isLoading, error} = VdapApi(seven_day_url); 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 ( if (isLoading) return (
<Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center"> <Flex w="100%" h="100%" bg="gray.800" borderRadius="md" align="center" justify="center">
@@ -106,59 +135,220 @@ export default function IvansAlerts() {
</Flex> </Flex>
); );
if (apiData.length === 0) return ( if (data.length === 0) return (
<Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md"> <Flex height="100%" width="100%" align="center" justify="center" bg="gray.800" borderRadius="md">
<Text color="gray.400">No flight level data available.</Text> <Text color="gray.400">No flight level data available this week.</Text>
</Flex> </Flex>
); );
return ( return (
<Flex w="100%" h="100%" direction="column" bg="gray.800" borderRadius="md" align="center" justify="center" p={4}> <Flex w="100%" h="100%" direction="column" bg="gray.800" borderRadius="xl" align="center" justify="center" gap={2} p={2} overflow="auto">
<Flex direction="row" gap={4}> {/*<Grid w="100%" h="100%" templateColumns="repeat(6, 1fr)" alignItems="center">*/}
<Text color="gray.400">Volcano</Text> {/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={4}>Volcano</GridItem>*/}
<Text color="gray.400">Report Number</Text> {/* <GridItem colSpan={1} color="gray.100" fontWeight="bold">Report Number</GridItem>*/}
<Text color="gray.400">Report Send Time</Text> {/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" pl={3}>Report Send Time</GridItem>*/}
<Text color="gray.400">Preparer</Text> {/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Preparer</GridItem>*/}
<Text color="gray.400">Summary</Text> {/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Summary</GridItem>*/}
<Text color="gray.400">Attachments</Text> {/* <GridItem colSpan={1} color="gray.100" fontWeight="bold" textAlign="center">Attachments</GridItem>*/}
</Flex> {/*</Grid>*/}
{apiData.map((item, index) => { {data.map((item, index) => {
const context_str = item.volcano_name + " " + "•" + " " + item.send_time; const context_string = item.volcano_name + ' • ' + item.send_time;
return ( return (
<Flex direction="row" key={index} gap={4}> <Grid w="100%" h="100%" templateColumns="repeat(8, 1fr)" key={index} borderRadius="lg" bg="gray.700" alignItems="center" justifyContent="center">
<Text color="gray.400">{item.volcano_name}</Text> <GridItem colSpan={2} color="gray.100" pl={4}>{item.volcano_name}</GridItem>
<Text color="gray.400">{item.report_num}</Text> <GridItem colSpan={1} color="gray.100">{item.report_num}</GridItem>
<Text color="gray.400">{item.send_time}</Text> <GridItem colSpan={2} color="gray.100">{item.send_time}</GridItem>
<Text color="gray.400">{item.preparer}</Text> <GridItem colSpan={1} color="gray.100">{item.preparer}</GridItem>
<Button onClick={() => setIsSummaryOpen(true)} color="gray.400">View</Button> <GridItem colSpan={1}>
<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> <Button w="7rem" h={0} mb={1} onClick={() => setActiveSummaryItem(item)} color="purple.300" variant="plain" textDecor="underline">View Summary</Button>
<FileDialog <SummaryDialog
open={isFileOpen} open={!!activeSummaryItem}
onOpenChange={(e) => setIsFileOpen(e.open)} onOpenChange={() => setActiveSummaryItem(null)}
item={item} item={activeSummaryItem}
context={context_str} context={context_string}
/> />
</> </>
</GridItem>
<GridItem colSpan={1}>
<Flex direction="column" justify="center" alignItems="center" gap={0}>
{item.files.length === 0 ? (
<Text color="gray.600" fontSize="sm">No Attachments</Text>
):(
item.files.map((file, index) => {
if (file.type === "pdf") return (
<div key={index}>
<Button w="7rem" h={0} mb={1} onClick={() => setActiveFileItem(file)} color="purple.300" variant="plain" textDecor="underline">View PDF</Button>
<FileDialog
open={!!activeFileItem}
onOpenChange={() => setActiveFileItem(null)}
item={activeFileItem}
context={context_string}
/>
</div>
)
else if (file.type === "image") return (
<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> </Flex>
)
})}
<Flex>
</Flex>
</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>
</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>
)
}
+11 -5
View File
@@ -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 }
+21 -20
View File
@@ -1,14 +1,14 @@
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);
@@ -21,7 +21,7 @@ export default function MyDashboard() {
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);
@@ -88,23 +88,23 @@ export default function MyDashboard() {
{/* 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 || {}}
@@ -114,14 +114,15 @@ export default function MyDashboard() {
</Popover.Positioner> </Popover.Positioner>
</Popover.Root> </Popover.Root>
) : ( ) : (
<Box w="20px" h="20px" /> <Box w="20px" h="20px"/>
)} )}
</Flex> </Flex>
{/* THE CHART CANVAS */} {/* THE CHART CANVAS */}
<Box flex="1" overflow="hidden" position="relative" p={1}> <Box flex="1" overflow="hidden" position="relative" p={1}>
{/* Pass the settings object into the actual chart component */} {/* Pass the settings object into the actual chart component */}
<Component settings={widget.settings || {}} /> {/* Pass widget ID to allow updating settings in widget component rather than just in settings component */}
<Component settings={widget.settings || {}} widgetID={widget.widgetID || {}}/>
</Box> </Box>
</Flex> </Flex>
@@ -141,9 +142,9 @@ export default function MyDashboard() {
<Box width="100%" height="100%"> <Box width="100%" height="100%">
<ResponsiveGridLayout <ResponsiveGridLayout
className="layout" className="layout"
layouts={{ lg: rglLayout }} // We feed it the formatted layout map layouts={{lg: rglLayout}} // We feed it the formatted layout map
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }} 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 cols={{lg: 12, md: 10, sm: 6, xs: 4, xxs: 2}} // Our 12-column master grid
rowHeight={30} rowHeight={30}
isDraggable={isEditing} // Only allows dragging when Settings tools toggle this isDraggable={isEditing} // Only allows dragging when Settings tools toggle this
isResizable={false} // Double lock-down isResizable={false} // Double lock-down
@@ -175,20 +176,20 @@ export default function MyDashboard() {
color={iconColor} color={iconColor}
border="1px solid" border="1px solid"
borderColor="gray.200" borderColor="gray.200"
_hover={{ bg: "red.50", color: "red.500", borderColor: "red.200" }} _hover={{bg: "red.50", color: "red.500", borderColor: "red.200"}}
zIndex={2} zIndex={2}
onClick={() => removeWidget(widget.id)} onClick={() => removeWidget(widget.id)}
onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget onMouseDown={(e) => e.stopPropagation()} // 🚫 Stops RGL from grabbing the widget
onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches onTouchStart={(e) => e.stopPropagation()} // 🚫 Same protection for mobile touches
> >
<X size={14} /> <X size={14}/>
</IconButton> </IconButton>
)} )}
{/* 4. SUSPENSE BOUNDARY wrapping the render function */} {/* 4. SUSPENSE BOUNDARY wrapping the render function */}
<Suspense fallback={ <Suspense fallback={
<Flex w="100%" h="100%" align="center" justify="center" bg="gray.800"> <Flex w="100%" h="100%" align="center" justify="center" bg="gray.800">
<Spinner size="md" color="blue.400" thickness="3px" /> <Spinner size="md" color="blue.400" thickness="3px"/>
</Flex> </Flex>
}> }>
{renderWidget(widget)} {renderWidget(widget)}
+11 -10
View File
@@ -1,6 +1,6 @@
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';
@@ -32,7 +32,7 @@ export const useDashboardStore = create((set, get) => ({
const currentLayout = get().widgets; const currentLayout = get().widgets;
// 2. Lock the UI immediately so the user feels zero lag // 2. Lock the UI immediately so the user feels zero lag
set({ isEditing: false, backupWidgets: [] }); set({isEditing: false, backupWidgets: []});
// 3. Reach outside this store and trigger the Django API call! // 3. Reach outside this store and trigger the Django API call!
try { try {
@@ -44,11 +44,11 @@ export const useDashboardStore = create((set, get) => ({
}, },
updateWidgetSettings: (id, newSettings) => set((state) => { updateWidgetSettings: (id, newSettings) => set((state) => {
console.log("2. Zustand updating widget:", id, "with:", newSettings); // console.log("2. Zustand updating widget:", id, "with:", newSettings);
return { return {
widgets: state.widgets.map(widget => widgets: state.widgets.map(widget =>
widget.id === id widget.id === id
? { ...widget, settings: { ...widget.settings, ...newSettings } } ? {...widget, settings: {...widget.settings, ...newSettings}}
: widget : widget
) )
}; };
@@ -82,13 +82,14 @@ export const useDashboardStore = create((set, get) => ({
id: uuid(), id: uuid(),
type: widgetId, type: widgetId,
size: sizeKey, size: sizeKey,
settings: registryEntry.defaultSettings,
x: startX, x: startX,
y: startY, y: startY,
w: dimensions.w, w: dimensions.w,
h: dimensions.h h: dimensions.h
}; };
console.log("newWidget: ", newWidget);
set((state) => ({ widgets: [...state.widgets, newWidget] })); set((state) => ({widgets: [...state.widgets, newWidget]}));
}, },
removeWidget: (id) => set((state) => ({ removeWidget: (id) => set((state) => ({
@@ -99,11 +100,11 @@ export const useDashboardStore = create((set, get) => ({
set((state) => ({ set((state) => ({
widgets: state.widgets.map(widget => { widgets: state.widgets.map(widget => {
const gridItem = rglLayout.find(l => l.i === widget.id); const gridItem = rglLayout.find(l => l.i === widget.id);
return gridItem ? { ...widget, x: gridItem.x, y: gridItem.y } : widget; return gridItem ? {...widget, x: gridItem.x, y: gridItem.y} : widget;
}) })
})); }));
}, },
// NEW: Action to hydrate the store on initial page load // NEW: Action to hydrate the store on initial page load
setWidgets: (savedWidgets) => set({ widgets: savedWidgets || [] }) setWidgets: (savedWidgets) => set({widgets: savedWidgets || []})
})); }));