diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js new file mode 100644 index 0000000..9d89904 --- /dev/null +++ b/src/systems/dashboard/dashboardManager.js @@ -0,0 +1,773 @@ +/** + * Dashboard Manager + * + * Orchestrates the complete dashboard system by integrating: + * - GridEngine (positioning) + * - WidgetRegistry (widget definitions) + * - TabManager (multi-tab support) + * - DragDropHandler (drag widgets) + * - ResizeHandler (resize widgets) + * - EditModeManager (edit/view modes) + * - LayoutPersistence (save/load) + * + * Provides high-level API for widget and tab management. + */ + +import { GridEngine } from './gridEngine.js'; +import { WidgetRegistry } from './widgetRegistry.js'; +import { TabManager } from './tabManager.js'; +import { DragDropHandler } from './dragDrop.js'; +import { ResizeHandler } from './resizeHandler.js'; +import { EditModeManager } from './editModeManager.js'; +import { LayoutPersistence } from './layoutPersistence.js'; + +/** + * @typedef {Object} DashboardConfig + * @property {number} columns - Grid column count (default: 12) + * @property {number} rowHeight - Grid row height in pixels (default: 80) + * @property {number} gap - Gap between widgets in pixels (default: 12) + * @property {number} debounceMs - Auto-save debounce delay (default: 500) + * @property {Function} onSave - Callback when layout saved + * @property {Function} onLoad - Callback when layout loaded + * @property {Function} onError - Callback on errors + */ + +/** + * DashboardManager - Complete dashboard system orchestrator + */ +export class DashboardManager { + /** + * @param {HTMLElement} container - Main dashboard container element + * @param {DashboardConfig} config - Configuration options + */ + constructor(container, config = {}) { + if (!container) { + throw new Error('[DashboardManager] Container element is required'); + } + + this.container = container; + this.config = { + columns: config.columns || 12, + rowHeight: config.rowHeight || 80, + gap: config.gap || 12, + debounceMs: config.debounceMs || 500, + onSave: config.onSave, + onLoad: config.onLoad, + onError: config.onError, + ...config + }; + + // Dashboard state + this.currentTabId = null; + this.widgets = new Map(); // widgetId => { widget data, element, tab } + this.defaultLayout = null; + + // System instances + this.gridEngine = null; + this.registry = null; + this.tabManager = null; + this.dragHandler = null; + this.resizeHandler = null; + this.editManager = null; + this.persistence = null; + + // Container elements + this.gridContainer = null; + this.tabContainer = null; + + this.changeListeners = new Set(); + + console.log('[DashboardManager] Initialized'); + } + + /** + * Initialize all dashboard systems + */ + async init() { + console.log('[DashboardManager] Initializing systems...'); + + // Create container structure + this.createContainerStructure(); + + // Initialize Grid Engine + this.gridEngine = new GridEngine({ + columns: this.config.columns, + rowHeight: this.config.rowHeight, + gap: this.config.gap, + container: this.gridContainer + }); + + // Initialize Widget Registry + this.registry = new WidgetRegistry(); + + // Initialize Tab Manager + this.tabManager = new TabManager({ + onTabChange: (tabId) => this.onTabChange(tabId), + onTabCreate: (tab) => this.onTabCreate(tab), + onTabDelete: (tabId) => this.onTabDelete(tabId), + onTabRename: (tabId, newName) => this.onTabRename(tabId, newName), + onTabReorder: (fromIndex, toIndex) => this.onTabReorder(fromIndex, toIndex) + }); + + // Initialize Drag & Drop + this.dragHandler = new DragDropHandler(this.gridEngine, { + showGrid: true, + enableSnap: true + }); + + // Initialize Resize Handler + this.resizeHandler = new ResizeHandler(this.gridEngine, { + minWidth: 2, + minHeight: 2, + maxWidth: this.config.columns, + maxHeight: 10 + }); + + // Initialize Edit Mode Manager + this.editManager = new EditModeManager({ + container: this.container, + onSave: () => this.handleEditSave(), + onCancel: (originalLayout) => this.handleEditCancel(originalLayout), + onWidgetAdd: (type) => this.addWidget(type), + onWidgetDelete: (widgetId) => this.removeWidget(widgetId), + onWidgetSettings: (widgetId) => this.openWidgetSettings(widgetId) + }); + + // Initialize Layout Persistence + this.persistence = new LayoutPersistence({ + debounceMs: this.config.debounceMs, + onSave: (layout) => { + console.log('[DashboardManager] Layout saved'); + if (this.config.onSave) this.config.onSave(layout); + }, + onLoad: (layout) => { + console.log('[DashboardManager] Layout loaded'); + if (this.config.onLoad) this.config.onLoad(layout); + }, + onError: (error) => { + console.error('[DashboardManager] Error:', error); + if (this.config.onError) this.config.onError(error); + } + }); + + // Try to load saved layout + await this.loadLayout(); + + console.log('[DashboardManager] All systems initialized'); + this.notifyChange('initialized'); + } + + /** + * Create dashboard container structure + */ + createContainerStructure() { + // Clear container + this.container.innerHTML = ''; + + // Create tab container + this.tabContainer = document.createElement('div'); + this.tabContainer.className = 'rpg-dashboard-tabs'; + this.tabContainer.id = 'rpg-dashboard-tabs'; + this.container.appendChild(this.tabContainer); + + // Create grid container + this.gridContainer = document.createElement('div'); + this.gridContainer.className = 'rpg-dashboard-grid'; + this.gridContainer.id = 'rpg-dashboard-grid'; + this.gridContainer.style.position = 'relative'; + this.gridContainer.style.minHeight = '600px'; + this.container.appendChild(this.gridContainer); + } + + /** + * Add a new widget to the dashboard + * @param {string} type - Widget type (must be registered) + * @param {string} [tabId] - Tab ID (default: current tab) + * @param {Object} [config] - Widget configuration + * @returns {string} Widget ID + */ + addWidget(type, tabId = null, config = {}) { + const targetTabId = tabId || this.currentTabId; + if (!targetTabId) { + throw new Error('[DashboardManager] No tab selected'); + } + + // Get widget definition from registry + const definition = this.registry.get(type); + if (!definition) { + throw new Error(`[DashboardManager] Widget type "${type}" not registered`); + } + + // Generate unique widget ID + const widgetId = `widget-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + // Find available position in grid + const position = this.findAvailablePosition(definition.defaultSize); + + // Create widget data + const widget = { + id: widgetId, + type, + x: position.x, + y: position.y, + w: definition.defaultSize.w, + h: definition.defaultSize.h, + config: config || {} + }; + + // Add to tab + const tab = this.tabManager.getTab(targetTabId); + if (!tab) { + throw new Error(`[DashboardManager] Tab "${targetTabId}" not found`); + } + + if (!tab.widgets) { + tab.widgets = []; + } + tab.widgets.push(widget); + + // Render widget if on current tab + if (targetTabId === this.currentTabId) { + this.renderWidget(widget, definition); + } + + // Trigger auto-save + this.triggerAutoSave(); + + console.log(`[DashboardManager] Added widget: ${widgetId} (${type}) to tab: ${targetTabId}`); + this.notifyChange('widgetAdded', { widgetId, type, tabId: targetTabId }); + + return widgetId; + } + + /** + * Remove a widget from the dashboard + * @param {string} widgetId - Widget ID to remove + */ + removeWidget(widgetId) { + // Find widget in current tab + const tab = this.tabManager.getTab(this.currentTabId); + if (!tab || !tab.widgets) { + console.warn(`[DashboardManager] Widget ${widgetId} not found in current tab`); + return; + } + + const index = tab.widgets.findIndex(w => w.id === widgetId); + if (index === -1) { + console.warn(`[DashboardManager] Widget ${widgetId} not found`); + return; + } + + // Get widget element and definition + const widgetData = this.widgets.get(widgetId); + if (widgetData) { + // Call widget cleanup + const definition = this.registry.get(widgetData.widget.type); + if (definition && definition.onRemove) { + definition.onRemove(widgetData.element, widgetData.widget.config); + } + + // Destroy drag/resize handlers + this.dragHandler.destroyWidget(widgetData.element); + this.resizeHandler.destroyWidget(widgetData.element); + + // Remove element + widgetData.element.remove(); + + // Remove from map + this.widgets.delete(widgetId); + } + + // Remove from tab + tab.widgets.splice(index, 1); + + // Trigger auto-save + this.triggerAutoSave(); + + console.log(`[DashboardManager] Removed widget: ${widgetId}`); + this.notifyChange('widgetRemoved', { widgetId }); + } + + /** + * Update a widget's configuration + * @param {string} widgetId - Widget ID + * @param {Object} updates - Configuration updates + */ + updateWidget(widgetId, updates) { + const widgetData = this.widgets.get(widgetId); + if (!widgetData) { + console.warn(`[DashboardManager] Widget ${widgetId} not found`); + return; + } + + // Update widget config + Object.assign(widgetData.widget.config, updates); + + // Get widget definition + const definition = this.registry.get(widgetData.widget.type); + + // Call onConfigChange if defined + if (definition && definition.onConfigChange) { + definition.onConfigChange(widgetData.element, widgetData.widget.config); + } + + // Re-render widget + this.renderWidgetContent(widgetData.element, widgetData.widget, definition); + + // Trigger auto-save + this.triggerAutoSave(); + + console.log(`[DashboardManager] Updated widget: ${widgetId}`); + this.notifyChange('widgetUpdated', { widgetId, updates }); + } + + /** + * Render a single widget + * @param {Object} widget - Widget data + * @param {Object} definition - Widget definition + */ + renderWidget(widget, definition) { + // Create widget element + const element = document.createElement('div'); + element.className = 'rpg-widget'; + element.id = `widget-${widget.id}`; + element.dataset.widgetId = widget.id; + element.dataset.widgetType = widget.type; + + // Position widget using grid engine + const pos = this.gridEngine.getPixelPosition(widget); + element.style.position = 'absolute'; + element.style.left = `${pos.left}px`; + element.style.top = `${pos.top}px`; + element.style.width = `${pos.width}px`; + element.style.height = `${pos.height}px`; + + // Add to grid + this.gridContainer.appendChild(element); + + // Render widget content + this.renderWidgetContent(element, widget, definition); + + // Initialize drag & drop + this.dragHandler.initWidget(element, widget, (updated, newX, newY) => { + widget.x = newX; + widget.y = newY; + this.repositionWidget(element, widget); + this.triggerAutoSave(); + }); + + // Initialize resize + this.resizeHandler.initWidget(element, widget, (updated, newW, newH, newX, newY) => { + widget.w = newW; + widget.h = newH; + widget.x = newX; + widget.y = newY; + this.repositionWidget(element, widget); + + // Call onResize if defined + if (definition.onResize) { + definition.onResize(element, newW, newH); + } + + this.triggerAutoSave(); + }, { + minW: definition.minSize.w, + minH: definition.minSize.h + }); + + // Add edit mode controls + if (this.editManager) { + this.editManager.addWidgetControls(element, widget.id); + } + + // Store widget data + this.widgets.set(widget.id, { + widget, + element, + definition, + tabId: this.currentTabId + }); + } + + /** + * Render widget content (called by widget render function) + * @param {HTMLElement} element - Widget element + * @param {Object} widget - Widget data + * @param {Object} definition - Widget definition + */ + renderWidgetContent(element, widget, definition) { + // Clear existing content (except resize handles and controls) + const handles = element.querySelector('.resize-handles'); + const controls = element.querySelector('.widget-edit-controls'); + element.innerHTML = ''; + if (handles) element.appendChild(handles); + if (controls) element.appendChild(controls); + + // Call widget render function + if (definition && definition.render) { + definition.render(element, widget.config || {}); + } + } + + /** + * Reposition widget element + * @param {HTMLElement} element - Widget element + * @param {Object} widget - Widget data + */ + repositionWidget(element, widget) { + const pos = this.gridEngine.getPixelPosition(widget); + element.style.left = `${pos.left}px`; + element.style.top = `${pos.top}px`; + element.style.width = `${pos.width}px`; + element.style.height = `${pos.height}px`; + } + + /** + * Find available position for new widget + * @param {Object} size - Widget size { w, h } + * @returns {Object} Position { x, y } + */ + findAvailablePosition(size) { + // Simple algorithm: try to place at top-left, move right, then down + const tab = this.tabManager.getTab(this.currentTabId); + const widgets = tab?.widgets || []; + + for (let y = 0; y < 20; y++) { + for (let x = 0; x <= this.config.columns - size.w; x++) { + const position = { x, y }; + const testWidget = { ...position, w: size.w, h: size.h }; + + // Check if position is free + const hasCollision = widgets.some(w => + this.gridEngine.detectCollision(testWidget, [w]) + ); + + if (!hasCollision) { + return position; + } + } + } + + // Fallback: place at bottom + const maxY = Math.max(...widgets.map(w => w.y + w.h), 0); + return { x: 0, y: maxY }; + } + + /** + * Create a new tab + * @param {string} name - Tab name + * @returns {string} Tab ID + */ + createTab(name) { + const tabId = this.tabManager.createTab(name); + this.triggerAutoSave(); + return tabId; + } + + /** + * Switch to a different tab + * @param {string} tabId - Tab ID to switch to + */ + switchTab(tabId) { + this.tabManager.switchTab(tabId); + } + + /** + * Handle tab change event + * @param {string} tabId - New active tab ID + */ + onTabChange(tabId) { + console.log(`[DashboardManager] Switching to tab: ${tabId}`); + this.currentTabId = tabId; + + // Clear grid + this.clearGrid(); + + // Render all widgets in this tab + const tab = this.tabManager.getTab(tabId); + if (tab && tab.widgets) { + tab.widgets.forEach(widget => { + const definition = this.registry.get(widget.type); + if (definition) { + this.renderWidget(widget, definition); + } + }); + } + + this.notifyChange('tabChanged', { tabId }); + } + + /** + * Handle tab creation + */ + onTabCreate(tab) { + console.log(`[DashboardManager] Tab created: ${tab.id}`); + this.triggerAutoSave(); + } + + /** + * Handle tab deletion + */ + onTabDelete(tabId) { + console.log(`[DashboardManager] Tab deleted: ${tabId}`); + this.triggerAutoSave(); + } + + /** + * Handle tab rename + */ + onTabRename(tabId, newName) { + console.log(`[DashboardManager] Tab renamed: ${tabId} -> ${newName}`); + this.triggerAutoSave(); + } + + /** + * Handle tab reorder + */ + onTabReorder(fromIndex, toIndex) { + console.log(`[DashboardManager] Tabs reordered: ${fromIndex} -> ${toIndex}`); + this.triggerAutoSave(); + } + + /** + * Clear all widgets from grid + */ + clearGrid() { + // Destroy all widgets + this.widgets.forEach((widgetData, widgetId) => { + const definition = this.registry.get(widgetData.widget.type); + if (definition && definition.onRemove) { + definition.onRemove(widgetData.element, widgetData.widget.config); + } + this.dragHandler.destroyWidget(widgetData.element); + this.resizeHandler.destroyWidget(widgetData.element); + widgetData.element.remove(); + }); + + this.widgets.clear(); + } + + /** + * Enter edit mode + */ + enterEditMode() { + this.editManager.enterEditMode(); + } + + /** + * Exit edit mode + * @param {boolean} save - Whether to save changes + */ + exitEditMode(save = false) { + this.editManager.exitEditMode(save); + } + + /** + * Handle edit mode save + */ + handleEditSave() { + console.log('[DashboardManager] Edit mode saved'); + this.triggerAutoSave(); + } + + /** + * Handle edit mode cancel + */ + handleEditCancel(originalLayout) { + console.log('[DashboardManager] Edit mode cancelled'); + // Could restore original layout here if needed + } + + /** + * Open widget settings dialog + * @param {string} widgetId - Widget ID + */ + openWidgetSettings(widgetId) { + const widgetData = this.widgets.get(widgetId); + if (!widgetData) return; + + const definition = this.registry.get(widgetData.widget.type); + if (definition && definition.getConfig) { + // Get config schema + const configSchema = definition.getConfig(); + // TODO: Show config dialog + console.log('[DashboardManager] Widget settings:', widgetId, configSchema); + } + } + + /** + * Get current dashboard configuration + * @returns {Object} Dashboard configuration + */ + getDashboardConfig() { + return { + version: 2, + gridConfig: { + columns: this.config.columns, + rowHeight: this.config.rowHeight, + gap: this.config.gap + }, + tabs: this.tabManager.getTabs().map(tab => ({ + id: tab.id, + name: tab.name, + widgets: tab.widgets || [] + })) + }; + } + + /** + * Apply dashboard configuration + * @param {Object} config - Dashboard configuration + */ + applyDashboardConfig(config) { + console.log('[DashboardManager] Applying dashboard config'); + + // Clear existing + this.clearGrid(); + this.tabManager.deleteAllTabs(); + + // Create tabs + config.tabs.forEach(tabConfig => { + this.tabManager.createTab(tabConfig.name, tabConfig.id); + const tab = this.tabManager.getTab(tabConfig.id); + tab.widgets = tabConfig.widgets || []; + }); + + // Switch to first tab + if (config.tabs.length > 0) { + this.switchTab(config.tabs[0].id); + } + + this.notifyChange('configApplied', { config }); + } + + /** + * Save current layout + * @param {boolean} immediate - Skip debounce + */ + async saveLayout(immediate = false) { + const config = this.getDashboardConfig(); + await this.persistence.saveLayout(config, immediate); + } + + /** + * Load saved layout + */ + async loadLayout() { + try { + const saved = await this.persistence.loadLayout(); + if (saved) { + this.applyDashboardConfig(saved); + } else if (this.defaultLayout) { + console.log('[DashboardManager] No saved layout, using default'); + this.applyDashboardConfig(this.defaultLayout); + } + } catch (error) { + console.error('[DashboardManager] Failed to load layout:', error); + if (this.defaultLayout) { + this.applyDashboardConfig(this.defaultLayout); + } + } + } + + /** + * Export layout as JSON + * @param {string} filename - Export filename + */ + exportLayout(filename = 'dashboard-layout.json') { + const config = this.getDashboardConfig(); + this.persistence.exportLayout(config, filename); + } + + /** + * Import layout from JSON file + * @param {File} file - JSON file + */ + async importLayout(file) { + const config = await this.persistence.importLayout(file); + this.applyDashboardConfig(config); + await this.saveLayout(true); + } + + /** + * Reset to default layout + */ + async resetLayout() { + if (!this.defaultLayout) { + console.warn('[DashboardManager] No default layout defined'); + return; + } + + await this.persistence.resetToDefault(this.defaultLayout); + this.applyDashboardConfig(this.defaultLayout); + } + + /** + * Set default layout + * @param {Object} layout - Default layout configuration + */ + setDefaultLayout(layout) { + this.defaultLayout = layout; + } + + /** + * Trigger auto-save + */ + triggerAutoSave() { + const config = this.getDashboardConfig(); + this.persistence.saveLayout(config).catch(err => { + console.error('[DashboardManager] Auto-save failed:', err); + }); + } + + /** + * Register change listener + * @param {Function} callback - Callback function (event, data) => void + */ + onChange(callback) { + this.changeListeners.add(callback); + } + + /** + * Unregister change listener + * @param {Function} callback - Callback to remove + */ + offChange(callback) { + this.changeListeners.delete(callback); + } + + /** + * Notify change listeners + * @private + */ + notifyChange(event, data) { + this.changeListeners.forEach(callback => { + try { + callback(event, data); + } catch (error) { + console.error('[DashboardManager] Error in change listener:', error); + } + }); + } + + /** + * Destroy dashboard and cleanup + */ + destroy() { + console.log('[DashboardManager] Destroying dashboard'); + + // Clear grid + this.clearGrid(); + + // Destroy systems + if (this.editManager) this.editManager.destroy(); + if (this.dragHandler) this.dragHandler.destroy(); + if (this.persistence) this.persistence.destroy(); + + // Clear listeners + this.changeListeners.clear(); + + // Clear container + this.container.innerHTML = ''; + } +} diff --git a/src/systems/dashboard/widgetBase.js b/src/systems/dashboard/widgetBase.js new file mode 100644 index 0000000..7d2dbff --- /dev/null +++ b/src/systems/dashboard/widgetBase.js @@ -0,0 +1,472 @@ +/** + * Widget Base Utilities + * + * Provides common utilities for widget development: + * - Standard widget HTML structure + * - Editable field handlers + * - Configuration UI helpers + * - Event listener management + */ + +/** + * Create standard widget container structure + * @param {Object} options - Widget options + * @param {string} options.title - Widget title + * @param {string} options.icon - Widget icon (emoji or FontAwesome class) + * @param {string} options.content - Widget content HTML + * @param {string} [options.headerClass] - Additional header CSS class + * @param {string} [options.contentClass] - Additional content CSS class + * @returns {string} Widget HTML + */ +export function createWidgetContainer({ title, icon, content, headerClass = '', contentClass = '' }) { + return ` +
+ `; +} + +/** + * Create editable field with auto-save + * @param {Object} options - Field options + * @param {string} options.value - Field value + * @param {string} options.field - Field name (for data-field attribute) + * @param {string} [options.placeholder] - Placeholder text + * @param {string} [options.className] - Additional CSS class + * @param {Function} [options.onSave] - Callback when field saved + * @returns {string} Editable field HTML + */ +export function createEditableField({ value, field, placeholder = '', className = '', onSave }) { + const dataAttr = onSave ? `data-on-save="true"` : ''; + return ` + ${value} + `; +} + +/** + * Attach editable field handlers to a container + * @param {HTMLElement} container - Container element + * @param {Function} onFieldChange - Callback (fieldName, newValue) => void + */ +export function attachEditableHandlers(container, onFieldChange) { + if (!container) return; + + // Find all editable fields + const editableFields = container.querySelectorAll('[contenteditable="true"]'); + + editableFields.forEach(field => { + // Store original value + let originalValue = field.textContent.trim(); + + // Focus event - select all text + field.addEventListener('focus', (e) => { + originalValue = e.target.textContent.trim(); + + // Select all text + const range = document.createRange(); + range.selectNodeContents(e.target); + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + }); + + // Blur event - save changes + field.addEventListener('blur', (e) => { + const newValue = e.target.textContent.trim(); + const fieldName = e.target.dataset.field; + + if (newValue !== originalValue && newValue !== '') { + console.log(`[WidgetBase] Field changed: ${fieldName} = ${newValue}`); + if (onFieldChange) { + onFieldChange(fieldName, newValue); + } + } else if (newValue === '') { + // Restore original if empty + e.target.textContent = originalValue; + } + }); + + // Enter key - blur to save + field.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.target.blur(); + } + // Escape key - cancel edit + if (e.key === 'Escape') { + e.preventDefault(); + e.target.textContent = originalValue; + e.target.blur(); + } + }); + + // Prevent paste with formatting + field.addEventListener('paste', (e) => { + e.preventDefault(); + const text = (e.clipboardData || window.clipboardData).getData('text/plain'); + document.execCommand('insertText', false, text); + }); + }); +} + +/** + * Create progress bar HTML + * @param {Object} options - Progress bar options + * @param {string} options.label - Label text + * @param {number} options.value - Current value (0-100) + * @param {string} [options.gradient] - CSS gradient for bar + * @param {boolean} [options.editable] - Whether value is editable + * @param {string} [options.field] - Field name for editable value + * @returns {string} Progress bar HTML + */ +export function createProgressBar({ label, value, gradient, editable = false, field = '' }) { + const barStyle = gradient ? `background: ${gradient}` : ''; + const valueHtml = editable + ? `${value}%` + : `${value}%`; + + return ` +