feat: Phase 1 complete + Task 2.1 User Stats Widget
Phase 1 Foundation: - DashboardManager: Complete orchestrator for all Epic 1 systems (572 lines) - WidgetBase: Common utilities for widget development (498 lines) Task 2.1 User Stats Widget: - Extracted and refactored from renderUserStats() - Clean vanilla JS implementation (408 lines) - Editable stat values with live updates - Progress bars with configurable colors - User portrait, name, and level display - Classic D&D stats (STR/DEX/CON/INT/WIS/CHA) with +/- buttons - Fully configurable (show/hide sections, visible stats) - Mobile-responsive with layout adjustments - No jQuery dependencies Dashboard Manager features: - Widget lifecycle management (add/remove/update) - Tab coordination with TabManager - Drag/drop and resize integration - Edit mode management - Layout persistence (save/load/export/import) - Auto-save with debouncing - Event-driven architecture Epic 2 progress: 1/4 core widgets complete
This commit is contained in:
@@ -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 = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 `
|
||||||
|
<div class="rpg-widget-container">
|
||||||
|
<div class="rpg-widget-header ${headerClass}">
|
||||||
|
<span class="rpg-widget-icon">${icon}</span>
|
||||||
|
<span class="rpg-widget-title">${title}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rpg-widget-content ${contentClass}">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `
|
||||||
|
<span class="rpg-editable ${className}"
|
||||||
|
contenteditable="true"
|
||||||
|
data-field="${field}"
|
||||||
|
${dataAttr}
|
||||||
|
title="Click to edit">${value}</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
? `<span class="rpg-stat-value rpg-editable-stat" contenteditable="true" data-field="${field}" title="Click to edit">${value}%</span>`
|
||||||
|
: `<span class="rpg-stat-value">${value}%</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rpg-stat-row">
|
||||||
|
<span class="rpg-stat-label">${label}:</span>
|
||||||
|
<div class="rpg-stat-bar" style="${barStyle}">
|
||||||
|
<div class="rpg-stat-fill" style="width: ${100 - value}%"></div>
|
||||||
|
</div>
|
||||||
|
${valueHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update progress bar value
|
||||||
|
* @param {HTMLElement} container - Container element
|
||||||
|
* @param {string} field - Field name
|
||||||
|
* @param {number} newValue - New value (0-100)
|
||||||
|
*/
|
||||||
|
export function updateProgressBar(container, field, newValue) {
|
||||||
|
const valueSpan = container.querySelector(`[data-field="${field}"]`);
|
||||||
|
const fillDiv = valueSpan?.parentElement.querySelector('.rpg-stat-fill');
|
||||||
|
|
||||||
|
if (valueSpan) {
|
||||||
|
valueSpan.textContent = `${newValue}%`;
|
||||||
|
}
|
||||||
|
if (fillDiv) {
|
||||||
|
fillDiv.style.width = `${100 - newValue}%`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create icon button
|
||||||
|
* @param {Object} options - Button options
|
||||||
|
* @param {string} options.icon - FontAwesome icon class or emoji
|
||||||
|
* @param {string} [options.label] - Button label
|
||||||
|
* @param {string} [options.className] - Additional CSS class
|
||||||
|
* @param {string} [options.title] - Tooltip text
|
||||||
|
* @returns {string} Button HTML
|
||||||
|
*/
|
||||||
|
export function createIconButton({ icon, label = '', className = '', title = '' }) {
|
||||||
|
const isFontAwesome = icon.startsWith('fa-');
|
||||||
|
const iconHtml = isFontAwesome
|
||||||
|
? `<i class="${icon}"></i>`
|
||||||
|
: `<span class="rpg-emoji-icon">${icon}</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<button class="rpg-icon-btn ${className}" title="${title}">
|
||||||
|
${iconHtml}
|
||||||
|
${label ? `<span>${label}</span>` : ''}
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create toggle switch
|
||||||
|
* @param {Object} options - Toggle options
|
||||||
|
* @param {string} options.id - Toggle ID
|
||||||
|
* @param {string} options.label - Toggle label
|
||||||
|
* @param {boolean} options.checked - Initial checked state
|
||||||
|
* @param {Function} [options.onChange] - Change callback
|
||||||
|
* @returns {string} Toggle HTML
|
||||||
|
*/
|
||||||
|
export function createToggle({ id, label, checked = false, onChange }) {
|
||||||
|
return `
|
||||||
|
<label class="rpg-toggle-label">
|
||||||
|
<input type="checkbox" id="${id}" ${checked ? 'checked' : ''}>
|
||||||
|
<span class="rpg-toggle-slider"></span>
|
||||||
|
<span class="rpg-toggle-text">${label}</span>
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach toggle handler
|
||||||
|
* @param {HTMLElement} container - Container element
|
||||||
|
* @param {string} toggleId - Toggle input ID
|
||||||
|
* @param {Function} onChange - Callback (checked) => void
|
||||||
|
*/
|
||||||
|
export function attachToggleHandler(container, toggleId, onChange) {
|
||||||
|
const toggle = container.querySelector(`#${toggleId}`);
|
||||||
|
if (!toggle) return;
|
||||||
|
|
||||||
|
toggle.addEventListener('change', (e) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(e.target.checked);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create select dropdown
|
||||||
|
* @param {Object} options - Select options
|
||||||
|
* @param {string} options.id - Select ID
|
||||||
|
* @param {Array<{value: string, label: string}>} options.options - Options array
|
||||||
|
* @param {string} [options.selected] - Selected value
|
||||||
|
* @param {string} [options.className] - Additional CSS class
|
||||||
|
* @returns {string} Select HTML
|
||||||
|
*/
|
||||||
|
export function createSelect({ id, options, selected = '', className = '' }) {
|
||||||
|
const optionsHtml = options.map(opt =>
|
||||||
|
`<option value="${opt.value}" ${opt.value === selected ? 'selected' : ''}>${opt.label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<select id="${id}" class="rpg-select ${className}">
|
||||||
|
${optionsHtml}
|
||||||
|
</select>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach select handler
|
||||||
|
* @param {HTMLElement} container - Container element
|
||||||
|
* @param {string} selectId - Select element ID
|
||||||
|
* @param {Function} onChange - Callback (value) => void
|
||||||
|
*/
|
||||||
|
export function attachSelectHandler(container, selectId, onChange) {
|
||||||
|
const select = container.querySelector(`#${selectId}`);
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
select.addEventListener('change', (e) => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(e.target.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create configuration section
|
||||||
|
* @param {Object} options - Config options
|
||||||
|
* @param {string} options.title - Section title
|
||||||
|
* @param {string} options.content - Section content HTML
|
||||||
|
* @param {boolean} [options.collapsible] - Whether section is collapsible
|
||||||
|
* @param {boolean} [options.collapsed] - Initial collapsed state
|
||||||
|
* @returns {string} Config section HTML
|
||||||
|
*/
|
||||||
|
export function createConfigSection({ title, content, collapsible = false, collapsed = false }) {
|
||||||
|
if (!collapsible) {
|
||||||
|
return `
|
||||||
|
<div class="rpg-config-section">
|
||||||
|
<h4 class="rpg-config-title">${title}</h4>
|
||||||
|
<div class="rpg-config-content">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rpg-config-section ${collapsed ? 'collapsed' : ''}">
|
||||||
|
<h4 class="rpg-config-title rpg-collapsible">
|
||||||
|
${title}
|
||||||
|
<i class="fa-solid fa-chevron-${collapsed ? 'down' : 'up'}"></i>
|
||||||
|
</h4>
|
||||||
|
<div class="rpg-config-content" style="${collapsed ? 'display: none;' : ''}">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach collapsible section handlers
|
||||||
|
* @param {HTMLElement} container - Container element
|
||||||
|
*/
|
||||||
|
export function attachCollapsibleHandlers(container) {
|
||||||
|
const collapsibles = container.querySelectorAll('.rpg-collapsible');
|
||||||
|
|
||||||
|
collapsibles.forEach(header => {
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
const section = header.parentElement;
|
||||||
|
const content = section.querySelector('.rpg-config-content');
|
||||||
|
const icon = header.querySelector('i');
|
||||||
|
|
||||||
|
const isCollapsed = section.classList.toggle('collapsed');
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
content.style.display = 'none';
|
||||||
|
icon.className = 'fa-solid fa-chevron-down';
|
||||||
|
} else {
|
||||||
|
content.style.display = 'block';
|
||||||
|
icon.className = 'fa-solid fa-chevron-up';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debounce function for auto-save
|
||||||
|
* @param {Function} func - Function to debounce
|
||||||
|
* @param {number} wait - Wait time in ms
|
||||||
|
* @returns {Function} Debounced function
|
||||||
|
*/
|
||||||
|
export function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe number parser with fallback
|
||||||
|
* @param {string|number} value - Value to parse
|
||||||
|
* @param {number} fallback - Fallback value
|
||||||
|
* @param {number} [min] - Minimum value
|
||||||
|
* @param {number} [max] - Maximum value
|
||||||
|
* @returns {number} Parsed number
|
||||||
|
*/
|
||||||
|
export function parseNumber(value, fallback, min = -Infinity, max = Infinity) {
|
||||||
|
const num = typeof value === 'string' ? parseInt(value, 10) : value;
|
||||||
|
if (isNaN(num)) return fallback;
|
||||||
|
return Math.max(min, Math.min(max, num));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create loading spinner
|
||||||
|
* @param {string} [text] - Loading text
|
||||||
|
* @returns {string} Loading spinner HTML
|
||||||
|
*/
|
||||||
|
export function createLoadingSpinner(text = 'Loading...') {
|
||||||
|
return `
|
||||||
|
<div class="rpg-loading-spinner">
|
||||||
|
<i class="fa-solid fa-spinner fa-spin"></i>
|
||||||
|
<span>${text}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create empty state message
|
||||||
|
* @param {Object} options - Empty state options
|
||||||
|
* @param {string} options.icon - Icon (emoji or FA class)
|
||||||
|
* @param {string} options.message - Message text
|
||||||
|
* @param {string} [options.action] - Optional action button HTML
|
||||||
|
* @returns {string} Empty state HTML
|
||||||
|
*/
|
||||||
|
export function createEmptyState({ icon, message, action = '' }) {
|
||||||
|
const isFontAwesome = icon.startsWith('fa-');
|
||||||
|
const iconHtml = isFontAwesome
|
||||||
|
? `<i class="${icon}"></i>`
|
||||||
|
: `<span class="rpg-emoji-icon">${icon}</span>`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rpg-empty-state">
|
||||||
|
<div class="rpg-empty-icon">${iconHtml}</div>
|
||||||
|
<p class="rpg-empty-message">${message}</p>
|
||||||
|
${action}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML to prevent XSS
|
||||||
|
* @param {string} unsafe - Unsafe string
|
||||||
|
* @returns {string} Escaped string
|
||||||
|
*/
|
||||||
|
export function escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format number with commas
|
||||||
|
* @param {number} num - Number to format
|
||||||
|
* @returns {string} Formatted number
|
||||||
|
*/
|
||||||
|
export function formatNumber(num) {
|
||||||
|
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate text with ellipsis
|
||||||
|
* @param {string} text - Text to truncate
|
||||||
|
* @param {number} maxLength - Maximum length
|
||||||
|
* @returns {string} Truncated text
|
||||||
|
*/
|
||||||
|
export function truncateText(text, maxLength) {
|
||||||
|
if (text.length <= maxLength) return text;
|
||||||
|
return text.slice(0, maxLength - 3) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create responsive grid for items
|
||||||
|
* @param {Array<string>} items - Array of item HTML
|
||||||
|
* @param {number} [columns] - Number of columns (auto if not specified)
|
||||||
|
* @param {string} [gap] - Gap size (CSS value)
|
||||||
|
* @returns {string} Grid HTML
|
||||||
|
*/
|
||||||
|
export function createGrid(items, columns = null, gap = '12px') {
|
||||||
|
const gridStyle = columns
|
||||||
|
? `grid-template-columns: repeat(${columns}, 1fr); gap: ${gap};`
|
||||||
|
: `grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: ${gap};`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rpg-grid" style="display: grid; ${gridStyle}">
|
||||||
|
${items.join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create card component
|
||||||
|
* @param {Object} options - Card options
|
||||||
|
* @param {string} options.title - Card title
|
||||||
|
* @param {string} options.content - Card content
|
||||||
|
* @param {string} [options.icon] - Optional icon
|
||||||
|
* @param {string} [options.footer] - Optional footer HTML
|
||||||
|
* @param {string} [options.className] - Additional CSS class
|
||||||
|
* @returns {string} Card HTML
|
||||||
|
*/
|
||||||
|
export function createCard({ title, content, icon = '', footer = '', className = '' }) {
|
||||||
|
const iconHtml = icon ? `<span class="rpg-card-icon">${icon}</span>` : '';
|
||||||
|
const footerHtml = footer ? `<div class="rpg-card-footer">${footer}</div>` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rpg-card ${className}">
|
||||||
|
<div class="rpg-card-header">
|
||||||
|
${iconHtml}
|
||||||
|
<h5 class="rpg-card-title">${title}</h5>
|
||||||
|
</div>
|
||||||
|
<div class="rpg-card-body">
|
||||||
|
${content}
|
||||||
|
</div>
|
||||||
|
${footerHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
/**
|
||||||
|
* User Stats Widget
|
||||||
|
*
|
||||||
|
* Displays user health/satiety/energy/hygiene/arousal bars,
|
||||||
|
* mood/conditions, and classic D&D stats (STR/DEX/CON/INT/WIS/CHA).
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Editable stat values with live update
|
||||||
|
* - Progress bars with customizable colors
|
||||||
|
* - User portrait and level display
|
||||||
|
* - Classic stats with +/- buttons
|
||||||
|
* - Mobile-responsive layout
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createProgressBar, attachEditableHandlers, parseNumber } from '../widgetBase.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register User Stats Widget
|
||||||
|
* @param {WidgetRegistry} registry - Widget registry instance
|
||||||
|
* @param {Object} dependencies - External dependencies
|
||||||
|
* @param {Function} dependencies.getContext - Get SillyTavern context
|
||||||
|
* @param {Function} dependencies.getUserAvatar - Get user avatar URL
|
||||||
|
* @param {Function} dependencies.getExtensionSettings - Get extension settings
|
||||||
|
* @param {Function} dependencies.onStatsChange - Callback when stats change
|
||||||
|
*/
|
||||||
|
export function registerUserStatsWidget(registry, dependencies) {
|
||||||
|
const {
|
||||||
|
getContext,
|
||||||
|
getUserAvatar,
|
||||||
|
getExtensionSettings,
|
||||||
|
onStatsChange
|
||||||
|
} = dependencies;
|
||||||
|
|
||||||
|
registry.register('userStats', {
|
||||||
|
name: 'User Stats',
|
||||||
|
icon: '❤️',
|
||||||
|
description: 'Health, energy, satiety bars and classic RPG stats',
|
||||||
|
minSize: { w: 4, h: 3 },
|
||||||
|
defaultSize: { w: 6, h: 4 },
|
||||||
|
requiresSchema: false,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render widget content
|
||||||
|
* @param {HTMLElement} container - Widget container
|
||||||
|
* @param {Object} config - Widget configuration
|
||||||
|
*/
|
||||||
|
render(container, config = {}) {
|
||||||
|
const settings = getExtensionSettings();
|
||||||
|
const stats = settings.userStats;
|
||||||
|
const classicStats = settings.classicStats;
|
||||||
|
const context = getContext();
|
||||||
|
const userName = context.name1;
|
||||||
|
const userPortrait = getUserAvatar();
|
||||||
|
|
||||||
|
// Merge default config with user config
|
||||||
|
const finalConfig = {
|
||||||
|
showClassicStats: true,
|
||||||
|
showMood: true,
|
||||||
|
showPortrait: true,
|
||||||
|
statBarGradient: true,
|
||||||
|
visibleStats: ['health', 'satiety', 'energy', 'hygiene', 'arousal'],
|
||||||
|
...config
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create gradient for stat bars
|
||||||
|
const gradient = finalConfig.statBarGradient
|
||||||
|
? `linear-gradient(to right, ${settings.statBarColorLow}, ${settings.statBarColorHigh})`
|
||||||
|
: settings.statBarColorHigh;
|
||||||
|
|
||||||
|
// Build progress bars HTML
|
||||||
|
const progressBarsHtml = finalConfig.visibleStats.map(statName => {
|
||||||
|
const label = statName.charAt(0).toUpperCase() + statName.slice(1);
|
||||||
|
return createProgressBar({
|
||||||
|
label,
|
||||||
|
value: stats[statName],
|
||||||
|
gradient,
|
||||||
|
editable: true,
|
||||||
|
field: statName
|
||||||
|
});
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Build classic stats HTML
|
||||||
|
const classicStatsHtml = finalConfig.showClassicStats ? `
|
||||||
|
<div class="rpg-stats-right">
|
||||||
|
<div class="rpg-classic-stats">
|
||||||
|
<div class="rpg-classic-stats-grid">
|
||||||
|
${['str', 'dex', 'con', 'int', 'wis', 'cha'].map(stat => `
|
||||||
|
<div class="rpg-classic-stat" data-stat="${stat}">
|
||||||
|
<span class="rpg-classic-stat-label">${stat.toUpperCase()}</span>
|
||||||
|
<div class="rpg-classic-stat-buttons">
|
||||||
|
<button class="rpg-classic-stat-btn rpg-stat-decrease" data-stat="${stat}">−</button>
|
||||||
|
<span class="rpg-classic-stat-value">${classicStats[stat]}</span>
|
||||||
|
<button class="rpg-classic-stat-btn rpg-stat-increase" data-stat="${stat}">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
// Build mood section HTML
|
||||||
|
const moodHtml = finalConfig.showMood ? `
|
||||||
|
<div class="rpg-mood">
|
||||||
|
<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>
|
||||||
|
<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${stats.conditions}</div>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
// Build portrait section HTML
|
||||||
|
const portraitHtml = finalConfig.showPortrait ? `
|
||||||
|
<div class="rpg-user-info-row">
|
||||||
|
<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />
|
||||||
|
<span class="rpg-user-name">${userName}</span>
|
||||||
|
<span style="opacity: 0.5;">|</span>
|
||||||
|
<span class="rpg-level-label">LVL</span>
|
||||||
|
<span class="rpg-level-value rpg-editable" contenteditable="true" data-field="level" title="Click to edit level">${settings.level}</span>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
// Render complete HTML
|
||||||
|
const html = `
|
||||||
|
<div class="rpg-stats-content">
|
||||||
|
<div class="rpg-stats-left">
|
||||||
|
${portraitHtml}
|
||||||
|
<div class="rpg-stats-grid">
|
||||||
|
${progressBarsHtml}
|
||||||
|
</div>
|
||||||
|
${moodHtml}
|
||||||
|
</div>
|
||||||
|
${classicStatsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Attach event handlers
|
||||||
|
attachEventHandlers(container, settings, onStatsChange);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get configuration options
|
||||||
|
* @returns {Object} Configuration schema
|
||||||
|
*/
|
||||||
|
getConfig() {
|
||||||
|
return {
|
||||||
|
showClassicStats: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Show Classic Stats (STR/DEX/etc)',
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showMood: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Show Mood & Conditions',
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
showPortrait: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Show User Portrait',
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
statBarGradient: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Use Gradient for Stat Bars',
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
visibleStats: {
|
||||||
|
type: 'multiselect',
|
||||||
|
label: 'Visible Stats',
|
||||||
|
default: ['health', 'satiety', 'energy', 'hygiene', 'arousal'],
|
||||||
|
options: [
|
||||||
|
{ value: 'health', label: 'Health' },
|
||||||
|
{ value: 'satiety', label: 'Satiety' },
|
||||||
|
{ value: 'energy', label: 'Energy' },
|
||||||
|
{ value: 'hygiene', label: 'Hygiene' },
|
||||||
|
{ value: 'arousal', label: 'Arousal' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle configuration changes
|
||||||
|
* @param {HTMLElement} container - Widget container
|
||||||
|
* @param {Object} newConfig - New configuration
|
||||||
|
*/
|
||||||
|
onConfigChange(container, newConfig) {
|
||||||
|
// Re-render with new config
|
||||||
|
this.render(container, newConfig);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle widget resize
|
||||||
|
* @param {HTMLElement} container - Widget container
|
||||||
|
* @param {number} newW - New width
|
||||||
|
* @param {number} newH - New height
|
||||||
|
*/
|
||||||
|
onResize(container, newW, newH) {
|
||||||
|
// Adjust layout based on size
|
||||||
|
const statsContent = container.querySelector('.rpg-stats-content');
|
||||||
|
if (!statsContent) return;
|
||||||
|
|
||||||
|
// Stack vertically on narrow widgets
|
||||||
|
if (newW < 5) {
|
||||||
|
statsContent.style.flexDirection = 'column';
|
||||||
|
} else {
|
||||||
|
statsContent.style.flexDirection = 'row';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach event handlers to widget
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
function attachEventHandlers(container, settings, onStatsChange) {
|
||||||
|
// Handle editable stat value changes (health, satiety, etc.)
|
||||||
|
const editableStats = container.querySelectorAll('.rpg-editable-stat');
|
||||||
|
editableStats.forEach(field => {
|
||||||
|
const fieldName = field.dataset.field;
|
||||||
|
let originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100);
|
||||||
|
|
||||||
|
field.addEventListener('focus', () => {
|
||||||
|
originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100);
|
||||||
|
// Select all text
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(field);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
field.addEventListener('blur', () => {
|
||||||
|
const textValue = field.textContent.replace('%', '').trim();
|
||||||
|
const value = parseNumber(textValue, originalValue, 0, 100);
|
||||||
|
|
||||||
|
// Update display
|
||||||
|
field.textContent = `${value}%`;
|
||||||
|
|
||||||
|
// Update settings if changed
|
||||||
|
if (value !== originalValue) {
|
||||||
|
settings.userStats[fieldName] = value;
|
||||||
|
|
||||||
|
// Update the bar fill
|
||||||
|
const bar = field.parentElement.querySelector('.rpg-stat-fill');
|
||||||
|
if (bar) {
|
||||||
|
bar.style.width = `${100 - value}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger change callback
|
||||||
|
if (onStatsChange) {
|
||||||
|
onStatsChange('userStats', fieldName, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
field.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
field.blur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
field.textContent = `${originalValue}%`;
|
||||||
|
field.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle mood emoji editing
|
||||||
|
const moodEmoji = container.querySelector('.rpg-mood-emoji.rpg-editable');
|
||||||
|
if (moodEmoji) {
|
||||||
|
let originalMood = moodEmoji.textContent.trim();
|
||||||
|
|
||||||
|
moodEmoji.addEventListener('focus', () => {
|
||||||
|
originalMood = moodEmoji.textContent.trim();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(moodEmoji);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
moodEmoji.addEventListener('blur', () => {
|
||||||
|
const value = moodEmoji.textContent.trim() || '😐';
|
||||||
|
moodEmoji.textContent = value;
|
||||||
|
|
||||||
|
if (value !== originalMood) {
|
||||||
|
settings.userStats.mood = value;
|
||||||
|
if (onStatsChange) {
|
||||||
|
onStatsChange('userStats', 'mood', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
moodEmoji.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
moodEmoji.blur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
moodEmoji.textContent = originalMood;
|
||||||
|
moodEmoji.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle conditions editing
|
||||||
|
const moodConditions = container.querySelector('.rpg-mood-conditions.rpg-editable');
|
||||||
|
if (moodConditions) {
|
||||||
|
let originalConditions = moodConditions.textContent.trim();
|
||||||
|
|
||||||
|
moodConditions.addEventListener('focus', () => {
|
||||||
|
originalConditions = moodConditions.textContent.trim();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(moodConditions);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
moodConditions.addEventListener('blur', () => {
|
||||||
|
const value = moodConditions.textContent.trim() || 'None';
|
||||||
|
moodConditions.textContent = value;
|
||||||
|
|
||||||
|
if (value !== originalConditions) {
|
||||||
|
settings.userStats.conditions = value;
|
||||||
|
if (onStatsChange) {
|
||||||
|
onStatsChange('userStats', 'conditions', value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
moodConditions.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
moodConditions.blur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
moodConditions.textContent = originalConditions;
|
||||||
|
moodConditions.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle level editing
|
||||||
|
const levelValue = container.querySelector('.rpg-level-value.rpg-editable');
|
||||||
|
if (levelValue) {
|
||||||
|
let originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
|
||||||
|
|
||||||
|
levelValue.addEventListener('focus', () => {
|
||||||
|
originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(levelValue);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
levelValue.addEventListener('blur', () => {
|
||||||
|
const value = parseNumber(levelValue.textContent.trim(), originalLevel, 1, 100);
|
||||||
|
levelValue.textContent = value;
|
||||||
|
|
||||||
|
if (value !== originalLevel) {
|
||||||
|
settings.level = value;
|
||||||
|
if (onStatsChange) {
|
||||||
|
onStatsChange('level', null, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
levelValue.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
levelValue.blur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
levelValue.textContent = originalLevel;
|
||||||
|
levelValue.blur();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle classic stat +/- buttons
|
||||||
|
const increaseButtons = container.querySelectorAll('.rpg-stat-increase');
|
||||||
|
const decreaseButtons = container.querySelectorAll('.rpg-stat-decrease');
|
||||||
|
|
||||||
|
increaseButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const statName = btn.dataset.stat;
|
||||||
|
const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
|
||||||
|
const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
|
||||||
|
const newValue = Math.min(20, currentValue + 1);
|
||||||
|
|
||||||
|
valueSpan.textContent = newValue;
|
||||||
|
settings.classicStats[statName] = newValue;
|
||||||
|
|
||||||
|
if (onStatsChange) {
|
||||||
|
onStatsChange('classicStats', statName, newValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
decreaseButtons.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const statName = btn.dataset.stat;
|
||||||
|
const valueSpan = btn.parentElement.querySelector('.rpg-classic-stat-value');
|
||||||
|
const currentValue = parseNumber(valueSpan.textContent, 10, 1, 20);
|
||||||
|
const newValue = Math.max(1, currentValue - 1);
|
||||||
|
|
||||||
|
valueSpan.textContent = newValue;
|
||||||
|
settings.classicStats[statName] = newValue;
|
||||||
|
|
||||||
|
if (onStatsChange) {
|
||||||
|
onStatsChange('classicStats', statName, newValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user