diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000..9bc42d2
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {
+ "associatedIndex": 7,
+ "fromUser": false
+}
+
+
+
+
+
+ {
+ "keyToString": {
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
+ "RunOnceActivity.ShowReadmeOnStart": "true",
+ "RunOnceActivity.git.unshallow": "true",
+ "RunOnceActivity.typescript.service.memoryLimit.init": "true",
+ "codeWithMe.voiceChat.enabledByDefault": "false",
+ "git-widget-placeholder": "20260605__1123__Hight__updates",
+ "javascript.preferred.runtime.type.id": "node",
+ "last_opened_file_path": "/Users/chight/Documents/Development/website-framework",
+ "node.js.detected.package.eslint": "true",
+ "node.js.selected.package.eslint": "(autodetect)",
+ "nodejs_package_manager_path": "npm",
+ "npm.build.executor": "Run",
+ "npm.dev.executor": "Run",
+ "ts.external.directory.path": "/Users/chight/Applications/WebStorm.app/Contents/plugins/javascript-plugin/jsLanguageServicesImpl/external",
+ "vue.rearranger.settings.migration": "true"
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1776977196451
+
+
+ 1776977196451
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/package-lock.json b/client/package-lock.json
index 8497895..2853d1b 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -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
+ }
+ }
}
}
}
diff --git a/client/package.json b/client/package.json
index df9ea57..4753b79 100644
--- a/client/package.json
+++ b/client/package.json
@@ -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",
diff --git a/client/src/App.jsx b/client/src/App.jsx
index c18c32e..f53c024 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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 }) => (
);
function App() {
- const sidebarContext = createContext(["Home", "Global Map", "Regional Map", "Volcano", "Admin"]);
- // const [user, setUser] = useState(null);
-
return (
{/*Public Routes*/}
@@ -63,8 +61,8 @@ function App() {
}>
{/* We use our "pass" component to fill the Outlet until the real pages are built */}
- } />
- } />
+ } />
+ } />
} />
} />
} />
diff --git a/client/src/components/Canvas/CanvasComponents/FirstWidgetButton.jsx b/client/src/components/Canvas/CanvasComponents/FirstWidgetButton.jsx
index 53816a5..a874d98 100644
--- a/client/src/components/Canvas/CanvasComponents/FirstWidgetButton.jsx
+++ b/client/src/components/Canvas/CanvasComponents/FirstWidgetButton.jsx
@@ -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 = (
diff --git a/client/src/components/Canvas/CanvasComponents/SettingsButton.jsx b/client/src/components/Canvas/CanvasComponents/SettingsButton.jsx
deleted file mode 100644
index d725fad..0000000
--- a/client/src/components/Canvas/CanvasComponents/SettingsButton.jsx
+++ /dev/null
@@ -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 = (<> Add Widget >);
- const WidgetMenuProps = { appendWidget, triggerItem }
- return (
-
-
-
-
-
-
-
-
-
-
-
- Edit Widgets
-
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/client/src/components/Canvas/CanvasComponents/WidgetMenu.jsx b/client/src/components/Canvas/CanvasComponents/WidgetMenu.jsx
index 0bf3f66..0e1d8f4 100644
--- a/client/src/components/Canvas/CanvasComponents/WidgetMenu.jsx
+++ b/client/src/components/Canvas/CanvasComponents/WidgetMenu.jsx
@@ -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 (
diff --git a/client/src/components/Canvas/views/Admin.jsx b/client/src/components/Canvas/views/Admin.jsx
index 8deb1d3..cab2e12 100644
--- a/client/src/components/Canvas/views/Admin.jsx
+++ b/client/src/components/Canvas/views/Admin.jsx
@@ -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';
diff --git a/client/src/components/Canvas/views/Home.jsx b/client/src/components/Canvas/views/Home.jsx
index 1ff06c7..cd3c1df 100644
--- a/client/src/components/Canvas/views/Home.jsx
+++ b/client/src/components/Canvas/views/Home.jsx
@@ -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 `` exactly
+ // 1. CSS making this look like how we want it, TODO turn to theme?
const tabStyles = {
fontSize: "lg",
fontWeight: "normal",
diff --git a/client/src/components/Canvas/views/HomeTabs/MyDashboard.jsx b/client/src/components/Canvas/views/HomeTabs/MyDashboard.jsx
index b7ba6cf..3f3a9eb 100644
--- a/client/src/components/Canvas/views/HomeTabs/MyDashboard.jsx
+++ b/client/src/components/Canvas/views/HomeTabs/MyDashboard.jsx
@@ -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 (
-
-
-
- );
- }
+ // 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 (
- }
- >
- { merged.map((item) => {
- const Widget = item.widget;
- return (
-
-
+
+ updateLayoutPositions(newLayout)} // Fires when user drops a widget
+ compactType="vertical" // Auto-packs widgets to the top
+ >
+ {/* 3. 🎨 Paint the Widgets */}
+ {widgets.map(widget => (
+
+ {/* 🚧 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.
+ */}
+
+ {widget.type}
+ Size: {widget.size}
+
+
+ ))}
+
+
+ {/* Empty State Helper */}
+ {widgets.length === 0 && (
+
+ Your dashboard is empty. Click Settings to add widgets.
- );
- })
- }
-
+ )}
+
);
-}
+}
\ No newline at end of file
diff --git a/client/src/components/Canvas/views/Volcano.jsx b/client/src/components/Canvas/views/Volcano.jsx
index 40ce215..ddb8e4a 100644
--- a/client/src/components/Canvas/views/Volcano.jsx
+++ b/client/src/components/Canvas/views/Volcano.jsx
@@ -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 (
diff --git a/client/src/components/SettingsButton.jsx b/client/src/components/SettingsButton.jsx
new file mode 100644
index 0000000..2534117
--- /dev/null
+++ b/client/src/components/SettingsButton.jsx
@@ -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 */}
+
+
+
+
+
+
+
+
+
+ {/* Dynamic Menu Options: What shows up depends on isEditing */}
+ {isEditing ? (
+ <>
+
+ Save Layout
+
+
+ Discard Changes
+
+ >
+ ) : (
+
+ Edit Layout
+
+ )}
+
+
+
+ setIsLibraryOpen(true)} cursor="pointer" _hover={{ bg: "gray.100" }}>
+ Widget Library
+
+
+
+
+
+ {/* THE WIDGET LIBRARY MODAL (Controlled by isLibraryOpen) */}
+ setIsLibraryOpen(e.open)} size="xl" placement="center">
+
+
+
+
+
+ Add Dashboard Widgets
+
+
+
+
+
+ {categories.map(cat => (
+ {cat}
+ ))}
+
+
+ {categories.map(cat => (
+
+
+ {categorizedWidgets[cat].map(widget => (
+
+ {widget.name}
+ {widget.description}
+
+
+ Add to Grid:
+
+ {Object.keys(widget.allowedSizes).map(sizeKey => (
+ 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"
+ >
+
+ {sizeKey.charAt(0).toUpperCase() + sizeKey.slice(1)}
+
+ ))}
+
+
+
+ ))}
+
+
+ ))}
+
+
+
+
+
+
+ >
+ );
+}
\ No newline at end of file
diff --git a/client/src/components/Canvas/CanvasComponents/AlertLevelWidget.jsx b/client/src/components/Widgets/AlertLevelWidget.jsx
similarity index 98%
rename from client/src/components/Canvas/CanvasComponents/AlertLevelWidget.jsx
rename to client/src/components/Widgets/AlertLevelWidget.jsx
index 345a2b9..ca546d6 100644
--- a/client/src/components/Canvas/CanvasComponents/AlertLevelWidget.jsx
+++ b/client/src/components/Widgets/AlertLevelWidget.jsx
@@ -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";
diff --git a/client/src/components/Canvas/CanvasComponents/Widget.jsx b/client/src/components/Widgets/Widget.jsx
similarity index 100%
rename from client/src/components/Canvas/CanvasComponents/Widget.jsx
rename to client/src/components/Widgets/Widget.jsx
diff --git a/client/src/constants/widgetRegistry.jsx b/client/src/constants/widgetRegistry.jsx
index 8d39b68..cf01d6c 100644
--- a/client/src/constants/widgetRegistry.jsx
+++ b/client/src/constants/widgetRegistry.jsx
@@ -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 },
diff --git a/client/src/store/useDashboardStore.jsx b/client/src/store/useDashboardStore.jsx
new file mode 100644
index 0000000..cafcc37
--- /dev/null
+++ b/client/src/store/useDashboardStore.jsx
@@ -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;
+ })
+ }));
+ }
+}));
\ No newline at end of file