2026-07-29 13:48:38 -07:00
|
|
|
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';
|
2026-09-02 12:45:40 -07:00
|
|
|
|
2026-07-09 15:10:50 -07:00
|
|
|
// 1. Import database messenger
|
|
|
|
|
import useAuthStore from "@/store/authStore";
|
|
|
|
|
|
|
|
|
|
// 2. Import registry
|
2026-07-29 13:48:38 -07:00
|
|
|
import {WIDGET_REGISTRY} from '@/constants/widgetRegistry.jsx';
|
2025-07-21 14:27:53 -07:00
|
|
|
|
2026-06-09 14:06:01 -07:00
|
|
|
// React-Grid-Layout requires this wrapper to automatically calculate screen width
|
|
|
|
|
const ResponsiveGridLayout = WidthProvider(Responsive);
|
2025-07-22 11:58:10 -07:00
|
|
|
|
2026-06-09 14:06:01 -07:00
|
|
|
export default function MyDashboard() {
|
2026-07-15 13:56:12 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// ☁️ Database Connection
|
|
|
|
|
const {user} = useAuthStore();
|
2026-07-09 15:10:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// ☁️ 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
|
2025-07-22 13:41:32 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// 🔒 Safety lock to prevent infinite re-renders
|
|
|
|
|
const hasHydrated = useRef(false);
|
2026-07-09 15:10:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// 1. Pull the new global slot recipe
|
|
|
|
|
const recipe = useSlotRecipe({key: "glassPanel"});
|
|
|
|
|
const styles = recipe({ layout: "widget" });
|
2026-08-04 09:05:30 -07:00
|
|
|
const popoverStyles = recipe({ layout: "popover" });
|
2026-07-09 15:10:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (user?.preferences?.dashboard_layout && !hasHydrated.current) {
|
|
|
|
|
const savedLayout = user.preferences.dashboard_layout;
|
2026-07-09 15:10:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// 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]);
|
2026-07-09 15:10:50 -07:00
|
|
|
|
|
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// 🛠️ 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.
|
|
|
|
|
}));
|
2026-07-08 15:05:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// 3. THE COMPONENT MAPPER
|
|
|
|
|
const renderWidget = (widget) => {
|
|
|
|
|
const registryEntry = WIDGET_REGISTRY[widget.type];
|
2026-07-08 15:05:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
if (registryEntry && registryEntry.component) {
|
|
|
|
|
const Component = registryEntry.component;
|
2026-07-08 15:05:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
// 1. Check if this widget type has a settings component registered
|
|
|
|
|
const SettingsUI = registryEntry.settingsComponent;
|
2026-07-10 14:56:11 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<Box css={styles.header}>
|
|
|
|
|
<Text color="titleColor" fontWeight="semibold" fontSize="md" isTruncated>
|
|
|
|
|
{registryEntry.name}
|
|
|
|
|
</Text>
|
2026-07-08 15:05:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
{/* THE SETTINGS ELLIPSIS */}
|
|
|
|
|
{SettingsUI ? (
|
|
|
|
|
<Popover.Root positioning={{placement: "bottom-end"}}>
|
|
|
|
|
<Popover.Trigger asChild>
|
|
|
|
|
<IconButton
|
|
|
|
|
size="xs"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
color="gray.400"
|
|
|
|
|
borderRadius="xl"
|
|
|
|
|
_hover={{color: "white", bg: "gray.700"}}
|
|
|
|
|
aria-label="Widget Settings"
|
|
|
|
|
>
|
|
|
|
|
<MoreVertical size={16}/>
|
|
|
|
|
</IconButton>
|
|
|
|
|
</Popover.Trigger>
|
2026-07-08 15:05:50 -07:00
|
|
|
|
2026-07-29 13:48:38 -07:00
|
|
|
{/* NEW: The Positioner lifts the content out of the Flexbox flow */}
|
|
|
|
|
<Popover.Positioner zIndex={10}>
|
2026-08-04 09:05:30 -07:00
|
|
|
<Popover.Content css={popoverStyles.root}>
|
2026-07-29 13:48:38 -07:00
|
|
|
<Suspense fallback={<Spinner size="sm"/>}>
|
|
|
|
|
<SettingsUI
|
|
|
|
|
widgetId={widget.id}
|
|
|
|
|
currentSettings={widget.settings || {}}
|
|
|
|
|
/>
|
|
|
|
|
</Suspense>
|
|
|
|
|
</Popover.Content>
|
|
|
|
|
</Popover.Positioner>
|
|
|
|
|
</Popover.Root>
|
|
|
|
|
) : (
|
|
|
|
|
<Box w="20px" h="20px"/>
|
2026-07-08 15:05:50 -07:00
|
|
|
)}
|
2026-07-29 13:48:38 -07:00
|
|
|
</Box>
|
|
|
|
|
|
|
|
|
|
<Box css={styles.body}>
|
|
|
|
|
<Component settings={widget.settings || {}}/>
|
|
|
|
|
</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>
|
2026-07-08 15:05:50 -07:00
|
|
|
);
|
2026-07-29 13:48:38 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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}
|
|
|
|
|
css={styles.root}
|
|
|
|
|
// 3. Grid-specific state overrides remain inline
|
|
|
|
|
boxShadow={isEditing ? "outline" : styles.root.boxShadow}
|
|
|
|
|
borderStyle={isEditing ? "dashed" : "solid"}
|
|
|
|
|
borderWidth={isEditing ? "2px" : "1px"}
|
2026-08-04 09:05:30 -07:00
|
|
|
borderColor={isEditing ? "orange.300" : styles.root.borderColor}
|
2026-07-29 13:48:38 -07:00
|
|
|
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="black"
|
|
|
|
|
border="1px solid"
|
|
|
|
|
borderRadius="xl"
|
|
|
|
|
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 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>
|
|
|
|
|
);
|
2026-06-09 14:06:01 -07:00
|
|
|
}
|