Refactor updating how widgets work and removed prop drilling
This commit is contained in:
Generated
+32
-2
@@ -22,7 +22,8 @@
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
@@ -1635,7 +1636,7 @@
|
||||
"version": "19.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
|
||||
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4541,6 +4542,35 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.14",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
|
||||
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-resizable": "^3.0.5",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"uuid": "^11.1.0"
|
||||
"uuid": "^11.1.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
|
||||
+3
-5
@@ -11,6 +11,7 @@ import RegionalMap from "./components/Canvas/views/RegionalMap.jsx";
|
||||
import Volcano from "@/components/Canvas/views/Volcano.jsx";
|
||||
import GlobalMap from "./components/Canvas/views/GlobalMap.jsx";
|
||||
import Admin from "./components/Canvas/views/Admin.jsx";
|
||||
import MyDashboard from "./components/Canvas/views/HomeTabs/MyDashboard.jsx";
|
||||
|
||||
function AppFoundation() {
|
||||
const recipe = useRecipe({ key: "dashboard" });
|
||||
@@ -50,9 +51,6 @@ const Placeholder = ({ name }) => (
|
||||
</Flex>
|
||||
);
|
||||
function App() {
|
||||
const sidebarContext = createContext(["Home", "Global Map", "Regional Map", "Volcano", "Admin"]);
|
||||
// const [user, setUser] = useState(null);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/*Public Routes*/}
|
||||
@@ -63,8 +61,8 @@ function App() {
|
||||
|
||||
<Route path="/home" element={<Home />}>
|
||||
{/* We use our "pass" component to fill the Outlet until the real pages are built */}
|
||||
<Route index element={<Placeholder name="My" />} />
|
||||
<Route path="gas" element={<Placeholder name="Gas" />} />
|
||||
<Route index element={<MyDashboard />} />
|
||||
<Route path="gas" element={<MyDashboard />} />
|
||||
<Route path="seismic" element={<Placeholder name="Seismic" />} />
|
||||
<Route path="remote" element={<Placeholder name="Remote Sensing" />} />
|
||||
<Route path="daily" element={<Placeholder name="Daily Activity" />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Menu, Portal } from '@chakra-ui/react'
|
||||
import WidgetMenu from './WidgetMenu';
|
||||
import AlertLevel from "@/components/Canvas/CanvasComponents/AlertLevelWidget.jsx";
|
||||
import AlertLevel from "@/components/Widgets/AlertLevelWidget.jsx";
|
||||
|
||||
export default function FirstWidgetButton({ appendWidget }) {
|
||||
const triggerItem = (
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { IconButton, Menu, Portal } from "@chakra-ui/react";
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import WidgetMenu from "./WidgetMenu";
|
||||
|
||||
export function SettingsButton({ onEditWidgets, appendWidget}) {
|
||||
const triggerItem = (<> <Menu.TriggerItem> <ArrowLeft /> Add Widget</Menu.TriggerItem> </>);
|
||||
const WidgetMenuProps = { appendWidget, triggerItem }
|
||||
return (
|
||||
<Menu.Root>
|
||||
<Menu.Trigger asChild >
|
||||
<IconButton variant="ghost" size="md" borderRadius="xl">
|
||||
<Settings />
|
||||
</IconButton>
|
||||
</Menu.Trigger>
|
||||
<Portal>
|
||||
<Menu.Positioner>
|
||||
<Menu.Content>
|
||||
<WidgetMenu {...WidgetMenuProps}/>
|
||||
<Menu.Item value="eidt" onClick={onEditWidgets} >
|
||||
Edit Widgets
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Positioner>
|
||||
</Portal>
|
||||
</Menu.Root>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Menu, Portal } from "@chakra-ui/react";
|
||||
import AlertLevel from "./AlertLevelWidget";
|
||||
import AlertLevel from "../../Widgets/AlertLevelWidget.jsx";
|
||||
|
||||
export default function WidgetMenu({appendWidget, triggerItem, positioning}) {
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ProjectCollector } from './AdminTabs/ProjectCollector';
|
||||
import { NASApplication } from './AdminTabs/NASApplication';
|
||||
import { IVANS } from './AdminTabs/IVANS'
|
||||
import { SearchBar } from "../CanvasComponents/SearchBar";
|
||||
import { SettingsButton } from "../CanvasComponents/SettingsButton";
|
||||
import { SettingsButton } from "../../SettingsButton.jsx";
|
||||
import { CanvasHeaderTabs } from '../CanvasComponents/CanvasHeaderTabs';
|
||||
import { ADMIN_TABS } from '@/constants/viewKeys';
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { HStack, Flex, Box } from "@chakra-ui/react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { SearchBar } from "../CanvasComponents/SearchBar";
|
||||
import { SettingsButton } from "../CanvasComponents/SettingsButton";
|
||||
import { SettingsButton } from "../../SettingsButton.jsx";
|
||||
|
||||
export default function Home() {
|
||||
const location = useLocation();
|
||||
const isPersonalDashboard = location.pathname === "/home" || location.pathname === "/home/";
|
||||
|
||||
// 1. 🎨 CSS Magic: Replicates Chakra's `<Tabs variant="line">` exactly
|
||||
// 1. CSS making this look like how we want it, TODO turn to theme?
|
||||
const tabStyles = {
|
||||
fontSize: "lg",
|
||||
fontWeight: "normal",
|
||||
|
||||
@@ -1,74 +1,70 @@
|
||||
import GridLayout, { WidthProvider } from 'react-grid-layout';
|
||||
import { useSlotRecipe, chakra, Flex, SimpleGrid } from '@chakra-ui/react';
|
||||
import CustomResizeHandle from '../../CanvasComponents/CustomResizeHandle';
|
||||
import FirstWidgetButton from '../../CanvasComponents/FirstWidgetButton';
|
||||
import { Box, Text, Flex } from "@chakra-ui/react";
|
||||
import { Responsive, WidthProvider } from "react-grid-layout";
|
||||
import { useDashboardStore } from "@/store/useDashboardStore.jsx";
|
||||
|
||||
export default function MyDashboard({layout, onLayoutChange, isEditable, isDelete, DeleteWidget, widgetArray, appendWidget}) {
|
||||
const recipe = useSlotRecipe({ key: "widget" });
|
||||
const styles = recipe(); // using root recipe for widget below
|
||||
const ResponsiveGridLayout = WidthProvider(GridLayout); // canvas width now adjusts to window size
|
||||
const handleDragStart = () => { document.body.style.userSelect = "none"; }; // don't allow text highlighting when dragging
|
||||
const handleDragStop = () => { document.body.style.userSelect = ""; }; // allow text highlighting all other times
|
||||
let merged = [];
|
||||
// React-Grid-Layout requires this wrapper to automatically calculate screen width
|
||||
const ResponsiveGridLayout = WidthProvider(Responsive);
|
||||
|
||||
if (widgetArray.length && layout.length) {
|
||||
const widgetMap = new Map(widgetArray.map(item => [item.i, item]));
|
||||
merged = layout.map(item => {
|
||||
return {
|
||||
...item,
|
||||
...widgetMap.get(item.i)
|
||||
};
|
||||
});
|
||||
}
|
||||
export default function MyDashboard() {
|
||||
// 1. ☁️ Connect to the Cloud (Zustand)
|
||||
const widgets = useDashboardStore(state => state.widgets);
|
||||
const isEditing = useDashboardStore(state => state.isEditing);
|
||||
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
|
||||
|
||||
// Show large 'Add Widget' button when dashboard is empty
|
||||
if ( !layout.length ) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
h="100%"
|
||||
w="100%"
|
||||
bg="gray.150"
|
||||
borderRadius="md"
|
||||
>
|
||||
<FirstWidgetButton appendWidget={ appendWidget } />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
// 2. 🛠️ Format the data for RGL
|
||||
// RGL expects a very specific array format for its layout prop
|
||||
const rglLayout = widgets.map(w => ({
|
||||
i: w.id,
|
||||
x: w.x,
|
||||
y: w.y,
|
||||
w: w.w,
|
||||
h: w.h,
|
||||
isResizable: false // 🚫 The magic lock! No resizing allowed globally.
|
||||
}));
|
||||
|
||||
// Show responsive grid of widgets
|
||||
return (
|
||||
<ResponsiveGridLayout
|
||||
useCSSTransforms={false} // remove the sliding animation of the widgets
|
||||
className="layout"
|
||||
layout={layout}
|
||||
cols={16} // Total columns in grid
|
||||
rowHeight={40} // Height of a single row in px
|
||||
isBounded={true} // Total width of the grid
|
||||
margin={[15, 5]} // [horizontal, vertical] gap
|
||||
onLayoutChange={onLayoutChange}
|
||||
draggableHandle=".widget-handle" // Make only the header draggable
|
||||
onDragStart={handleDragStart}
|
||||
onDragStop={handleDragStop}
|
||||
position="relative"
|
||||
resizeHandle={<CustomResizeHandle isVisable={isEditable} />}
|
||||
>
|
||||
{ merged.map((item) => {
|
||||
const Widget = item.widget;
|
||||
return (
|
||||
<Flex key={item.i} css={styles.root} boxShadow="md" height="100%" >
|
||||
<Widget
|
||||
id={item.i}
|
||||
isEditable={isEditable}
|
||||
isDelete={isDelete}
|
||||
DeleteWidget={DeleteWidget}
|
||||
appendWidget={appendWidget}
|
||||
/>
|
||||
<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 your 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"}
|
||||
>
|
||||
{/* 🚧 Temporary Placeholder!
|
||||
Eventually, we will swap this block out for a dynamic component loader
|
||||
that looks at widget.type and loads the correct graph or table.
|
||||
*/}
|
||||
<Flex width="100%" height="100%" align="center" justify="center" direction="column">
|
||||
<Text fontWeight="bold" color="gray.600">{widget.type}</Text>
|
||||
<Text fontSize="sm" color="gray.400">Size: {widget.size}</Text>
|
||||
</Flex>
|
||||
</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>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ResponsiveGridLayout>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { Reports } from './VolcanoTabs/Reports';
|
||||
import { OfficialInstitutions } from './VolcanoTabs/OfficialInstitutions';
|
||||
import { SocialMedia } from './VolcanoTabs/SocialMedia';
|
||||
import { SearchBar } from "../CanvasComponents/SearchBar";
|
||||
import { SettingsButton } from "../CanvasComponents/SettingsButton";
|
||||
import { SettingsButton } from "../../SettingsButton.jsx";
|
||||
|
||||
export default function Volcano() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
Box,
|
||||
Text,
|
||||
Flex,
|
||||
Grid,
|
||||
Dialog,
|
||||
Tabs,
|
||||
Badge,
|
||||
Menu,
|
||||
HStack
|
||||
} 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";
|
||||
|
||||
export function SettingsButton() {
|
||||
// 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);
|
||||
|
||||
// 2. 🎛️ Local state to control the Widget Library popup independently of the dropdown
|
||||
const [isLibraryOpen, setIsLibraryOpen] = useState(false);
|
||||
|
||||
// 3. 🧠 Categorize widgets for the tabs
|
||||
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);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* THE STATIC DROPDOWN MENU */}
|
||||
<Menu.Root>
|
||||
<Menu.Trigger asChild>
|
||||
<IconButton
|
||||
aria-label="Settings"
|
||||
bg="vdap.darkGreen" // Or #1a2e24
|
||||
color="white"
|
||||
size="sm"
|
||||
borderRadius="md"
|
||||
_hover={{ bg: "vdap.darkGreenHover" }} // Or #111f18
|
||||
>
|
||||
<Settings size={18} />
|
||||
</IconButton>
|
||||
</Menu.Trigger>
|
||||
|
||||
<Menu.Content bg="white" boxShadow="lg" borderRadius="md" p={1} zIndex="dropdown">
|
||||
|
||||
{/* Dynamic Menu Options: What shows up depends on isEditing */}
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Menu.Item onClick={saveEdit} color="green.600" fontWeight="bold" cursor="pointer" _hover={{ bg: "green.50" }}>
|
||||
<Save size={16} style={{ marginRight: '8px' }} /> Save Layout
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={cancelEdit} color="red.500" cursor="pointer" _hover={{ bg: "red.50" }}>
|
||||
<X size={16} style={{ marginRight: '8px' }} /> Discard Changes
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item onClick={enterEditMode} cursor="pointer" _hover={{ bg: "gray.100" }}>
|
||||
<Move size={16} style={{ marginRight: '8px' }} /> Edit Layout
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Separator my={1} borderColor="gray.200" />
|
||||
|
||||
<Menu.Item onClick={() => setIsLibraryOpen(true)} cursor="pointer" _hover={{ bg: "gray.100" }}>
|
||||
<LayoutGrid size={16} style={{ marginRight: '8px' }} /> Widget Library
|
||||
</Menu.Item>
|
||||
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
|
||||
{/* THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
|
||||
<Dialog.Root open={isLibraryOpen} onOpenChange={(e) => setIsLibraryOpen(e.open)} size="xl" placement="center">
|
||||
<Dialog.Backdrop bg="blackAlpha.600" />
|
||||
<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>
|
||||
|
||||
<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={() => addWidget(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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import Widget from "./Widget";
|
||||
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";
|
||||
@@ -9,6 +9,7 @@
|
||||
export const WIDGET_REGISTRY = {
|
||||
"vaa_activity": {
|
||||
name: "VAA 7 Day Activity",
|
||||
category: "Aviation",
|
||||
description: "Displays 7-day flight level timelines across multiple volcanoes.",
|
||||
allowedSizes: {
|
||||
large: { w: 12, h: 8 }, // Spans the entire width of the screen
|
||||
@@ -17,6 +18,7 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
"db_cache_health": {
|
||||
name: "DB Cache Health",
|
||||
category: "Computer Science",
|
||||
description: "Bar chart showing cache table operational status.",
|
||||
allowedSizes: {
|
||||
medium: { w: 4, h: 6 }, // 1/3 width
|
||||
@@ -26,6 +28,7 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
"ivans_alerts": {
|
||||
name: "IVANS",
|
||||
category: "Communications",
|
||||
description: "Feed of recent IVANS reports and reviewer statuses.",
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 6 }, // 1/4 width (tall and skinny)
|
||||
@@ -35,6 +38,7 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
"remote_sensing": {
|
||||
name: "Remote Sensing",
|
||||
category: "Remote Sensing",
|
||||
description: "Feed of recent remote sensing reports.",
|
||||
allowedSizes: {
|
||||
medium: { w: 4, h: 5 }, // 1/3 width
|
||||
@@ -43,6 +47,7 @@ export const WIDGET_REGISTRY = {
|
||||
|
||||
"alert_levels": {
|
||||
name: "Alert Level Changes",
|
||||
category: "Daily Alerts",
|
||||
description: "List of 7-day alert level changes with trend arrows.",
|
||||
allowedSizes: {
|
||||
small: { w: 3, h: 5 },
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { create } from 'zustand';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { WIDGET_REGISTRY } from '../constants/widgetRegistry.jsx';
|
||||
|
||||
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({
|
||||
isEditing: false,
|
||||
widgets: get().backupWidgets // ⏪ Restore snapshot, instantly undoing any drags
|
||||
}),
|
||||
|
||||
saveEdit: async () => {
|
||||
const payload = get().widgets;
|
||||
|
||||
// 💾 This is perfectly formatted for your backend DB right now
|
||||
console.log("Saving dashboard to API:", JSON.stringify(payload, null, 2));
|
||||
|
||||
// Example: await fetch('/api/user/dashboard', { method: 'POST', body: JSON.stringify(payload) });
|
||||
|
||||
set({ isEditing: false, backupWidgets: [] });
|
||||
},
|
||||
|
||||
addWidget: (widgetId, sizeKey) => {
|
||||
// Look up the exact rules for this widget from your constant
|
||||
const registryEntry = WIDGET_REGISTRY[widgetId];
|
||||
|
||||
if (!registryEntry || !registryEntry.allowedSizes[sizeKey]) {
|
||||
console.error(`Invalid widget ID or Size: ${widgetId} - ${sizeKey}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dimensions = registryEntry.allowedSizes[sizeKey];
|
||||
|
||||
const newWidget = {
|
||||
id: uuid(),
|
||||
type: widgetId,
|
||||
size: sizeKey,
|
||||
x: 0,
|
||||
y: Infinity, // RGL's auto-packer drops this exactly into the first available slot at the bottom
|
||||
w: dimensions.w,
|
||||
h: dimensions.h
|
||||
};
|
||||
|
||||
set((state) => ({ widgets: [...state.widgets, newWidget] }));
|
||||
},
|
||||
|
||||
removeWidget: (id) => set((state) => ({
|
||||
widgets: state.widgets.filter(w => w.id !== id)
|
||||
})),
|
||||
|
||||
// Called automatically by React-Grid-Layout when the user lets go of the mouse
|
||||
updateLayoutPositions: (rglLayout) => {
|
||||
set((state) => ({
|
||||
widgets: state.widgets.map(widget => {
|
||||
// Find where RGL says this widget currently is
|
||||
const gridItem = rglLayout.find(l => l.i === widget.id);
|
||||
|
||||
// Only update X and Y. We completely ignore W and H so users can't resize!
|
||||
return gridItem ? { ...widget, x: gridItem.x, y: gridItem.y } : widget;
|
||||
})
|
||||
}));
|
||||
}
|
||||
}));
|
||||
Reference in New Issue
Block a user