feat(dashboard) - Resizable and repositionable widgets.

This commit is contained in:
Willie Zutz 2025-07-26 13:16:12 -06:00
parent 7253cbc89c
commit 7b372e75da
11 changed files with 744 additions and 391 deletions

View file

@ -9,7 +9,8 @@ import {
Layers,
List,
} from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
import {
Card,
CardContent,
@ -22,8 +23,11 @@ import WidgetConfigModal from '@/components/dashboard/WidgetConfigModal';
import WidgetDisplay from '@/components/dashboard/WidgetDisplay';
import { useDashboard } from '@/lib/hooks/useDashboard';
import { Widget, WidgetConfig } from '@/lib/types/widget';
import { DASHBOARD_CONSTRAINTS } from '@/lib/constants/dashboard';
import { toast } from 'sonner';
const ResponsiveGridLayout = WidthProvider(Responsive);
const DashboardPage = () => {
const {
widgets,
@ -37,17 +41,21 @@ const DashboardPage = () => {
importDashboard,
settings,
updateSettings,
getLayouts,
updateLayouts,
} = useDashboard();
const [showAddModal, setShowAddModal] = useState(false);
const [editingWidget, setEditingWidget] = useState<Widget | null>(null);
const hasAutoRefreshed = useRef(false);
// Memoize the ResponsiveGridLayout to prevent re-renders
const ResponsiveGrid = useMemo(() => ResponsiveGridLayout, []);
// Auto-refresh stale widgets when dashboard loads (only once)
useEffect(() => {
if (!isLoading && widgets.length > 0 && !hasAutoRefreshed.current) {
hasAutoRefreshed.current = true;
refreshAllWidgets();
}
}, [isLoading, widgets, refreshAllWidgets]);
@ -119,6 +127,25 @@ const DashboardPage = () => {
updateSettings({ parallelLoading: !settings.parallelLoading });
};
// Handle layout changes from react-grid-layout
const handleLayoutChange = (layout: any, layouts: any) => {
updateLayouts(layouts);
};
// Memoize grid children to prevent unnecessary re-renders
const gridChildren = useMemo(() => {
return widgets.map((widget) => (
<div key={widget.id}>
<WidgetDisplay
widget={widget}
onEdit={handleEditWidget}
onDelete={handleDeleteWidget}
onRefresh={handleRefreshWidget}
/>
</div>
));
}, [widgets]);
// Empty state component
const EmptyDashboard = () => (
<div className="col-span-2 flex justify-center items-center min-h-[400px]">
@ -224,22 +251,23 @@ const DashboardPage = () => {
) : widgets.length === 0 ? (
<EmptyDashboard />
) : (
<div
className="grid gap-6 auto-rows-min"
style={{
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
}}
<ResponsiveGrid
className="layout"
layouts={getLayouts()}
breakpoints={DASHBOARD_CONSTRAINTS.GRID_BREAKPOINTS}
cols={DASHBOARD_CONSTRAINTS.GRID_COLUMNS}
rowHeight={DASHBOARD_CONSTRAINTS.GRID_ROW_HEIGHT}
margin={DASHBOARD_CONSTRAINTS.GRID_MARGIN}
containerPadding={DASHBOARD_CONSTRAINTS.GRID_CONTAINER_PADDING}
onLayoutChange={handleLayoutChange}
isDraggable={true}
isResizable={true}
compactType="vertical"
preventCollision={false}
draggableHandle=".widget-drag-handle"
>
{widgets.map((widget) => (
<WidgetDisplay
key={widget.id}
widget={widget}
onEdit={handleEditWidget}
onDelete={handleDeleteWidget}
onRefresh={handleRefreshWidget}
/>
))}
</div>
{gridChildren}
</ResponsiveGrid>
)}
</div>

View file

@ -1,7 +1,12 @@
/* React Grid Layout styles */
@import "react-grid-layout/css/styles.css";
@import "react-resizable/css/styles.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
.overflow-hidden-scrollable {
-ms-overflow-style: none;

View file

@ -8,6 +8,7 @@ import {
AlertCircle,
ChevronDown,
ChevronUp,
GripVertical,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import MarkdownRenderer from '@/components/MarkdownRenderer';
@ -49,14 +50,24 @@ const WidgetDisplay = ({
};
return (
<Card className="flex flex-col h-fit">
<CardHeader className="pb-3">
<Card className="flex flex-col h-full w-full">
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-medium truncate">
{widget.title}
</CardTitle>
<div className="flex items-center space-x-2 flex-1 min-w-0">
{/* Drag Handle */}
<div
className="widget-drag-handle flex-shrink-0 p-1 rounded hover:bg-light-secondary dark:hover:bg-dark-secondary cursor-move transition-colors"
title="Drag to move widget"
>
<GripVertical size={16} className="text-gray-400 dark:text-gray-500" />
</div>
<CardTitle className="text-lg font-medium truncate">
{widget.title}
</CardTitle>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 flex-shrink-0">
{/* Last updated date with refresh frequency tooltip */}
<span
className="text-xs text-gray-500 dark:text-gray-400"
@ -81,43 +92,45 @@ const WidgetDisplay = ({
</div>
</CardHeader>
<CardContent className="flex-1 max-h-[50vh] overflow-y-auto">
{widget.isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<RefreshCw size={20} className="animate-spin mr-2" />
<span>Loading content...</span>
</div>
) : widget.error ? (
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
<AlertCircle
size={16}
className="text-red-500 mt-0.5 flex-shrink-0"
/>
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-300">
Error Loading Content
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{widget.error}
</p>
<CardContent className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
{widget.isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<RefreshCw size={20} className="animate-spin mr-2" />
<span>Loading content...</span>
</div>
</div>
) : widget.content ? (
<div className="prose prose-sm dark:prose-invert">
<MarkdownRenderer content={widget.content} thinkOverlay={true} />
</div>
) : (
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-center">
<p className="text-sm">No content yet</p>
<p className="text-xs mt-1">Click refresh to load content</p>
) : widget.error ? (
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
<AlertCircle
size={16}
className="text-red-500 mt-0.5 flex-shrink-0"
/>
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-300">
Error Loading Content
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{widget.error}
</p>
</div>
</div>
</div>
)}
) : widget.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownRenderer content={widget.content} thinkOverlay={true} />
</div>
) : (
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-center">
<p className="text-sm">No content yet</p>
<p className="text-xs mt-1">Click refresh to load content</p>
</div>
</div>
)}
</div>
</CardContent>
{/* Collapsible footer with sources and actions */}
<div className="bg-light-secondary/30 dark:bg-dark-secondary/30">
<div className="bg-light-secondary/30 dark:bg-dark-secondary/30 flex-shrink-0">
<button
onClick={() => setIsFooterExpanded(!isFooterExpanded)}
className="w-full px-4 py-2 flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400 hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors"

View file

@ -0,0 +1,44 @@
// Dashboard-wide constants and constraints
export const DASHBOARD_CONSTRAINTS = {
// Grid layout constraints
WIDGET_MIN_WIDTH: 2, // Minimum columns
WIDGET_MAX_WIDTH: 12, // Maximum columns (full width)
WIDGET_MIN_HEIGHT: 2, // Minimum rows
WIDGET_MAX_HEIGHT: 20, // Maximum rows
// Default widget sizing
DEFAULT_WIDGET_WIDTH: 6, // Half width by default
DEFAULT_WIDGET_HEIGHT: 4, // Standard height
// Grid configuration
GRID_COLUMNS: {
lg: 12,
md: 10,
sm: 6,
xs: 4,
xxs: 2,
},
GRID_BREAKPOINTS: {
lg: 1200,
md: 996,
sm: 768,
xs: 480,
xxs: 0,
},
GRID_ROW_HEIGHT: 60,
GRID_MARGIN: [16, 16] as [number, number],
GRID_CONTAINER_PADDING: [0, 0] as [number, number],
} as const;
// Responsive constraints - adjust max width based on breakpoint
export const getResponsiveConstraints = (breakpoint: keyof typeof DASHBOARD_CONSTRAINTS.GRID_COLUMNS) => {
const maxCols = DASHBOARD_CONSTRAINTS.GRID_COLUMNS[breakpoint];
return {
minW: DASHBOARD_CONSTRAINTS.WIDGET_MIN_WIDTH,
maxW: Math.min(DASHBOARD_CONSTRAINTS.WIDGET_MAX_WIDTH, maxCols),
minH: DASHBOARD_CONSTRAINTS.WIDGET_MIN_HEIGHT,
maxH: DASHBOARD_CONSTRAINTS.WIDGET_MAX_HEIGHT,
};
};

View file

@ -1,11 +1,15 @@
import { useState, useEffect, useCallback } from 'react';
import { Widget, WidgetConfig } from '@/lib/types/widget';
import { Layout } from 'react-grid-layout';
import { Widget, WidgetConfig, WidgetLayout } from '@/lib/types/widget';
import {
DashboardState,
DashboardConfig,
DashboardLayouts,
GridLayoutItem,
DASHBOARD_STORAGE_KEYS,
} from '@/lib/types/dashboard';
import { WidgetCache } from '@/lib/types/cache';
import { DASHBOARD_CONSTRAINTS, getResponsiveConstraints } from '@/lib/constants/dashboard';
// Helper function to request location permission and get user's location
const requestLocationPermission = async (): Promise<string | undefined> => {
@ -81,6 +85,10 @@ interface UseDashboardReturn {
refreshWidget: (id: string, forceRefresh?: boolean) => Promise<void>;
refreshAllWidgets: (forceRefresh?: boolean) => Promise<void>;
// Layout management
updateLayouts: (layouts: DashboardLayouts) => void;
getLayouts: () => DashboardLayouts;
// Storage management
exportDashboard: () => Promise<string>;
importDashboard: (configJson: string) => Promise<void>;
@ -108,11 +116,24 @@ export const useDashboard = (): UseDashboardReturn => {
const savedWidgets = localStorage.getItem(DASHBOARD_STORAGE_KEYS.WIDGETS);
const widgets: Widget[] = savedWidgets ? JSON.parse(savedWidgets) : [];
// Convert date strings back to Date objects
widgets.forEach((widget) => {
// Convert date strings back to Date objects and ensure layout exists
widgets.forEach((widget, index) => {
if (widget.lastUpdated) {
widget.lastUpdated = new Date(widget.lastUpdated);
}
// Migration: Add default layout if missing
if (!widget.layout) {
const defaultLayout: WidgetLayout = {
x: (index % 2) * 6, // Alternate between columns
y: Math.floor(index / 2) * 4, // Stack rows
w: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_WIDTH,
h: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_HEIGHT,
isDraggable: true,
isResizable: true,
};
widget.layout = defaultLayout;
}
});
// Load settings
@ -167,6 +188,44 @@ export const useDashboard = (): UseDashboardReturn => {
}, [state.settings]);
const addWidget = useCallback((config: WidgetConfig) => {
// Find the next available position in the grid
const getNextPosition = () => {
const existingWidgets = state.widgets;
let x = 0;
let y = 0;
// Simple algorithm: try to place in first available spot
for (let row = 0; row < 20; row++) {
for (let col = 0; col < 12; col += 6) { // Start with half-width widgets
const position = { x: col, y: row };
const hasCollision = existingWidgets.some(widget =>
widget.layout.x < position.x + 6 &&
widget.layout.x + widget.layout.w > position.x &&
widget.layout.y < position.y + 3 &&
widget.layout.y + widget.layout.h > position.y
);
if (!hasCollision) {
return { x: position.x, y: position.y };
}
}
}
// Fallback: place at bottom
const maxY = Math.max(0, ...existingWidgets.map(w => w.layout.y + w.layout.h));
return { x: 0, y: maxY };
};
const position = getNextPosition();
const defaultLayout: WidgetLayout = {
x: position.x,
y: position.y,
w: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_WIDTH,
h: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_HEIGHT,
isDraggable: true,
isResizable: true,
};
const newWidget: Widget = {
...config,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
@ -174,20 +233,26 @@ export const useDashboard = (): UseDashboardReturn => {
isLoading: false,
content: null,
error: null,
layout: config.layout || defaultLayout,
};
setState((prev) => ({
...prev,
widgets: [...prev.widgets, newWidget],
}));
}, []);
}, [state.widgets]);
const updateWidget = useCallback((id: string, config: WidgetConfig) => {
setState((prev) => ({
...prev,
widgets: prev.widgets.map((widget) =>
widget.id === id
? { ...widget, ...config, id } // Preserve the ID
? {
...widget,
...config,
id, // Preserve the ID
layout: config.layout || widget.layout, // Preserve existing layout if not provided
}
: widget,
),
}));
@ -436,6 +501,63 @@ export const useDashboard = (): UseDashboardReturn => {
[],
);
const getLayouts = useCallback((): DashboardLayouts => {
const createBreakpointLayout = (breakpoint: keyof typeof DASHBOARD_CONSTRAINTS.GRID_COLUMNS) => {
const constraints = getResponsiveConstraints(breakpoint);
const maxCols = DASHBOARD_CONSTRAINTS.GRID_COLUMNS[breakpoint];
return state.widgets.map(widget => ({
i: widget.id,
x: widget.layout.x,
y: widget.layout.y,
w: Math.min(widget.layout.w, maxCols), // Constrain width to available columns
h: widget.layout.h,
minW: constraints.minW,
maxW: constraints.maxW,
minH: constraints.minH,
maxH: constraints.maxH,
static: widget.layout.static,
isDraggable: widget.layout.isDraggable,
isResizable: widget.layout.isResizable,
}));
};
return {
lg: createBreakpointLayout('lg'),
md: createBreakpointLayout('md'),
sm: createBreakpointLayout('sm'),
xs: createBreakpointLayout('xs'),
xxs: createBreakpointLayout('xxs'),
};
}, [state.widgets]);
const updateLayouts = useCallback((layouts: DashboardLayouts) => {
const updatedWidgets = state.widgets.map(widget => {
// Use lg layout as the primary layout for position and size updates
const newLayout = layouts.lg.find((layout: Layout) => layout.i === widget.id);
if (newLayout) {
return {
...widget,
layout: {
x: newLayout.x,
y: newLayout.y,
w: newLayout.w,
h: newLayout.h,
static: newLayout.static || widget.layout.static,
isDraggable: newLayout.isDraggable ?? widget.layout.isDraggable,
isResizable: newLayout.isResizable ?? widget.layout.isResizable,
},
};
}
return widget;
});
setState(prev => ({
...prev,
widgets: updatedWidgets,
}));
}, [state.widgets]);
return {
// State
widgets: state.widgets,
@ -450,6 +572,10 @@ export const useDashboard = (): UseDashboardReturn => {
refreshWidget,
refreshAllWidgets,
// Layout management
updateLayouts,
getLayouts,
// Storage management
exportDashboard,
importDashboard,

View file

@ -1,5 +1,6 @@
// Dashboard configuration and state types
import { Widget } from './widget';
import { Widget, WidgetLayout } from './widget';
import { Layout } from 'react-grid-layout';
export interface DashboardConfig {
widgets: Widget[];
@ -19,9 +20,25 @@ export interface DashboardState {
settings: DashboardConfig['settings'];
}
// Layout item for react-grid-layout (extends WidgetLayout with required 'i' property)
export interface GridLayoutItem extends WidgetLayout {
i: string; // Widget ID
}
// Layout configuration for responsive grid (compatible with react-grid-layout)
export interface DashboardLayouts {
lg: Layout[];
md: Layout[];
sm: Layout[];
xs: Layout[];
xxs: Layout[];
[key: string]: Layout[]; // Index signature for react-grid-layout compatibility
}
// Local storage keys
export const DASHBOARD_STORAGE_KEYS = {
WIDGETS: 'perplexica_dashboard_widgets',
SETTINGS: 'perplexica_dashboard_settings',
CACHE: 'perplexica_dashboard_cache',
LAYOUTS: 'perplexica_dashboard_layouts',
} as const;

View file

@ -4,6 +4,17 @@ export interface Source {
type: 'Web Page' | 'HTTP Data';
}
// Grid layout properties for widgets (only position and size data that should be persisted)
export interface WidgetLayout {
x: number;
y: number;
w: number;
h: number;
static?: boolean;
isDraggable?: boolean;
isResizable?: boolean;
}
export interface WidgetConfig {
id?: string;
title: string;
@ -14,6 +25,7 @@ export interface WidgetConfig {
refreshFrequency: number;
refreshUnit: 'minutes' | 'hours';
tool_names?: string[];
layout?: WidgetLayout;
}
export interface Widget extends WidgetConfig {
@ -22,4 +34,5 @@ export interface Widget extends WidgetConfig {
isLoading: boolean;
content: string | null;
error: string | null;
layout: WidgetLayout;
}