Files
Web-Frontend/client/src/pages/home/tabs/MyDashboard.jsx
T
vkuchenik 91d77b8bd4 fix: ivans, rs, and al were failing to load data
MyDashboard was not passing widgetID to widget components
2026-09-16 09:54:00 -07:00

184 lines
6.8 KiB
React

import {Box, Text, Flex, IconButton, Spinner, Popover, useSlotRecipe} from "@chakra-ui/react";
import {X, MoreVertical} from "lucide-react";
import {Responsive, WidthProvider} from "react-grid-layout";
import {useDashboardStore} from "@/store/useDashboardStore.jsx";
import {Suspense, useEffect, useRef} from 'react';
// 1. Import database messenger
import useAuthStore from "@/store/authStore";
// 2. Import registry
import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx';
// React-Grid-Layout requires this wrapper to automatically calculate screen width
const ResponsiveGridLayout = WidthProvider(Responsive);
export default function MyDashboard() {
// ☁️ Database Connection
const {user} = useAuthStore();
// ☁️ Local UI Connections
const widgets = useDashboardStore(state => state.widgets);
const isEditing = useDashboardStore(state => state.isEditing);
const updateLayoutPositions = useDashboardStore(state => state.updateLayoutPositions);
const removeWidget = useDashboardStore(state => state.removeWidget);
const setWidgets = useDashboardStore(state => state.setWidgets); // The injection action
// 🔒 Safety lock to prevent infinite re-renders
const hasHydrated = useRef(false);
// 1. Pull the global slot recipes
const recipe = useSlotRecipe({key: "glassPanel"});
const styles = recipe({ layout: "widget" });
const popoverStyles = recipe({ layout: "popover" });
// 2. Populate widgets
useEffect(() => {
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
const savedLayout = user.preferences.dashboard_layout;
// Safety check: Only inject if it's actually an array of widgets.
// (Prevents a crash if MariaDB returns a default empty object {})
if (Array.isArray(savedLayout)) setWidgets(savedLayout);
hasHydrated.current = true;
}
}, [user, setWidgets]);
// 🛠️ Format the data for RGL
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.
}));
// 3. The component mapper
const renderWidget = (widget) => {
const registryEntry = WIDGET_REGISTRY[widget.type];
if (registryEntry && registryEntry.component) {
const Component = registryEntry.component;
// 1. Check if this widget type has a settings component registered
const SettingsUI = registryEntry.settingsComponent;
return (
<>
<Box css={styles.header}>
<Text>
{registryEntry.name}
</Text>
{/* WIDGET SETTINGS BUTTON (ELLIPSES) */}
{SettingsUI && (
<Popover.Root positioning={{placement: "bottom-end"}}>
<Popover.Trigger asChild>
<IconButton size="xs" variant="ghost" aria-label="Widget Settings">
<MoreVertical size={16}/>
</IconButton>
</Popover.Trigger>
{/* NEW: The Positioner lifts the content out of the Flexbox flow */}
<Popover.Positioner zIndex={1}>
<Popover.Content css={popoverStyles.root}>
<Suspense fallback={<Spinner size="sm"/>}>
<SettingsUI
widgetId={widget.id}
currentSettings={widget.settings || {}}
/>
</Suspense>
</Popover.Content>
</Popover.Positioner>
</Popover.Root>
)}
</Box>
<Box css={styles.body}>
<Component settings={widget.settings || {}} widgetID={widget.id || {}}/>
</Box>
</>
);
}
// Fallback if someone asks for a widget that doesn't exist
return (
<Flex css={styles.body} align="center" justify="center" direction="column">
<Text fontWeight="bold" color="red.400">Error</Text>
<Text fontSize="sm" color="gray.400">Widget '{widget.type}' not found.</Text>
</Flex>
);
};
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
compactType="vertical" // Auto-packs widgets to the top
onLayoutChange={(newLayout) => updateLayoutPositions(newLayout)} // Fires when user drops a widget
>
{/* 🎨 Paint the Widgets */}
{widgets.map(widget => (
<Box
key={widget.id}
css={styles.root}
// Grid-specific state overrides remain inline
boxShadow={isEditing ? "outline" : styles.root.boxShadow}
borderStyle={isEditing ? "dashed" : "solid"}
borderWidth={isEditing ? "2px" : "1px"}
borderColor={isEditing ? "orange.300" : styles.root.borderColor}
cursor={isEditing ? "grab" : "default"}
position="relative" // REQUIRED so the absolute button stays inside this box
>
{/* THE DELETE BUTTON */}
{isEditing && (
<IconButton
position="absolute"
top={2}
right={2}
bg="white"
color="black"
border="1px solid"
borderRadius="xl"
borderColor="gray.200"
_hover={{bg: "red.50", color: "red.500", borderColor: "red.200"}}
zIndex={2}
aria-label="Remove widget"
size="xs"
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>
)}
{/* SUSPENSE BOUNDARY wrapping the render function */}
<Suspense fallback={
<Flex css={styles.body} align="center" justify="center">
<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>
);
}