diff --git a/typographic-app/client/src/components/DashboardCanvas.tsx b/typographic-app/client/src/components/DashboardCanvas.tsx new file mode 100644 index 0000000..ae4abf7 --- /dev/null +++ b/typographic-app/client/src/components/DashboardCanvas.tsx @@ -0,0 +1,590 @@ +import { useRef, useEffect, useState, useCallback } from 'react'; +import { Dashboard, WidgetConfig, WIDGET_SIZES } from '../types/dashboard'; + +interface DashboardCanvasProps { + dashboard: Dashboard; + selectedWidgets: Set; + onWidgetSelect: (widgetId: string, multiSelect?: boolean) => void; + onWidgetUpdate: (widgetId: string, updates: Partial) => void; + onWidgetDelete: (widgetId: string) => void; + onWidgetDuplicate: (widgetId: string) => void; +} + +interface DragState { + isDragging: boolean; + dragWidget: string | null; + dragOffset: { x: number; y: number }; + dragStart: { x: number; y: number }; +} + +export default function DashboardCanvas({ + dashboard, + selectedWidgets, + onWidgetSelect, + onWidgetUpdate, + onWidgetDelete, + onWidgetDuplicate +}: DashboardCanvasProps) { + const canvasRef = useRef(null); + const [dragState, setDragState] = useState({ + isDragging: false, + dragWidget: null, + dragOffset: { x: 0, y: 0 }, + dragStart: { x: 0, y: 0 } + }); + // Grid and snapping constants + const GRID_SIZE = dashboard.settings.gridSize || 48; + + // Snap position to grid + const snapToGrid = useCallback((position: { x: number; y: number }) => { + return { + x: Math.round(position.x / GRID_SIZE) * GRID_SIZE, + y: Math.round(position.y / GRID_SIZE) * GRID_SIZE + }; + }, [GRID_SIZE]); + + // Check if position is valid (within bounds, no overlap) + const isValidPosition = useCallback((widgetId: string, position: { x: number; y: number }, size: { width: number; height: number }) => { + const widgetRect = { + left: position.x, + right: position.x + size.width * GRID_SIZE, + top: position.y, + bottom: position.y + size.height * GRID_SIZE + }; + + // Check canvas bounds + const canvasRect = canvasRef.current?.getBoundingClientRect(); + if (canvasRect) { + const scale = 1; // Fixed zoom for now + const canvasWidth = canvasRect.width / scale; + const canvasHeight = canvasRect.height / scale; + + if (widgetRect.right > canvasWidth || widgetRect.bottom > canvasHeight) { + return false; + } + } + + // Check overlap with other widgets + const overlapping = dashboard.widgets.some(widget => { + if (widget.id === widgetId) return false; + + const widgetSize = WIDGET_SIZES[widget.size]; + const otherRect = { + left: widget.position.x, + right: widget.position.x + widgetSize.width * GRID_SIZE, + top: widget.position.y, + bottom: widget.position.y + widgetSize.height * GRID_SIZE + }; + + return !( + widgetRect.right <= otherRect.left || + widgetRect.left >= otherRect.right || + widgetRect.bottom <= otherRect.top || + widgetRect.top >= otherRect.bottom + ); + }); + + return !overlapping; + }, [dashboard.widgets, GRID_SIZE]); + + // Handle mouse/touch events for widget interaction + const handleMouseDown = useCallback((event: React.MouseEvent, widgetId: string) => { + event.preventDefault(); + event.stopPropagation(); + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const widget = dashboard.widgets.find(w => w.id === widgetId); + if (!widget) return; + + const startX = event.clientX - rect.left; + const startY = event.clientY - rect.top; + + // Check if clicking on resize handle + const isResizeHandle = (event.target as HTMLElement).classList.contains('widget-resize-handle'); + if (isResizeHandle) { + handleResizeStart(event, widgetId); + return; + } + + setDragState({ + isDragging: true, + dragWidget: widgetId, + dragOffset: { + x: widget.position.x - startX, + y: widget.position.y - startY + }, + dragStart: { x: startX, y: startY } + }); + + onWidgetSelect(widgetId, event.ctrlKey || event.metaKey); + }, [dashboard.widgets, onWidgetSelect]); + + const handleResizeStart = useCallback((event: React.MouseEvent, widgetId: string) => { + event.preventDefault(); + event.stopPropagation(); + + setDragState({ + isDragging: true, + dragWidget: widgetId, + dragOffset: { x: 0, y: 0 }, + dragStart: { x: event.clientX, y: event.clientY } + }); + }, []); + + const handleMouseMove = useCallback((event: MouseEvent) => { + if (!dragState.isDragging || !dragState.dragWidget) return; + + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const currentX = event.clientX - rect.left; + const currentY = event.clientY - rect.top; + + const widget = dashboard.widgets.find(w => w.id === dragState.dragWidget); + if (!widget) return; + + if (dragState.dragWidget) { // Resize mode + const deltaX = currentX - dragState.dragStart.x; + const deltaY = currentY - dragState.dragStart.y; + + const newWidth = Math.max(1, widget.position.width + Math.round(deltaX / GRID_SIZE)); + const newHeight = Math.max(1, widget.position.height + Math.round(deltaY / GRID_SIZE)); + + if (isValidPosition(widget.id, widget.position, { width: newWidth, height: newHeight })) { + onWidgetUpdate(widget.id, { + position: { + ...widget.position, + width: newWidth, + height: newHeight + } + }); + } + } else { // Move mode + const newPosition = snapToGrid({ + x: currentX + dragState.dragOffset.x, + y: currentY + dragState.dragOffset.y + }); + + if (isValidPosition(widget.id, newPosition, { + width: widget.position.width, + height: widget.position.height + })) { + onWidgetUpdate(widget.id, { + position: { + ...newPosition, + width: widget.position.width, + height: widget.position.height + } + }); + } + } + }, [dragState, dashboard.widgets, GRID_SIZE, snapToGrid, isValidPosition, onWidgetUpdate]); + + const handleMouseUp = useCallback(() => { + setDragState({ + isDragging: false, + dragWidget: null, + dragOffset: { x: 0, y: 0 }, + dragStart: { x: 0, y: 0 } + }); + }, []); + + // Add global event listeners + useEffect(() => { + if (dragState.isDragging) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + } + }, [dragState.isDragging, handleMouseMove, handleMouseUp]); + + // Handle canvas click for deselection + const handleCanvasClick = useCallback((event: React.MouseEvent) => { + if (event.target === event.currentTarget) { + onWidgetSelect(''); + } + }, [onWidgetSelect]); + + // Handle keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Delete' || event.key === 'Backspace') { + selectedWidgets.forEach(widgetId => onWidgetDelete(widgetId)); + } else if (event.key === 'Escape') { + onWidgetSelect(''); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [selectedWidgets, onWidgetDelete, onWidgetSelect]); + + // Render grid background + const renderGrid = () => { + const gridLines = []; + const canvasRect = canvasRef.current?.getBoundingClientRect(); + + if (!canvasRect) return null; + + const width = canvasRect.width; + const height = canvasRect.height; + const scaledGridSize = GRID_SIZE * 1; // Fixed zoom for now + + // Vertical lines + for (let x = 0; x <= width; x += scaledGridSize) { + gridLines.push( + + ); + } + + // Horizontal lines + for (let y = 0; y <= height; y += scaledGridSize) { + gridLines.push( + + ); + } + + return ( + + {gridLines} + + ); + }; + + // Render widget + const renderWidget = (widget: WidgetConfig) => { + const isSelected = selectedWidgets.has(widget.id); + + return ( +
handleMouseDown(e, widget.id)} + > + {/* Widget header */} +
+
+ {widget.title} +
+
+ + +
+
+ + {/* Widget content */} +
+ +
+ + {/* Resize handles */} +
handleResizeStart(e, widget.id)} + > + ↖️ +
+
handleResizeStart(e, widget.id)} + > + ↓ +
+
handleResizeStart(e, widget.id)} + > + → +
+
+ ); + }; + + // Widget content renderer + const WidgetContent = ({ widget }: { widget: WidgetConfig }) => { + switch (widget.type) { + case 'metric': + return ( +
+
+ 1,234 +
+
+ Sample Metric +
+
+ ); + + case 'chart': + return ( +
+
+ 📊 Chart Widget +
+
+ ); + + case 'table': + return ( +
+
+ 📋 Table Widget +
+
+ ); + + case 'text': + return ( +
+
+ Rich Text Content +
+
+ This is a sample text widget. You can edit this content and format it using markdown. +
+
+ ); + + case 'progress': + return ( +
+
+ Progress: 75% +
+
+
+
+
+ ); + + case 'status': + return ( +
+
🟢
+
+ Operational +
+
+ ); + + case 'gauge': + return ( +
+
🎯
+
+ Gauge: 75/100 +
+
+ ); + + case 'list': + return ( +
+
+ Sample List +
+
    +
  • Item 1
  • +
  • Item 2
  • +
  • Item 3
  • +
+
+ ); + + default: + return ( +
+
+ Unknown Widget Type: {widget.type} +
+
+ ); + } + }; + + return ( +
+ {/* Grid background */} + {renderGrid()} + + {/* Widgets */} + {dashboard.widgets.map(renderWidget)} + + {/* Empty state */} + {dashboard.widgets.length === 0 && ( +
+
📊
+
No widgets yet
+
Add widgets from the sidebar to get started
+
+ )} + + {/* Canvas info */} +
+ {dashboard.widgets.length} widgets • Grid: {GRID_SIZE}px +
+
+ ); +} \ No newline at end of file diff --git a/typographic-app/client/src/components/DashboardSidebar.tsx b/typographic-app/client/src/components/DashboardSidebar.tsx new file mode 100644 index 0000000..19422a1 --- /dev/null +++ b/typographic-app/client/src/components/DashboardSidebar.tsx @@ -0,0 +1,374 @@ +import { useState, useMemo } from 'react'; +import { Search, X, ChevronRight, ChevronDown } from 'lucide-react'; +import { Dashboard, WidgetCategory, getWidgetsByCategory, WIDGET_CATEGORIES } from '../types/dashboard'; + +interface DashboardSidebarProps { + isOpen: boolean; + onToggle: () => void; + selectedCategory: string; + onCategoryChange: (category: string) => void; + searchQuery: string; + onSearchChange: (query: string) => void; + onWidgetAdd: (widgetType: string, position: { x: number; y: number }) => void; + dashboard: Dashboard; +} + +export default function DashboardSidebar({ + isOpen, + onToggle, + selectedCategory, + onCategoryChange, + searchQuery, + onSearchChange, + onWidgetAdd, + dashboard +}: DashboardSidebarProps) { + const [expandedCategories, setExpandedCategories] = useState>( + new Set(['Data', 'Visualization', 'Content', 'Navigation', 'Utility']) + ); + + // Filter widgets based on search query + const filteredWidgets = useMemo(() => { + const allWidgets = WIDGET_CATEGORIES.flatMap(category => + getWidgetsByCategory(category as WidgetCategory) + ); + + if (!searchQuery.trim()) { + return allWidgets; + } + + const query = searchQuery.toLowerCase(); + return allWidgets.filter(widget => + widget.name.toLowerCase().includes(query) || + widget.description.toLowerCase().includes(query) || + widget.type.toLowerCase().includes(query) + ); + }, [searchQuery]); + + // Group filtered widgets by category + const widgetsByCategory = useMemo(() => { + const grouped: { [key: string]: any[] } = {}; + + WIDGET_CATEGORIES.forEach(category => { + const categoryWidgets = filteredWidgets.filter(widget => widget.category === category); + if (categoryWidgets.length > 0) { + grouped[category] = categoryWidgets; + } + }); + + return grouped; + }, [filteredWidgets]); + + const toggleCategory = (category: string) => { + setExpandedCategories(prev => { + const newSet = new Set(prev); + if (newSet.has(category)) { + newSet.delete(category); + } else { + newSet.add(category); + } + return newSet; + }); + }; + + const handleWidgetDragStart = (event: React.DragEvent, widgetType: string) => { + event.dataTransfer.setData('application/json', JSON.stringify({ + type: 'dashboard-widget', + widgetType + })); + event.dataTransfer.effectAllowed = 'copy'; + }; + + const handleWidgetClick = (widgetType: string) => { + // Add widget to a default position (center of visible area) + const canvasRect = document.querySelector('.dashboard-canvas')?.getBoundingClientRect(); + if (canvasRect) { + const centerX = Math.max(0, (canvasRect.width / 2) - 100); // 100px offset from center + const centerY = Math.max(0, (canvasRect.height / 2) - 50); // 50px offset from center + + onWidgetAdd(widgetType, { + x: Math.round(centerX / (dashboard.settings.gridSize || 48)), + y: Math.round(centerY / (dashboard.settings.gridSize || 48)) + }); + } + }; + + return ( + <> + {/* Sidebar toggle button */} + + + {/* Sidebar */} +
+
+ {/* Sidebar header */} +
+
+

+ Widget Library +

+ +
+ + {/* Search */} +
+ + onSearchChange(e.target.value)} + style={{ + width: '100%', + padding: '6px 8px 6px 28px', + background: 'var(--control-bg)', + border: '1px solid var(--control-border)', + borderRadius: '4px', + color: 'var(--text)', + fontSize: '12px' + }} + /> +
+
+ + {/* Category tabs */} +
+
+ {WIDGET_CATEGORIES.map(category => ( + + ))} +
+
+ + {/* Widget list */} +
+ {Object.entries(widgetsByCategory).map(([category, widgets]) => ( +
+ + + {expandedCategories.has(category) && ( +
+ {widgets.map(widget => ( +
handleWidgetDragStart(e, widget.type)} + onClick={() => handleWidgetClick(widget.type)} + style={{ + padding: '12px 8px', + marginBottom: '4px', + background: 'var(--bg-elev-2)', + border: '1px solid var(--control-border)', + borderRadius: '4px', + cursor: 'grab', + transition: 'all 0.2s ease', + opacity: 0.8 + }} + onMouseEnter={(e) => { + e.currentTarget.style.borderColor = 'var(--accent)'; + e.currentTarget.style.opacity = '1'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.borderColor = 'var(--control-border)'; + e.currentTarget.style.opacity = '0.8'; + }} + > +
+ {widget.icon} +
+
+ {widget.name} +
+
+ {widget.description} +
+
+
+ +
+ + {widget.defaultSize} + + + {widget.type} + +
+
+ ))} +
+ )} +
+ ))} + + {Object.keys(widgetsByCategory).length === 0 && ( +
+ {searchQuery.trim() ? 'No widgets found' : 'No widgets available'} +
+ )} +
+ + {/* Dashboard info */} +
+
Dashboard: {dashboard.name}
+
Widgets: {dashboard.widgets.length}
+
Grid: {dashboard.settings.gridSize || 48}px
+
+
+
+ + {/* Backdrop for mobile */} + {isOpen && ( +
+ )} + + ); +} \ No newline at end of file diff --git a/typographic-app/client/src/components/DashboardToolbar.tsx b/typographic-app/client/src/components/DashboardToolbar.tsx new file mode 100644 index 0000000..bcdc0a9 --- /dev/null +++ b/typographic-app/client/src/components/DashboardToolbar.tsx @@ -0,0 +1,289 @@ +import { Save, FolderOpen, Plus, Trash2, Copy, Download, Upload, Settings } from 'lucide-react'; +import { Dashboard } from '../types/dashboard'; + +interface DashboardToolbarProps { + dashboard: Dashboard; + isSaving: boolean; + isDirty: boolean; + selectedWidgetsCount: number; + onSave: () => void; + onNew: () => void; + onExport: () => void; + onImport: (event: React.ChangeEvent) => void; + onDuplicate: () => void; + onDeleteSelected: () => void; + onSettings: () => void; +} + +export default function DashboardToolbar({ + dashboard, + isSaving, + isDirty, + selectedWidgetsCount, + onSave, + onNew, + onExport, + onImport, + onDuplicate, + onDeleteSelected, + onSettings +}: DashboardToolbarProps) { + const handleImportClick = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = (e) => onImport(e as any); + input.click(); + }; + + return ( +
+ {/* Left section - Dashboard info and primary actions */} +
+ {/* Dashboard name and status */} +
+
+ {dashboard.name} +
+ {isDirty && ( +
+ UNSAVED +
+ )} + {isSaving && ( +
+ Saving... +
+ )} +
+ + {/* Primary actions */} +
+ + + +
+
+ + {/* Center section - Dashboard management */} +
+ + + + + +
+ + {/* Right section - Widget actions and settings */} +
+ {selectedWidgetsCount > 0 && ( + <> +
+ {selectedWidgetsCount} selected +
+ + + + + + )} + + +
+ + {/* Keyboard shortcuts hint */} +
+ Ctrl+S Save • Ctrl+N New • Del Delete +
+
+ ); +} \ No newline at end of file diff --git a/typographic-app/client/src/components/WidgetConfigModal.tsx b/typographic-app/client/src/components/WidgetConfigModal.tsx new file mode 100644 index 0000000..aa4ca06 --- /dev/null +++ b/typographic-app/client/src/components/WidgetConfigModal.tsx @@ -0,0 +1,416 @@ +import { useState, useEffect } from 'react'; +import { X, Settings, Save, RotateCcw } from 'lucide-react'; +import { WidgetConfig, getWidgetByType } from '../types/dashboard'; + +interface WidgetConfigModalProps { + widget: WidgetConfig; + isOpen: boolean; + onClose: () => void; + onSave: (widgetId: string, updates: Partial) => void; +} + +interface ConfigFieldProps { + field: { + key: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'color' | 'range'; + label: string; + required: boolean; + defaultValue: any; + options?: { label: string; value: any }[]; + min?: number; + max?: number; + }; + value: any; + onChange: (value: any) => void; +} + +function ConfigField({ field, value, onChange }: ConfigFieldProps) { + const inputId = `config-${field.key}`; + + const renderInput = () => { + switch (field.type) { + case 'string': + return ( + onChange(e.target.value)} + placeholder={field.defaultValue} + className="config-input" + /> + ); + + case 'number': + return ( + onChange(Number(e.target.value))} + min={field.min} + max={field.max} + className="config-input" + /> + ); + + case 'boolean': + return ( +
+ onChange(e.target.checked)} + className="config-checkbox" + /> + +
+ ); + + case 'select': + return ( + + ); + + case 'color': + return ( +
+ onChange(e.target.value)} + className="config-color" + /> + onChange(e.target.value)} + placeholder={field.defaultValue} + className="config-input" + /> +
+ ); + + case 'range': + return ( +
+ onChange(Number(e.target.value))} + min={field.min || 0} + max={field.max || 100} + className="config-range" + /> + + {value ?? field.defaultValue} + +
+ ); + + default: + return ( + onChange(e.target.value)} + className="config-input" + /> + ); + } + }; + + return ( +
+ + {renderInput()} + {field.type !== 'boolean' && ( +
+ {field.type === 'range' && `${field.min || 0} - ${field.max || 100}`} + {field.type === 'color' && 'Click to select color'} +
+ )} +
+ ); +} + +export default function WidgetConfigModal({ + widget, + isOpen, + onClose, + onSave +}: WidgetConfigModalProps) { + const [config, setConfig] = useState>({}); + const [activeTab, setActiveTab] = useState<'basic' | 'style' | 'data'>('basic'); + + const widgetLibraryItem = getWidgetByType(widget.type as any); + + useEffect(() => { + if (widget) { + setConfig({ + title: widget.title, + description: widget.description, + settings: { ...widget.settings }, + style: { ...widget.style } + }); + } + }, [widget]); + + const handleSave = () => { + onSave(widget.id, config); + onClose(); + }; + + const handleReset = () => { + if (widgetLibraryItem) { + setConfig({ + title: widgetLibraryItem.name, + description: widgetLibraryItem.description, + settings: { ...widgetLibraryItem.defaultSettings }, + style: { + backgroundColor: undefined, + borderColor: undefined, + textColor: undefined, + borderRadius: undefined, + opacity: undefined + } + }); + } + }; + + const updateConfig = (updates: Partial) => { + setConfig(prev => ({ ...prev, ...updates })); + }; + + const updateSettings = (updates: Record) => { + setConfig(prev => ({ + ...prev, + settings: { ...prev.settings, ...updates } + })); + }; + + const updateStyle = (updates: Partial>) => { + setConfig(prev => ({ + ...prev, + style: { ...prev.style, ...updates } + })); + }; + + if (!isOpen || !widget) return null; + + return ( +
+
e.stopPropagation()}> +
+
+ + Configure {widget.type} Widget +
+ +
+ +
+ {/* Tab Navigation */} +
+ {['basic', 'style', 'data'].map(tab => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === 'basic' && ( +
+

Basic Settings

+ + updateConfig({ title: value })} + /> + + updateConfig({ description: value })} + /> + + {/* Widget-specific settings */} + {widgetLibraryItem?.configOptions.map(field => ( + updateSettings({ [field.key]: value })} + /> + ))} +
+ )} + + {activeTab === 'style' && ( +
+

Visual Style

+ + updateStyle({ backgroundColor: value })} + /> + + updateStyle({ borderColor: value })} + /> + + updateStyle({ textColor: value })} + /> + + updateStyle({ borderRadius: value })} + /> + + updateStyle({ opacity: value })} + /> +
+ )} + + {activeTab === 'data' && ( +
+

Data Connection

+
+

+ Connect this widget to workflow output nodes to display live data. +

+
+
+ No data connections configured yet. +
+ +
+
+
+ )} +
+
+ +
+
+ +
+ + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/typographic-app/client/src/pages/DashboardBuilder.tsx b/typographic-app/client/src/pages/DashboardBuilder.tsx index 9cdbaec..f812e9d 100644 --- a/typographic-app/client/src/pages/DashboardBuilder.tsx +++ b/typographic-app/client/src/pages/DashboardBuilder.tsx @@ -1,33 +1,558 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; +import { toast, ToastContainer } from 'react-toastify'; +import { Plus } from 'lucide-react'; +import DashboardCanvas from '../components/DashboardCanvas'; +import DashboardSidebar from '../components/DashboardSidebar'; +import DashboardToolbar from '../components/DashboardToolbar'; +import { + Dashboard, + WidgetConfig, + DEFAULT_DASHBOARD, + WIDGET_SIZES, + WidgetType +} from '../types/dashboard'; + +const API_BASE: string = (import.meta as any).env?.VITE_API_BASE ?? '/api'; + +// Types for component state +interface DashboardState { + dashboard: Dashboard | null; + isLoading: boolean; + isSaving: boolean; + isDirty: boolean; +} + +interface SidebarState { + isOpen: boolean; + selectedCategory: string; + searchQuery: string; +} export default function DashboardBuilder() { - const [dashboards, setDashboards] = useState([]); - const [name, setName] = useState('My Dashboard'); + // Main dashboard state + const [dashboardState, setDashboardState] = useState({ + dashboard: null, + isLoading: false, + isSaving: false, + isDirty: false + }); + + // UI state + const [sidebarState, setSidebarState] = useState({ + isOpen: true, + selectedCategory: 'Data', + searchQuery: '' + }); + + const [showDashboardMenu, setShowDashboardMenu] = useState(false); + const [dashboardList, setDashboardList] = useState([]); + const [selectedWidgets, setSelectedWidgets] = useState>(new Set()); + + // Initialize with a new dashboard or load existing one useEffect(() => { - fetch('/api/dashboards').then(r=>r.json()).then(setDashboards).catch(()=>{}); + loadDashboardList(); + const urlParams = new URLSearchParams(window.location.search); + const dashboardId = urlParams.get('id'); + + if (dashboardId) { + loadDashboard(dashboardId); + } else { + createNewDashboard(); + } + }, []); + + // Load dashboard list + const loadDashboardList = useCallback(async () => { + try { + const response = await fetch(`${API_BASE}/dashboards`); + if (response.ok) { + const dashboards = await response.json(); + setDashboardList(dashboards); + } + } catch (error) { + console.error('Failed to load dashboard list:', error); + } + }, []); + + // Create new dashboard + const createNewDashboard = useCallback(async (name?: string) => { + const newDashboard = { + ...DEFAULT_DASHBOARD, + name: name || 'New Dashboard', + id: `dash_${Date.now()}`, + createdAt: new Date(), + updatedAt: new Date() + }; + + setDashboardState(prev => ({ + ...prev, + dashboard: newDashboard, + isDirty: true + })); + + setSelectedWidgets(new Set()); }, []); - async function createDashboard() { - const res = await fetch('/api/dashboards', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name }) }); - const json = await res.json(); - setDashboards((d) => [json, ...d]); + // Load existing dashboard + const loadDashboard = useCallback(async (id: string) => { + setDashboardState(prev => ({ ...prev, isLoading: true })); + + try { + const response = await fetch(`${API_BASE}/dashboards/${id}`); + if (response.ok) { + const dashboard = await response.json(); + setDashboardState(prev => ({ + ...prev, + dashboard, + isLoading: false, + isDirty: false + })); + setSelectedWidgets(new Set()); + } else { + throw new Error('Failed to load dashboard'); + } + } catch (error) { + console.error('Failed to load dashboard:', error); + toast.error('Failed to load dashboard'); + setDashboardState(prev => ({ ...prev, isLoading: false })); + // Fall back to creating new dashboard + createNewDashboard('Untitled Dashboard'); + } + }, [createNewDashboard]); + + // Save current dashboard + const saveDashboard = useCallback(async () => { + if (!dashboardState.dashboard) return; + + setDashboardState(prev => ({ ...prev, isSaving: true })); + + try { + const method = dashboardState.dashboard.id.startsWith('dash_') ? 'POST' : 'PUT'; + const url = method === 'POST' + ? `${API_BASE}/dashboards` + : `${API_BASE}/dashboards/${dashboardState.dashboard.id}`; + + const response = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(dashboardState.dashboard) + }); + + if (response.ok) { + const savedDashboard = await response.json(); + setDashboardState(prev => ({ + ...prev, + dashboard: savedDashboard, + isSaving: false, + isDirty: false + })); + toast.success('Dashboard saved successfully'); + loadDashboardList(); // Refresh the list + } else { + throw new Error('Failed to save dashboard'); + } + } catch (error) { + console.error('Failed to save dashboard:', error); + toast.error('Failed to save dashboard'); + setDashboardState(prev => ({ ...prev, isSaving: false })); + } + }, [dashboardState.dashboard, loadDashboardList]); + + + + // Add widget to dashboard + const addWidget = useCallback((widgetType: string, position: { x: number; y: number }) => { + if (!dashboardState.dashboard) return; + + const widgetId = `${widgetType.toLowerCase()}-${Date.now()}`; + const newWidget: WidgetConfig = { + id: widgetId, + type: widgetType as WidgetType, + title: `${widgetType} Widget`, + description: '', + size: 'medium', + position: { + x: position.x, + y: position.y, + width: WIDGET_SIZES.medium.width, + height: WIDGET_SIZES.medium.height + }, + settings: {} + }; + + setDashboardState(prev => ({ + ...prev, + dashboard: { + ...prev.dashboard!, + widgets: [...prev.dashboard!.widgets, newWidget], + updatedAt: new Date() + }, + isDirty: true + })); + + setSelectedWidgets(new Set([widgetId])); + }, [dashboardState.dashboard]); + + // Update widget + const updateWidget = useCallback((widgetId: string, updates: Partial) => { + if (!dashboardState.dashboard) return; + + setDashboardState(prev => ({ + ...prev, + dashboard: { + ...prev.dashboard!, + widgets: prev.dashboard!.widgets.map(widget => + widget.id === widgetId ? { ...widget, ...updates } : widget + ), + updatedAt: new Date() + }, + isDirty: true + })); + }, [dashboardState.dashboard]); + + // Delete widget + const deleteWidget = useCallback((widgetId: string) => { + if (!dashboardState.dashboard) return; + + setDashboardState(prev => ({ + ...prev, + dashboard: { + ...prev.dashboard!, + widgets: prev.dashboard!.widgets.filter(widget => widget.id !== widgetId), + updatedAt: new Date() + }, + isDirty: true + })); + + setSelectedWidgets(prev => { + const newSet = new Set(prev); + newSet.delete(widgetId); + return newSet; + }); + }, [dashboardState.dashboard]); + + // Duplicate widget + const duplicateWidget = useCallback((widgetId: string) => { + if (!dashboardState.dashboard) return; + + const widgetToDuplicate = dashboardState.dashboard.widgets.find(w => w.id === widgetId); + if (!widgetToDuplicate) return; + + const duplicatedWidget: WidgetConfig = { + ...widgetToDuplicate, + id: `${widgetToDuplicate.type.toLowerCase()}-${Date.now()}`, + title: `${widgetToDuplicate.title} (Copy)`, + position: { + ...widgetToDuplicate.position, + x: widgetToDuplicate.position.x + 20, + y: widgetToDuplicate.position.y + 20 + } + }; + + setDashboardState(prev => ({ + ...prev, + dashboard: { + ...prev.dashboard!, + widgets: [...prev.dashboard!.widgets, duplicatedWidget], + updatedAt: new Date() + }, + isDirty: true + })); + }, [dashboardState.dashboard]); + + // Handle widget selection + const handleWidgetSelect = useCallback((widgetId: string, multiSelect = false) => { + setSelectedWidgets(prev => { + const newSet = new Set(prev); + if (multiSelect) { + if (newSet.has(widgetId)) { + newSet.delete(widgetId); + } else { + newSet.add(widgetId); + } + } else { + newSet.clear(); + newSet.add(widgetId); + } + return newSet; + }); + }, []); + + // Export dashboard + const exportDashboard = useCallback(() => { + if (!dashboardState.dashboard) return; + + const exportData = { + ...dashboardState.dashboard, + exportedAt: new Date().toISOString(), + version: '1.0' + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${dashboardState.dashboard.name.replace(/\s+/g, '_')}_dashboard.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success('Dashboard exported successfully'); + }, [dashboardState.dashboard]); + + // Import dashboard + const importDashboard = useCallback((event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const dashboardData = JSON.parse(e.target?.result as string); + + // Validate basic structure + if (!dashboardData.name || !dashboardData.widgets) { + throw new Error('Invalid dashboard file format'); + } + + // Create new dashboard from imported data + const importedDashboard = { + ...dashboardData, + id: `dash_${Date.now()}`, + createdAt: new Date(), + updatedAt: new Date() + }; + + setDashboardState(prev => ({ + ...prev, + dashboard: importedDashboard, + isDirty: true + })); + + toast.success('Dashboard imported successfully'); + } catch (error) { + console.error('Failed to import dashboard:', error); + toast.error('Failed to import dashboard: Invalid file format'); + } + }; + reader.readAsText(file); + }, []); + + // Auto-save functionality + useEffect(() => { + if (!dashboardState.isDirty || !dashboardState.dashboard) return; + + const timer = setTimeout(() => { + saveDashboard(); + }, 2000); // Auto-save after 2 seconds of inactivity + + return () => clearTimeout(timer); + }, [dashboardState.isDirty, dashboardState.dashboard, saveDashboard]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.ctrlKey || event.metaKey) { + switch (event.key) { + case 's': + event.preventDefault(); + saveDashboard(); + break; + case 'n': + event.preventDefault(); + createNewDashboard(); + break; + case 'd': + if (selectedWidgets.size > 0) { + event.preventDefault(); + selectedWidgets.forEach(duplicateWidget); + } + break; + case 'Delete': + case 'Backspace': + if (selectedWidgets.size > 0) { + event.preventDefault(); + selectedWidgets.forEach(deleteWidget); + } + break; + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [saveDashboard, createNewDashboard, duplicateWidget, deleteWidget, selectedWidgets]); + + if (!dashboardState.dashboard) { + return ( +
+ Loading dashboard... +
+ ); } return ( -
-
- setName(e.target.value)} /> - +
+ + + {/* Sidebar */} + setSidebarState(prev => ({ ...prev, isOpen: !prev.isOpen }))} + selectedCategory={sidebarState.selectedCategory} + onCategoryChange={(category) => setSidebarState(prev => ({ ...prev, selectedCategory: category }))} + searchQuery={sidebarState.searchQuery} + onSearchChange={(query) => setSidebarState(prev => ({ ...prev, searchQuery: query }))} + onWidgetAdd={addWidget} + dashboard={dashboardState.dashboard} + /> + + {/* Main content */} +
+ {/* Toolbar */} + selectedWidgets.forEach(duplicateWidget)} + onDeleteSelected={() => selectedWidgets.forEach(deleteWidget)} + onSettings={() => {/* TODO: Implement settings modal */}} + /> + + {/* Canvas area */} +
+ +
-
- {dashboards.map((d) => ( -
-
{d.name}
-
Widgets: auto-inferred from workflow nodes
+ + {/* Dashboard menu overlay */} + {showDashboardMenu && ( +
+
+

Dashboards

+
+ +
+
+ {dashboardList.map(dashboard => ( +
{ + loadDashboard(dashboard.id); + setShowDashboardMenu(false); + }} + > +
+ {dashboard.name} +
+
+ {dashboard.widgets.length} widgets • Updated {dashboard.updatedAt.toLocaleDateString()} +
+
+ ))} +
+
+ +
- ))} -
+
+ )}
); } diff --git a/typographic-app/client/src/styles/theme.css b/typographic-app/client/src/styles/theme.css index 35f8a81..940b289 100644 --- a/typographic-app/client/src/styles/theme.css +++ b/typographic-app/client/src/styles/theme.css @@ -547,3 +547,808 @@ box-shadow: 0 0 0 0 rgba(255,255,255,0.0); } } + +/* Dashboard Builder Styles */ +.dashboard-canvas { + position: relative; + overflow: hidden; + user-select: none; +} + +.dashboard-canvas svg { + pointer-events: none; +} + +.dashboard-widget { + position: absolute; + display: flex; + flex-direction: column; + transition: all 0.2s ease; + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); +} + +.dashboard-widget:hover { + box-shadow: 0 4px 16px rgba(0,0,0,0.15); +} + +.dashboard-widget.selected { + box-shadow: 0 0 0 2px var(--accent), 0 4px 12px rgba(0,0,0,0.15); +} + +.widget-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: var(--bg-elev-2); + border-bottom: 1px solid var(--control-border); + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: bold; + color: var(--text); +} + +.widget-action-btn { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 2px; + border-radius: 2px; + font-size: 12px; + transition: all 0.15s ease; +} + +.widget-action-btn:hover { + color: var(--text); + background: var(--bg-elev); +} + +.widget-content { + flex: 1; + padding: 12px; + overflow: hidden; + color: var(--text); +} + +.widget-resize-handle { + position: absolute; + background: var(--accent); + color: white; + border-radius: 2px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + user-select: none; + opacity: 0; + transition: opacity 0.2s ease; +} + +.dashboard-widget:hover .widget-resize-handle { + opacity: 1; +} + +.widget-resize-handle:hover { + background: var(--accent-2); +} + +.resize-handle-se { + bottom: 0; + right: 0; + width: 16px; + height: 16px; + cursor: nw-resize; +} + +.resize-handle-s { + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 16px; + height: 16px; + cursor: s-resize; +} + +.resize-handle-e { + top: 50%; + right: 0; + transform: translateY(-50%); + width: 16px; + height: 16px; + cursor: e-resize; +} + +/* Dashboard Sidebar Styles */ +.dashboard-sidebar { + background: var(--bg-elev); + border-right: 1px solid var(--control-border); + box-shadow: 0 0 20px rgba(0,0,0,0.1); + backdrop-filter: blur(12px) saturate(140%); + -webkit-backdrop-filter: blur(12px) saturate(140%); +} + +.sidebar-toggle { + background: var(--bg-elev); + border: 1px solid var(--control-border); + border-radius: 0 8px 8px 0; + color: var(--text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} + +.sidebar-toggle:hover { + background: var(--bg-elev-2); + border-color: var(--accent); +} + +/* Dashboard Toolbar Styles */ +.dashboard-toolbar { + background: var(--bg-elev); + border-bottom: 1px solid var(--control-border); + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + backdrop-filter: blur(8px) saturate(140%); + -webkit-backdrop-filter: blur(8px) saturate(140%); +} + +.dashboard-toolbar button { + background: var(--control-bg); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + cursor: pointer; + font-family: var(--font-mono); + font-size: 12px; + padding: 6px 12px; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 4px; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04), inset 0 -1px 0 rgba(0,0,0,0.28); +} + +.dashboard-toolbar button:hover { + border-color: var(--accent); + background: var(--bg-elev-2); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), inset 0 -1px 0 rgba(0,0,0,0.24); +} + +.dashboard-toolbar button:disabled { + opacity: 0.5; + cursor: not-allowed; + background: var(--bg-elev-2); +} + +.dashboard-toolbar button:disabled:hover { + border-color: var(--control-border); + background: var(--bg-elev-2); +} + +/* Widget Library Item Styles */ +.widget-library-item { + padding: 12px 8px; + margin-bottom: 4px; + background: var(--bg-elev-2); + border: 1px solid var(--control-border); + border-radius: 4px; + cursor: grab; + transition: all 0.2s ease; + opacity: 0.8; +} + +.widget-library-item:hover { + border-color: var(--accent); + opacity: 1; + background: var(--bg-elev); +} + +.widget-library-item:active { + cursor: grabbing; +} + +.widget-library-icon { + font-size: 16px; + margin-right: 8px; +} + +.widget-library-name { + font-size: 12px; + font-weight: bold; + color: var(--text); + margin-bottom: 2px; +} + +.widget-library-description { + font-size: 10px; + color: var(--muted); + line-height: 1.3; +} + +.widget-library-meta { + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; +} + +.widget-library-badge { + padding: 2px 6px; + border-radius: 2px; + font-size: 9px; + font-weight: bold; +} + +.widget-library-badge.size { + background: var(--control-bg); + color: var(--muted); +} + +.widget-library-badge.type { + background: var(--accent); + color: white; +} + +/* Category Tabs */ +.dashboard-category-tabs { + display: flex; + gap: 4px; + padding: 12px; + border-bottom: 1px solid var(--control-border); + overflow-x: auto; +} + +.category-tab { + padding: 4px 8px; + background: var(--control-bg); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + cursor: pointer; + font-size: 11px; + font-family: var(--font-mono); + white-space: nowrap; + transition: all 0.15s ease; +} + +.category-tab.active { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.category-tab:hover { + border-color: var(--accent); + background: var(--bg-elev-2); +} + +/* Search Input */ +.dashboard-search { + position: relative; + margin-bottom: 12px; +} + +.dashboard-search-icon { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + color: var(--muted); + font-size: 14px; +} + +.dashboard-search-input { + width: 100%; + padding: 6px 8px 6px 28px; + background: var(--control-bg); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + font-size: 12px; +} + +.dashboard-search-input:focus { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-color: var(--accent); +} + +/* Dashboard Menu Overlay */ +.dashboard-menu-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.dashboard-menu { + background: var(--bg-elev); + border: 1px solid var(--control-border); + border-radius: 8px; + padding: 20px; + min-width: 400px; + max-height: 80vh; + overflow: auto; + box-shadow: 0 20px 60px rgba(0,0,0,0.35); +} + +.dashboard-menu-header { + margin: 0 0 20px 0; + font-size: 18px; + font-weight: bold; + color: var(--text); + display: flex; + align-items: center; + justify-content: space-between; +} + +.dashboard-menu-actions { + display: flex; + gap: 10px; + margin-bottom: 20px; +} + +.dashboard-menu-list { + max-height: 400px; + overflow: auto; +} + +.dashboard-menu-item { + padding: 12px; + border: 1px solid var(--control-border); + border-radius: 4px; + margin-bottom: 8px; + cursor: pointer; + background: var(--bg-elev-2); + transition: all 0.15s ease; +} + +.dashboard-menu-item:hover { + border-color: var(--accent); + background: var(--bg-elev); +} + +.dashboard-menu-item.active { + background: var(--accent); + border-color: var(--accent); + color: white; +} + +.dashboard-menu-item-name { + font-weight: bold; + margin-bottom: 4px; +} + +.dashboard-menu-item-meta { + font-size: 12px; + opacity: 0.8; +} + +/* Empty States */ +.dashboard-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--muted); + text-align: center; +} + +.dashboard-empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.dashboard-empty-title { + font-size: 18px; + margin-bottom: 8px; +} + +.dashboard-empty-description { + font-size: 14px; + opacity: 0.7; +} + +/* Loading States */ +.dashboard-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 18px; + color: var(--muted); +} + +/* Toast Container for Dashboard */ +.toast-container-dashboard { + bottom: 16px; + right: 16px; + width: 320px; +} + +.toast-container-dashboard .Toastify__toast-container { + width: 100%; +} + +.toast-container-dashboard .Toastify__toast { + width: 100%; + margin: 0; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .dashboard-toolbar { + padding: 0 8px; + flex-wrap: wrap; + height: auto; + min-height: 48px; + } + + .dashboard-toolbar > div { + margin-bottom: 8px; + } + + .dashboard-sidebar { + position: fixed; + z-index: 1000; + } + + .sidebar-toggle { + display: none; + } +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .dashboard-widget { + border-width: 2px; + } + + .dashboard-toolbar button { + border-width: 2px; + } + + .category-tab { + border-width: 2px; + } +} + +/* Widget Configuration Modal */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +.modal-content { + background: var(--bg-elev); + border: 1px solid var(--control-border); + border-radius: 8px; + max-width: 600px; + max-height: 80vh; + width: 90vw; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.35); + backdrop-filter: blur(12px) saturate(140%); + -webkit-backdrop-filter: blur(12px) saturate(140%); +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--control-border); + background: var(--bg-elev-2); + border-radius: 8px 8px 0 0; +} + +.modal-title { + font-size: 16px; + font-weight: bold; + color: var(--text); + display: flex; + align-items: center; +} + +.modal-close { + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.15s ease; +} + +.modal-close:hover { + color: var(--text); + background: var(--bg-elev); +} + +.modal-body { + flex: 1; + overflow: auto; + padding: 20px; +} + +.modal-footer { + padding: 16px 20px; + border-top: 1px solid var(--control-border); + background: var(--bg-elev-2); + border-radius: 0 0 8px 8px; +} + +.modal-actions { + display: flex; + align-items: center; + gap: 12px; +} + +/* Configuration Tabs */ +.config-tabs { + display: flex; + gap: 4px; + margin-bottom: 20px; + border-bottom: 1px solid var(--control-border); +} + +.config-tab { + padding: 8px 16px; + background: var(--control-bg); + border: 1px solid var(--control-border); + border-radius: 4px 4px 0 0; + color: var(--text); + cursor: pointer; + font-size: 12px; + font-family: var(--font-mono); + transition: all 0.15s ease; + border-bottom: none; +} + +.config-tab.active { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.config-tab:hover { + background: var(--bg-elev-2); + border-color: var(--accent); +} + +.config-content { + max-height: 400px; + overflow: auto; +} + +.config-section { + margin-bottom: 24px; +} + +.config-section-title { + font-size: 14px; + font-weight: bold; + color: var(--text); + margin: 0 0 16px 0; + padding-bottom: 8px; + border-bottom: 1px solid var(--control-border); +} + +.config-field { + margin-bottom: 16px; +} + +.config-label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--text); + margin-bottom: 4px; +} + +.config-required { + color: var(--accent-3); + margin-left: 2px; +} + +.config-input, +.config-select { + width: 100%; + padding: 8px 12px; + background: var(--control-bg); + border: 1px solid var(--control-border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + transition: all 0.15s ease; +} + +.config-input:focus, +.config-select:focus { + outline: 2px solid var(--ring); + outline-offset: 2px; + border-color: var(--accent); +} + +.config-checkbox-wrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.config-checkbox { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.config-checkbox-label { + font-size: 12px; + color: var(--text); + cursor: pointer; +} + +.config-color-wrapper { + display: flex; + gap: 8px; + align-items: center; +} + +.config-color { + width: 40px; + height: 32px; + border: 1px solid var(--control-border); + border-radius: 4px; + cursor: pointer; +} + +.config-range-wrapper { + display: flex; + align-items: center; + gap: 12px; +} + +.config-range { + flex: 1; + accent-color: var(--accent); +} + +.config-range-value { + font-size: 12px; + color: var(--muted); + min-width: 32px; + text-align: right; +} + +.config-help { + font-size: 11px; + color: var(--muted); + margin-top: 4px; +} + +.config-info { + color: var(--text); +} + +.btn-primary, +.btn-secondary { + padding: 8px 16px; + border-radius: 4px; + font-size: 12px; + font-family: var(--font-mono); + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid var(--control-border); +} + +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-primary:hover { + background: var(--accent-2); + border-color: var(--accent-2); +} + +.btn-secondary { + background: var(--control-bg); + color: var(--text); + border-color: var(--control-border); +} + +.btn-secondary:hover { + background: var(--bg-elev-2); + border-color: var(--accent); +} + +/* Reduced motion support */ +@media (prefers-reduced-motion: reduce) { + .dashboard-widget, + .dashboard-toolbar button, + .category-tab, + .widget-library-item, + .config-tab, + .config-input, + .config-select, + .btn-primary, + .btn-secondary { + transition: none; + } + + .dashboard-widget:hover .widget-resize-handle { + opacity: 1; + } +} + +/* High contrast mode support */ +@media (prefers-contrast: high) { + .config-input, + .config-select, + .btn-primary, + .btn-secondary { + border-width: 2px; + } + + .config-tab { + border-width: 2px; + border-bottom: 2px solid var(--accent); + } + + .config-tab.active { + border-bottom-color: var(--accent); + } +} + +/* Mobile responsiveness */ +@media (max-width: 768px) { + .modal-content { + width: 95vw; + max-height: 90vh; + } + + .modal-body { + padding: 16px; + } + + .modal-footer { + padding: 12px 16px; + } + + .config-tabs { + overflow-x: auto; + } + + .config-content { + max-height: 300px; + } +} diff --git a/typographic-app/client/src/types/dashboard.ts b/typographic-app/client/src/types/dashboard.ts new file mode 100644 index 0000000..0fe67f9 --- /dev/null +++ b/typographic-app/client/src/types/dashboard.ts @@ -0,0 +1,324 @@ + +// Dashboard Types +export type DashboardLayout = 'grid' | 'masonry' | 'flexbox'; + +export type WidgetType = + | 'metric' + | 'chart' + | 'table' + | 'text' + | 'image' + | 'map' + | 'timeline' + | 'progress' + | 'status' + | 'list' + | 'calendar' + | 'gauge'; + +export type ChartType = + | 'line' + | 'bar' + | 'pie' + | 'doughnut' + | 'area' + | 'scatter' + | 'heatmap' + | 'radar' + | 'treemap'; + +export type WidgetSize = 'small' | 'medium' | 'large' | 'xlarge'; + +export type DataSource = { + workflowId: string; + nodeId: string; + outputType: 'data' | 'meta'; + endpoint?: string; + refreshInterval?: number; // in seconds +}; + +export type WidgetPosition = { + x: number; + y: number; + width: number; + height: number; +}; + +export type WidgetConfig = { + id: string; + type: WidgetType; + title: string; + description?: string; + size: WidgetSize; + position: WidgetPosition; + dataSource?: DataSource; + settings: Record; // Widget-specific settings + style?: { + backgroundColor?: string; + borderColor?: string; + textColor?: string; + borderRadius?: number; + opacity?: number; + }; +}; + +export type Dashboard = { + id: string; + name: string; + description?: string; + layout: DashboardLayout; + widgets: WidgetConfig[]; + settings: { + backgroundColor?: string; + backgroundImage?: string; + gridSize?: number; + gap?: number; + theme?: 'dark' | 'light' | 'auto'; + refreshInterval?: number; + }; + createdAt: Date; + updatedAt: Date; + createdBy?: string; +}; + +// Widget Library Categories +export type WidgetCategory = 'Data' | 'Visualization' | 'Content' | 'Navigation' | 'Utility'; + +export type WidgetLibraryItem = { + id: string; + type: WidgetType; + category: WidgetCategory; + name: string; + description: string; + icon: string; + defaultSize: WidgetSize; + defaultSettings: Record; + supportedDataTypes: string[]; + configOptions: { + key: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'color' | 'range'; + label: string; + required: boolean; + defaultValue: any; + options?: { label: string; value: any }[]; + min?: number; + max?: number; + }[]; +}; + +// Predefined widget templates +export const WIDGET_CATEGORIES: WidgetCategory[] = ['Data', 'Visualization', 'Content', 'Navigation', 'Utility']; + +export const WIDGET_SIZES: { [key in WidgetSize]: { width: number; height: number } } = { + small: { width: 1, height: 1 }, + medium: { width: 2, height: 1 }, + large: { width: 2, height: 2 }, + xlarge: { width: 3, height: 2 } +}; + +export const DEFAULT_DASHBOARD: Omit = { + name: 'New Dashboard', + description: '', + layout: 'grid', + widgets: [], + settings: { + backgroundColor: '#1b1b1b', + gridSize: 48, + gap: 12, + theme: 'dark', + refreshInterval: 60 + } +}; + +// Widget Library Items +export const WIDGET_LIBRARY: WidgetLibraryItem[] = [ + // Data Widgets + { + id: 'metric-number', + type: 'metric', + category: 'Data', + name: 'Metric Number', + description: 'Display a single metric with trend indicators', + icon: '📊', + defaultSize: 'small', + defaultSettings: { format: 'number', showTrend: true, showChange: true }, + supportedDataTypes: ['number', 'currency', 'percentage'], + configOptions: [ + { key: 'format', type: 'select', label: 'Format', required: true, defaultValue: 'number', + options: [ + { label: 'Number', value: 'number' }, + { label: 'Currency', value: 'currency' }, + { label: 'Percentage', value: 'percentage' } + ] + }, + { key: 'showTrend', type: 'boolean', label: 'Show Trend', required: false, defaultValue: true }, + { key: 'showChange', type: 'boolean', label: 'Show Change', required: false, defaultValue: true } + ] + }, + { + id: 'chart-line', + type: 'chart', + category: 'Visualization', + name: 'Line Chart', + description: 'Time series line chart with multiple series support', + icon: '📈', + defaultSize: 'large', + defaultSettings: { chartType: 'line', xAxis: 'time', yAxis: 'value', showLegend: true }, + supportedDataTypes: ['timeseries', 'array'], + configOptions: [ + { key: 'chartType', type: 'select', label: 'Chart Type', required: true, defaultValue: 'line', + options: [ + { label: 'Line', value: 'line' }, + { label: 'Area', value: 'area' }, + { label: 'Bar', value: 'bar' } + ] + }, + { key: 'showLegend', type: 'boolean', label: 'Show Legend', required: false, defaultValue: true }, + { key: 'xAxis', type: 'string', label: 'X Axis Field', required: true, defaultValue: 'time' }, + { key: 'yAxis', type: 'string', label: 'Y Axis Field', required: true, defaultValue: 'value' } + ] + }, + { + id: 'table-data', + type: 'table', + category: 'Data', + name: 'Data Table', + description: 'Tabular data display with sorting and filtering', + icon: '📋', + defaultSize: 'xlarge', + defaultSettings: { showHeaders: true, sortable: true, filterable: true, pagination: true }, + supportedDataTypes: ['table', 'array'], + configOptions: [ + { key: 'showHeaders', type: 'boolean', label: 'Show Headers', required: false, defaultValue: true }, + { key: 'sortable', type: 'boolean', label: 'Sortable Columns', required: false, defaultValue: true }, + { key: 'filterable', type: 'boolean', label: 'Filterable', required: false, defaultValue: true }, + { key: 'pagination', type: 'boolean', label: 'Pagination', required: false, defaultValue: true }, + { key: 'pageSize', type: 'number', label: 'Page Size', required: false, defaultValue: 25, min: 5, max: 100 } + ] + }, + { + id: 'text-content', + type: 'text', + category: 'Content', + name: 'Rich Text', + description: 'Rich text content with markdown support', + icon: '📝', + defaultSize: 'medium', + defaultSettings: { content: '# Heading\n\nContent here...', markdown: true }, + supportedDataTypes: ['string', 'markdown'], + configOptions: [ + { key: 'content', type: 'string', label: 'Content', required: true, defaultValue: '# Heading\n\nContent here...' }, + { key: 'markdown', type: 'boolean', label: 'Enable Markdown', required: false, defaultValue: true } + ] + }, + { + id: 'progress-bar', + type: 'progress', + category: 'Data', + name: 'Progress Bar', + description: 'Visual progress indicator with percentage', + icon: '📊', + defaultSize: 'medium', + defaultSettings: { min: 0, max: 100, current: 75, showPercentage: true, color: '#22c55e' }, + supportedDataTypes: ['number', 'percentage'], + configOptions: [ + { key: 'min', type: 'number', label: 'Minimum', required: true, defaultValue: 0 }, + { key: 'max', type: 'number', label: 'Maximum', required: true, defaultValue: 100 }, + { key: 'current', type: 'number', label: 'Current Value', required: true, defaultValue: 75 }, + { key: 'showPercentage', type: 'boolean', label: 'Show Percentage', required: false, defaultValue: true }, + { key: 'color', type: 'color', label: 'Color', required: false, defaultValue: '#22c55e' } + ] + }, + { + id: 'status-indicator', + type: 'status', + category: 'Utility', + name: 'Status Indicator', + description: 'Status indicator with color coding', + icon: '🔴', + defaultSize: 'small', + defaultSettings: { status: 'operational', showLabel: true, color: '#22c55e' }, + supportedDataTypes: ['string', 'status'], + configOptions: [ + { key: 'status', type: 'select', label: 'Status', required: true, defaultValue: 'operational', + options: [ + { label: 'Operational', value: 'operational' }, + { label: 'Warning', value: 'warning' }, + { label: 'Critical', value: 'critical' }, + { label: 'Maintenance', value: 'maintenance' }, + { label: 'Offline', value: 'offline' } + ] + }, + { key: 'showLabel', type: 'boolean', label: 'Show Label', required: false, defaultValue: true }, + { key: 'color', type: 'color', label: 'Color', required: false, defaultValue: '#22c55e' } + ] + }, + { + id: 'gauge-chart', + type: 'gauge', + category: 'Visualization', + name: 'Gauge Chart', + description: 'Circular gauge with needle indicator', + icon: '🎯', + defaultSize: 'medium', + defaultSettings: { min: 0, max: 100, current: 75, color: '#3b82f6', showValue: true }, + supportedDataTypes: ['number', 'percentage'], + configOptions: [ + { key: 'min', type: 'number', label: 'Minimum', required: true, defaultValue: 0 }, + { key: 'max', type: 'number', label: 'Maximum', required: true, defaultValue: 100 }, + { key: 'current', type: 'number', label: 'Current Value', required: true, defaultValue: 75 }, + { key: 'color', type: 'color', label: 'Color', required: false, defaultValue: '#3b82f6' }, + { key: 'showValue', type: 'boolean', label: 'Show Value', required: false, defaultValue: true } + ] + }, + { + id: 'list-widget', + type: 'list', + category: 'Content', + name: 'List Widget', + description: 'Ordered or unordered list display', + icon: '📜', + defaultSize: 'medium', + defaultSettings: { items: ['Item 1', 'Item 2', 'Item 3'], ordered: false, bulletColor: '#6c5ce7' }, + supportedDataTypes: ['array', 'list'], + configOptions: [ + { key: 'items', type: 'string', label: 'Items (comma-separated)', required: true, defaultValue: 'Item 1, Item 2, Item 3' }, + { key: 'ordered', type: 'boolean', label: 'Ordered List', required: false, defaultValue: false }, + { key: 'bulletColor', type: 'color', label: 'Bullet Color', required: false, defaultValue: '#6c5ce7' } + ] + } +]; + +// Helper functions +export function getWidgetIcon(type: WidgetType): string { + const widget = WIDGET_LIBRARY.find(w => w.type === type); + return widget?.icon || '📦'; +} + +export function getWidgetByType(type: WidgetType): WidgetLibraryItem | undefined { + return WIDGET_LIBRARY.find(w => w.type === type); +} + +export function getWidgetsByCategory(category: WidgetCategory): WidgetLibraryItem[] { + return WIDGET_LIBRARY.filter(w => w.category === category); +} + +export function createWidgetFromLibrary( + libraryItem: WidgetLibraryItem, + position: { x: number; y: number } +): WidgetConfig { + return { + id: `${libraryItem.type}-${Date.now()}`, + type: libraryItem.type, + title: libraryItem.name, + description: libraryItem.description, + size: libraryItem.defaultSize, + position: { + x: position.x, + y: position.y, + width: WIDGET_SIZES[libraryItem.defaultSize].width, + height: WIDGET_SIZES[libraryItem.defaultSize].height + }, + settings: { ...libraryItem.defaultSettings } + }; +} \ No newline at end of file diff --git a/typographic-app/server/src/routes/dashboards.ts b/typographic-app/server/src/routes/dashboards.ts index 1552a42..be78a81 100644 --- a/typographic-app/server/src/routes/dashboards.ts +++ b/typographic-app/server/src/routes/dashboards.ts @@ -1,16 +1,528 @@ import { Router } from 'express'; +import { z } from 'zod'; +import fs from 'fs/promises'; +import path from 'path'; + +// Dashboard and Widget types +interface Dashboard { + id: string; + name: string; + description?: string; + layout: 'grid' | 'masonry' | 'flexbox'; + widgets: WidgetConfig[]; + settings: { + backgroundColor?: string; + backgroundImage?: string; + gridSize?: number; + gap?: number; + theme?: 'dark' | 'light' | 'auto'; + refreshInterval?: number; + }; + createdAt: Date; + updatedAt: Date; + createdBy?: string; +} + +interface WidgetConfig { + id: string; + type: string; + title: string; + description?: string; + size: 'small' | 'medium' | 'large' | 'xlarge'; + position: { x: number; y: number; width: number; height: number }; + dataSource?: { + workflowId: string; + nodeId: string; + outputType: 'data' | 'meta'; + endpoint?: string; + refreshInterval?: number; + }; + settings: Record; + style?: { + backgroundColor?: string; + borderColor?: string; + textColor?: string; + borderRadius?: number; + opacity?: number; + }; +} + +interface WorkflowConnection { + dashboardId: string; + workflowId: string; + nodeId: string; + widgetId: string; + connectionType: 'data' | 'meta'; + lastSync?: Date; + status: 'active' | 'inactive' | 'error'; +} + +interface DashboardSummary { + id: string; + name: string; + description?: string; + widgetCount: number; + lastModified: Date; + createdAt: Date; + connectedWorkflows: string[]; +} + +// Validation schemas +const DashboardSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().optional(), + layout: z.enum(['grid', 'masonry', 'flexbox']), + widgets: z.array(z.any()), + settings: z.object({ + backgroundColor: z.string().optional(), + backgroundImage: z.string().optional(), + gridSize: z.number().min(12).max(100).optional(), + gap: z.number().min(0).max(50).optional(), + theme: z.enum(['dark', 'light', 'auto']).optional(), + refreshInterval: z.number().min(10).max(3600).optional() + }) +}); + +const WidgetSchema = z.object({ + id: z.string(), + type: z.string(), + title: z.string(), + description: z.string().optional(), + size: z.enum(['small', 'medium', 'large', 'xlarge']), + position: z.object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number() + }), + dataSource: z.object({ + workflowId: z.string(), + nodeId: z.string(), + outputType: z.enum(['data', 'meta']), + endpoint: z.string().optional(), + refreshInterval: z.number().optional() + }).optional(), + settings: z.record(z.any()), + style: z.object({ + backgroundColor: z.string().optional(), + borderColor: z.string().optional(), + textColor: z.string().optional(), + borderRadius: z.number().optional(), + opacity: z.number().optional() + }).optional() +}); + +const ConnectionSchema = z.object({ + dashboardId: z.string(), + workflowId: z.string(), + nodeId: z.string(), + widgetId: z.string(), + connectionType: z.enum(['data', 'meta']) +}); + +// In-memory storage (can be replaced with database) +const dashboards = new Map(); +const connections = new Map(); +const dataDirectory = path.join(__dirname, '../../data/dashboards'); + +// Ensure data directory exists +async function ensureDataDirectory() { + try { + await fs.access(dataDirectory); + } catch { + await fs.mkdir(dataDirectory, { recursive: true }); + } +} + +// Helper functions +function generateId(): string { + return `dash_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; +} + +function getDashboardSummary(dashboard: Dashboard): DashboardSummary { + const dashboardConnections = Array.from(connections.values()) + .filter(conn => conn.dashboardId === dashboard.id); + + return { + id: dashboard.id, + name: dashboard.name, + description: dashboard.description, + widgetCount: dashboard.widgets.length, + lastModified: dashboard.updatedAt, + createdAt: dashboard.createdAt, + connectedWorkflows: [...new Set(dashboardConnections.map(conn => conn.workflowId))] + }; +} + +async function saveDashboardToFile(dashboard: Dashboard) { + const filePath = path.join(dataDirectory, `${dashboard.id}.json`); + await fs.writeFile(filePath, JSON.stringify(dashboard, null, 2)); +} + +async function loadDashboardFromFile(id: string): Promise { + try { + const filePath = path.join(dataDirectory, `${id}.json`); + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } +} export const dashboardsRouter = Router(); -const dashboards = new Map(); +// Initialize data directory +ensureDataDirectory().catch(console.error); + +// GET /api/dashboards - List all dashboards (with summaries) +dashboardsRouter.get('/', async (_req, res) => { + try { + const summaries: DashboardSummary[] = []; + + // Load from memory first + for (const dashboard of dashboards.values()) { + summaries.push(getDashboardSummary(dashboard)); + } + + // Load from files if not in memory + const files = await fs.readdir(dataDirectory).catch(() => []); + for (const file of files) { + if (file.endsWith('.json')) { + const id = file.replace('.json', ''); + if (!dashboards.has(id)) { + const dashboard = await loadDashboardFromFile(id); + if (dashboard) { + dashboards.set(id, dashboard); + summaries.push(getDashboardSummary(dashboard)); + } + } + } + } -dashboardsRouter.get('/', (_req, res) => { - res.json(Array.from(dashboards.values())); + // Sort by last modified + summaries.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + + res.json(summaries); + } catch (error) { + console.error('Error listing dashboards:', error); + res.status(500).json({ error: 'Failed to list dashboards' }); + } +}); + +// GET /api/dashboards/:id - Get specific dashboard +dashboardsRouter.get('/:id', async (req, res) => { + try { + const { id } = req.params; + + // Try memory first + let dashboard = dashboards.get(id); + + // Try file if not in memory + if (!dashboard) { + dashboard = await loadDashboardFromFile(id); + if (dashboard) { + dashboards.set(id, dashboard); + } + } + + if (!dashboard) { + return res.status(404).json({ error: 'Dashboard not found' }); + } + + res.json(dashboard); + } catch (error) { + console.error('Error getting dashboard:', error); + res.status(500).json({ error: 'Failed to get dashboard' }); + } +}); + +// POST /api/dashboards - Create new dashboard +dashboardsRouter.post('/', async (req, res) => { + try { + const validatedData = DashboardSchema.parse(req.body); + const now = new Date(); + + const dashboard: Dashboard = { + id: generateId(), + name: validatedData.name, + description: validatedData.description, + layout: validatedData.layout, + widgets: validatedData.widgets || [], + settings: validatedData.settings, + createdAt: now, + updatedAt: now, + createdBy: req.headers['user-id'] as string || 'anonymous' + }; + + dashboards.set(dashboard.id, dashboard); + await saveDashboardToFile(dashboard); + + res.status(201).json(getDashboardSummary(dashboard)); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Invalid dashboard data', details: error.errors }); + } else { + console.error('Error creating dashboard:', error); + res.status(500).json({ error: 'Failed to create dashboard' }); + } + } +}); + +// PUT /api/dashboards/:id - Update dashboard +dashboardsRouter.put('/:id', async (req, res) => { + try { + const { id } = req.params; + const validatedData = DashboardSchema.parse(req.body); + + let dashboard = dashboards.get(id); + if (!dashboard) { + dashboard = await loadDashboardFromFile(id); + if (!dashboard) { + return res.status(404).json({ error: 'Dashboard not found' }); + } + } + + // Update dashboard + dashboard.name = validatedData.name; + dashboard.description = validatedData.description; + dashboard.layout = validatedData.layout; + dashboard.widgets = validatedData.widgets || []; + dashboard.settings = validatedData.settings; + dashboard.updatedAt = new Date(); + + dashboards.set(id, dashboard); + await saveDashboardToFile(dashboard); + + res.json(getDashboardSummary(dashboard)); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Invalid dashboard data', details: error.errors }); + } else { + console.error('Error updating dashboard:', error); + res.status(500).json({ error: 'Failed to update dashboard' }); + } + } }); -dashboardsRouter.post('/', (req, res) => { - const id = String(Date.now()); - const dashboard = { id, ...req.body }; - dashboards.set(id, dashboard); - res.status(201).json(dashboard); +// DELETE /api/dashboards/:id - Delete dashboard +dashboardsRouter.delete('/:id', async (req, res) => { + try { + const { id } = req.params; + + if (!dashboards.has(id)) { + const dashboard = await loadDashboardFromFile(id); + if (!dashboard) { + return res.status(404).json({ error: 'Dashboard not found' }); + } + } + + // Remove from memory + dashboards.delete(id); + + // Remove file + try { + const filePath = path.join(dataDirectory, `${id}.json`); + await fs.unlink(filePath); + } catch { + // File might not exist, ignore + } + + // Remove associated connections + for (const [connId, connection] of connections.entries()) { + if (connection.dashboardId === id) { + connections.delete(connId); + } + } + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting dashboard:', error); + res.status(500).json({ error: 'Failed to delete dashboard' }); + } +}); + +// POST /api/dashboards/:id/connections - Create connection between workflow node and widget +dashboardsRouter.post('/:id/connections', async (req, res) => { + try { + const { id } = req.params; + const connectionData = ConnectionSchema.parse(req.body); + + // Verify dashboard exists + let dashboard = dashboards.get(id); + if (!dashboard) { + dashboard = await loadDashboardFromFile(id); + if (!dashboard) { + return res.status(404).json({ error: 'Dashboard not found' }); + } + } + + // Verify widget exists in dashboard + const widgetExists = dashboard.widgets.some(w => w.id === connectionData.widgetId); + if (!widgetExists) { + return res.status(404).json({ error: 'Widget not found in dashboard' }); + } + + const connectionId = `${connectionData.dashboardId}_${connectionData.widgetId}`; + const connection: WorkflowConnection = { + dashboardId: connectionData.dashboardId, + workflowId: connectionData.workflowId, + nodeId: connectionData.nodeId, + widgetId: connectionData.widgetId, + connectionType: connectionData.connectionType, + status: 'active', + lastSync: new Date() + }; + + connections.set(connectionId, connection); + + res.status(201).json(connection); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Invalid connection data', details: error.errors }); + } else { + console.error('Error creating connection:', error); + res.status(500).json({ error: 'Failed to create connection' }); + } + } +}); + +// GET /api/dashboards/:id/connections - Get connections for dashboard +dashboardsRouter.get('/:id/connections', async (req, res) => { + try { + const { id } = req.params; + const dashboardConnections = Array.from(connections.values()) + .filter(conn => conn.dashboardId === id); + + res.json(dashboardConnections); + } catch (error) { + console.error('Error getting connections:', error); + res.status(500).json({ error: 'Failed to get connections' }); + } +}); + +// DELETE /api/dashboards/:id/connections/:widgetId - Remove connection +dashboardsRouter.delete('/:id/connections/:widgetId', async (req, res) => { + try { + const { id, widgetId } = req.params; + const connectionId = `${id}_${widgetId}`; + + const connection = connections.get(connectionId); + if (!connection) { + return res.status(404).json({ error: 'Connection not found' }); + } + + connections.delete(connectionId); + res.json({ success: true }); + } catch (error) { + console.error('Error deleting connection:', error); + res.status(500).json({ error: 'Failed to delete connection' }); + } +}); + +// POST /api/dashboards/:id/widgets - Add widget to dashboard +dashboardsRouter.post('/:id/widgets', async (req, res) => { + try { + const { id } = req.params; + const widgetData = WidgetSchema.parse(req.body); + + let dashboard = dashboards.get(id); + if (!dashboard) { + dashboard = await loadDashboardFromFile(id); + if (!dashboard) { + return res.status(404).json({ error: 'Dashboard not found' }); + } + } + + // Check if widget ID already exists + const existingWidget = dashboard.widgets.find(w => w.id === widgetData.id); + if (existingWidget) { + return res.status(409).json({ error: 'Widget with this ID already exists' }); + } + + dashboard.widgets.push(widgetData); + dashboard.updatedAt = new Date(); + + dashboards.set(id, dashboard); + await saveDashboardToFile(dashboard); + + res.status(201).json(widgetData); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Invalid widget data', details: error.errors }); + } else { + console.error('Error adding widget:', error); + res.status(500).json({ error: 'Failed to add widget' }); + } + } +}); + +// PUT /api/dashboards/:id/widgets/:widgetId - Update widget +dashboardsRouter.put('/:id/widgets/:widgetId', async (req, res) => { + try { + const { id, widgetId } = req.params; + const widgetData = WidgetSchema.parse(req.body); + + let dashboard = dashboards.get(id); + if (!dashboard) { + dashboard = await loadDashboardFromFile(id); + if (!dashboard) { + return res.status(404).json({ error: 'Dashboard not found' }); + } + } + + const widgetIndex = dashboard.widgets.findIndex(w => w.id === widgetId); + if (widgetIndex === -1) { + return res.status(404).json({ error: 'Widget not found' }); + } + + dashboard.widgets[widgetIndex] = widgetData; + dashboard.updatedAt = new Date(); + + dashboards.set(id, dashboard); + await saveDashboardToFile(dashboard); + + res.json(widgetData); + } catch (error) { + if (error instanceof z.ZodError) { + res.status(400).json({ error: 'Invalid widget data', details: error.errors }); + } else { + console.error('Error updating widget:', error); + res.status(500).json({ error: 'Failed to update widget' }); + } + } +}); + +// DELETE /api/dashboards/:id/widgets/:widgetId - Remove widget +dashboardsRouter.delete('/:id/widgets/:widgetId', async (req, res) => { + try { + const { id, widgetId } = req.params; + + let dashboard = dashboards.get(id); + if (!dashboard) { + dashboard = await loadDashboardFromFile(id); + if (!dashboard) { + return res.status(404).json({ error: 'Dashboard not found' }); + } + } + + const widgetIndex = dashboard.widgets.findIndex(w => w.id === widgetId); + if (widgetIndex === -1) { + return res.status(404).json({ error: 'Widget not found' }); + } + + dashboard.widgets.splice(widgetIndex, 1); + dashboard.updatedAt = new Date(); + + dashboards.set(id, dashboard); + await saveDashboardToFile(dashboard); + + // Remove associated connections + const connectionId = `${id}_${widgetId}`; + connections.delete(connectionId); + + res.json({ success: true }); + } catch (error) { + console.error('Error deleting widget:', error); + res.status(500).json({ error: 'Failed to delete widget' }); + } });