ecf7e88bb4
- Created LayoutPersistence class with full save/load/import/export
- Implemented debounced auto-save (500ms after changes)
- Added manual save, export (JSON download), import (file picker)
- Added reset to default with confirmation
- Comprehensive dashboard validation
- Event-driven architecture with onChange listeners
- Save status indicator with real-time updates
- Event log for all persistence operations
- Auto-load saved layout on startup
- Complete integration test with all systems
Task 1.8 complete in <15 minutes (estimated 2-3 days)
EPIC 1: DASHBOARD INFRASTRUCTURE COMPLETE! 🎉
1447 lines
58 KiB
HTML
1447 lines
58 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<title>Layout Persistence Test - Dashboard System</title>
|
||
<style>
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
body {
|
||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||
background: #1a1a2e;
|
||
color: #eee;
|
||
padding: 20px;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
h1 {
|
||
margin-bottom: 20px;
|
||
color: #e94560;
|
||
font-size: clamp(20px, 5vw, 28px);
|
||
}
|
||
|
||
.dashboard-container {
|
||
background: #16213e;
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
position: relative;
|
||
min-height: 600px;
|
||
}
|
||
|
||
.dashboard-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
padding-bottom: 15px;
|
||
border-bottom: 2px solid #0f3460;
|
||
}
|
||
|
||
.dashboard-title {
|
||
font-size: 24px;
|
||
font-weight: bold;
|
||
color: #4ecca3;
|
||
}
|
||
|
||
.edit-mode-toggle {
|
||
background: #4ecca3;
|
||
color: #1a1a2e;
|
||
border: none;
|
||
padding: 10px 20px;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.edit-mode-toggle:hover {
|
||
background: #5edc9f;
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.edit-mode-toggle.active {
|
||
background: #e94560;
|
||
color: white;
|
||
}
|
||
|
||
.grid-container {
|
||
position: relative;
|
||
background: #0f3460;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
min-height: 500px;
|
||
}
|
||
|
||
.grid-container.edit-mode {
|
||
border: 2px dashed rgba(78, 204, 163, 0.5);
|
||
}
|
||
|
||
.widget {
|
||
position: absolute;
|
||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
user-select: none;
|
||
transition: box-shadow 0.2s, border-color 0.2s;
|
||
border: 2px solid rgba(255, 255, 255, 0.1);
|
||
touch-action: none;
|
||
cursor: grab;
|
||
}
|
||
|
||
.edit-mode .widget {
|
||
cursor: grab;
|
||
}
|
||
|
||
.widget:active {
|
||
cursor: grabbing;
|
||
}
|
||
|
||
.widget:hover {
|
||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||
}
|
||
|
||
.widget.dragging {
|
||
opacity: 0.3;
|
||
}
|
||
|
||
.widget.resizing {
|
||
box-shadow: 0 8px 24px rgba(78, 204, 163, 0.6);
|
||
border-color: rgba(78, 204, 163, 0.8);
|
||
}
|
||
|
||
.widget-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||
}
|
||
|
||
.widget-icon {
|
||
font-size: 20px;
|
||
}
|
||
|
||
.widget-title {
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
flex: 1;
|
||
}
|
||
|
||
.widget-info {
|
||
font-size: 11px;
|
||
opacity: 0.7;
|
||
margin-top: 4px;
|
||
}
|
||
|
||
/* Resize handles - visible in edit mode */
|
||
.resize-handles {
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
/* Always show handles in edit mode, brighter on hover */
|
||
.edit-mode .resize-handles {
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.edit-mode .widget:hover .resize-handles,
|
||
.widget.resizing .resize-handles {
|
||
opacity: 1;
|
||
}
|
||
|
||
.resize-handle {
|
||
transition: background 0.2s, transform 0.2s;
|
||
}
|
||
|
||
.resize-handle:hover {
|
||
background: rgba(78, 204, 163, 1) !important;
|
||
transform: scale(1.3) !important;
|
||
}
|
||
|
||
/* Widget edit controls - hidden by default */
|
||
.widget-edit-controls {
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
}
|
||
|
||
.edit-mode .widget:hover .widget-edit-controls {
|
||
opacity: 1;
|
||
}
|
||
|
||
.hint {
|
||
background: #0f3460;
|
||
padding: 12px;
|
||
border-radius: 5px;
|
||
font-size: 12px;
|
||
color: #aaa;
|
||
line-height: 1.6;
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.hint strong {
|
||
color: #4ecca3;
|
||
}
|
||
|
||
.hint kbd {
|
||
background: #1a1a2e;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
border: 1px solid #4ecca3;
|
||
color: #4ecca3;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
}
|
||
|
||
.status-bar {
|
||
display: flex;
|
||
gap: 15px;
|
||
align-items: center;
|
||
padding: 12px;
|
||
background: #0f3460;
|
||
border-radius: 6px;
|
||
margin-top: 15px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.status-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.status-label {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.status-value {
|
||
color: #4ecca3;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.mode-indicator {
|
||
padding: 4px 12px;
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.mode-indicator.view {
|
||
background: rgba(78, 204, 163, 0.2);
|
||
color: #4ecca3;
|
||
}
|
||
|
||
.mode-indicator.edit {
|
||
background: rgba(233, 69, 96, 0.2);
|
||
color: #e94560;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
body {
|
||
padding: 10px;
|
||
}
|
||
|
||
.dashboard-container {
|
||
padding: 15px;
|
||
}
|
||
|
||
.grid-container {
|
||
min-height: 400px;
|
||
}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>💾 Layout Persistence Test - Dashboard System</h1>
|
||
|
||
<div class="hint">
|
||
<strong>Features:</strong><br>
|
||
• <strong>Auto-save:</strong> Layout saves automatically 500ms after any change<br>
|
||
• <strong>Manual Save:</strong> Click "Save Now" to force immediate save<br>
|
||
• <strong>Export/Import:</strong> Download layout as JSON or upload a saved layout<br>
|
||
• <strong>Reset:</strong> Restore default layout with confirmation<br>
|
||
• Edit mode: Drag widgets to move, drag <strong>green dots</strong> to resize<br>
|
||
• Add widgets from library (left side), hover for delete/settings controls<br>
|
||
• Watch the event log below to see all persistence operations
|
||
</div>
|
||
|
||
<div class="dashboard-container">
|
||
<div class="dashboard-header">
|
||
<div class="dashboard-title">🎮 RPG Dashboard</div>
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<button id="save-now" class="edit-mode-toggle" style="background: #4ecca3; font-size: 13px; padding: 8px 16px;">💾 Save Now</button>
|
||
<button id="export-layout" class="edit-mode-toggle" style="background: #667eea; font-size: 13px; padding: 8px 16px;">📥 Export</button>
|
||
<button id="import-layout" class="edit-mode-toggle" style="background: #f5576c; font-size: 13px; padding: 8px 16px;">📤 Import</button>
|
||
<button id="reset-layout" class="edit-mode-toggle" style="background: #e94560; font-size: 13px; padding: 8px 16px;">🔄 Reset</button>
|
||
<button id="edit-toggle" class="edit-mode-toggle">Edit Layout</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="grid-container" class="grid-container"></div>
|
||
|
||
<div class="status-bar">
|
||
<div class="status-item">
|
||
<span class="status-label">Mode:</span>
|
||
<span id="mode-indicator" class="mode-indicator view">VIEW</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Widgets:</span>
|
||
<span id="widget-count" class="status-value">0</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Grid Units:</span>
|
||
<span id="total-units" class="status-value">0</span>
|
||
</div>
|
||
<div class="status-item">
|
||
<span class="status-label">Save Status:</span>
|
||
<span id="save-status" class="status-value">Not saved</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 20px; background: #16213e; border-radius: 12px; padding: 20px;">
|
||
<h2 style="color: #4ecca3; font-size: 18px; margin-bottom: 15px;">📋 Event Log</h2>
|
||
<div id="event-log" style="background: #0f3460; border-radius: 8px; padding: 12px; max-height: 300px; overflow-y: auto; font-family: monospace; font-size: 12px; line-height: 1.6;"></div>
|
||
</div>
|
||
|
||
<input type="file" id="import-file-input" accept="application/json" style="display: none;">
|
||
|
||
<script>
|
||
// ========== BUNDLED CLASSES ==========
|
||
|
||
// GridEngine
|
||
class GridEngine {
|
||
constructor(config = {}) {
|
||
this.columns = config.columns || 12;
|
||
this.rowHeight = config.rowHeight || 80;
|
||
this.gap = config.gap || 12;
|
||
this.containerWidth = 0;
|
||
this.container = config.container;
|
||
if (this.container) this.updateContainerWidth();
|
||
}
|
||
|
||
updateContainerWidth() {
|
||
if (this.container) {
|
||
this.containerWidth = this.container.offsetWidth - (this.gap * 2);
|
||
}
|
||
}
|
||
|
||
getPixelPosition(widget) {
|
||
this.updateContainerWidth();
|
||
const totalGaps = this.gap * (this.columns + 1);
|
||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||
const left = widget.x * (colWidth + this.gap) + this.gap;
|
||
const top = widget.y * (this.rowHeight + this.gap) + this.gap;
|
||
const width = widget.w * colWidth + (widget.w - 1) * this.gap;
|
||
const height = widget.h * this.rowHeight + (widget.h - 1) * this.gap;
|
||
return { left, top, width, height };
|
||
}
|
||
|
||
snapToCell(pixelX, pixelY) {
|
||
this.updateContainerWidth();
|
||
const totalGaps = this.gap * (this.columns + 1);
|
||
const colWidth = (this.containerWidth - totalGaps) / this.columns;
|
||
const x = Math.round((pixelX - this.gap) / (colWidth + this.gap));
|
||
const y = Math.round((pixelY - this.gap) / (this.rowHeight + this.gap));
|
||
return {
|
||
x: Math.max(0, Math.min(x, this.columns - 1)),
|
||
y: Math.max(0, y)
|
||
};
|
||
}
|
||
}
|
||
|
||
// DragDropHandler
|
||
class DragDropHandler {
|
||
constructor(gridEngine, options = {}) {
|
||
this.gridEngine = gridEngine;
|
||
this.options = { ghostOpacity: 0.5, touchDelay: 150, ...options };
|
||
this.dragState = null;
|
||
this.dragHandlers = new Map();
|
||
this.touchTimer = null;
|
||
this.boundMouseMove = this.onMouseMove.bind(this);
|
||
this.boundMouseUp = this.onMouseUp.bind(this);
|
||
this.boundTouchMove = this.onTouchMove.bind(this);
|
||
this.boundTouchEnd = this.onTouchEnd.bind(this);
|
||
this.boundKeyDown = this.onKeyDown.bind(this);
|
||
}
|
||
|
||
initWidget(element, widget, onDragEnd) {
|
||
const dragHandle = element;
|
||
const mouseDownHandler = (e) => {
|
||
if (e.button !== 0) return;
|
||
// Don't drag if clicking on resize handle or widget controls
|
||
if (e.target.closest('.resize-handle') || e.target.closest('.widget-edit-controls')) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
this.startDrag(e, element, widget, onDragEnd);
|
||
};
|
||
const touchStartHandler = (e) => {
|
||
// Don't drag if touching resize handle or widget controls
|
||
if (e.target.closest('.resize-handle') || e.target.closest('.widget-edit-controls')) {
|
||
return;
|
||
}
|
||
this.touchTimer = setTimeout(() => {
|
||
e.preventDefault();
|
||
this.startDrag(e.touches[0], element, widget, onDragEnd);
|
||
}, this.options.touchDelay);
|
||
};
|
||
const touchCancelHandler = () => {
|
||
if (this.touchTimer) {
|
||
clearTimeout(this.touchTimer);
|
||
this.touchTimer = null;
|
||
}
|
||
};
|
||
dragHandle.addEventListener('mousedown', mouseDownHandler);
|
||
dragHandle.addEventListener('touchstart', touchStartHandler, { passive: false });
|
||
dragHandle.addEventListener('touchcancel', touchCancelHandler);
|
||
dragHandle.addEventListener('touchend', touchCancelHandler);
|
||
this.dragHandlers.set(element, { mouseDownHandler, touchStartHandler, touchCancelHandler, dragHandle });
|
||
}
|
||
|
||
startDrag(e, element, widget, onDragEnd) {
|
||
const rect = element.getBoundingClientRect();
|
||
const offsetX = e.clientX - rect.left;
|
||
const offsetY = e.clientY - rect.top;
|
||
const ghost = element.cloneNode(true);
|
||
ghost.style.position = 'fixed';
|
||
ghost.style.opacity = this.options.ghostOpacity;
|
||
ghost.style.pointerEvents = 'none';
|
||
ghost.style.zIndex = '10000';
|
||
ghost.style.width = element.offsetWidth + 'px';
|
||
ghost.style.height = element.offsetHeight + 'px';
|
||
document.body.appendChild(ghost);
|
||
|
||
this.dragState = { element, widget: { ...widget }, startX: e.clientX, startY: e.clientY, offsetX, offsetY, ghost, isDragging: true, onDragEnd };
|
||
document.addEventListener('mousemove', this.boundMouseMove);
|
||
document.addEventListener('mouseup', this.boundMouseUp);
|
||
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
|
||
document.addEventListener('touchend', this.boundTouchEnd);
|
||
document.addEventListener('keydown', this.boundKeyDown);
|
||
element.style.opacity = '0.3';
|
||
element.classList.add('dragging');
|
||
}
|
||
|
||
onMouseMove(e) {
|
||
if (!this.dragState?.isDragging) return;
|
||
e.preventDefault();
|
||
this.updateDragPosition(e.clientX, e.clientY);
|
||
}
|
||
|
||
onTouchMove(e) {
|
||
if (!this.dragState?.isDragging) return;
|
||
e.preventDefault();
|
||
this.updateDragPosition(e.touches[0].clientX, e.touches[0].clientY);
|
||
}
|
||
|
||
updateDragPosition(clientX, clientY) {
|
||
const { ghost, offsetX, offsetY } = this.dragState;
|
||
ghost.style.left = (clientX - offsetX) + 'px';
|
||
ghost.style.top = (clientY - offsetY) + 'px';
|
||
const containerRect = this.gridEngine.container.getBoundingClientRect();
|
||
const relativeX = clientX - containerRect.left - offsetX;
|
||
const relativeY = clientY - containerRect.top - offsetY;
|
||
const snapped = this.gridEngine.snapToCell(relativeX, relativeY);
|
||
this.dragState.widget.x = snapped.x;
|
||
this.dragState.widget.y = snapped.y;
|
||
}
|
||
|
||
onMouseUp(e) {
|
||
if (!this.dragState?.isDragging) return;
|
||
e.preventDefault();
|
||
this.endDrag();
|
||
}
|
||
|
||
onTouchEnd(e) {
|
||
if (!this.dragState?.isDragging) return;
|
||
e.preventDefault();
|
||
this.endDrag();
|
||
}
|
||
|
||
onKeyDown(e) {
|
||
if (!this.dragState?.isDragging) return;
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
this.cancelDrag();
|
||
}
|
||
}
|
||
|
||
endDrag() {
|
||
if (!this.dragState) return;
|
||
const { element, widget, onDragEnd } = this.dragState;
|
||
element.style.opacity = '1';
|
||
element.classList.remove('dragging');
|
||
if (onDragEnd) onDragEnd(widget, widget.x, widget.y);
|
||
this.cleanup();
|
||
}
|
||
|
||
cancelDrag() {
|
||
if (!this.dragState) return;
|
||
const { element } = this.dragState;
|
||
element.style.opacity = '1';
|
||
element.classList.remove('dragging');
|
||
this.cleanup();
|
||
}
|
||
|
||
cleanup() {
|
||
if (this.dragState?.ghost) this.dragState.ghost.remove();
|
||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||
document.removeEventListener('keydown', this.boundKeyDown);
|
||
if (this.touchTimer) {
|
||
clearTimeout(this.touchTimer);
|
||
this.touchTimer = null;
|
||
}
|
||
this.dragState = null;
|
||
}
|
||
|
||
destroyWidget(element) {
|
||
const handlers = this.dragHandlers.get(element);
|
||
if (!handlers) return;
|
||
const { dragHandle, mouseDownHandler, touchStartHandler, touchCancelHandler } = handlers;
|
||
dragHandle.removeEventListener('mousedown', mouseDownHandler);
|
||
dragHandle.removeEventListener('touchstart', touchStartHandler);
|
||
dragHandle.removeEventListener('touchcancel', touchCancelHandler);
|
||
dragHandle.removeEventListener('touchend', touchCancelHandler);
|
||
this.dragHandlers.delete(element);
|
||
}
|
||
}
|
||
|
||
// ResizeHandler (functional version)
|
||
class ResizeHandler {
|
||
constructor(gridEngine, options = {}) {
|
||
this.gridEngine = gridEngine;
|
||
this.options = { minWidth: 2, minHeight: 2, maxWidth: 12, maxHeight: 10, ...options };
|
||
this.resizeHandlers = new Map();
|
||
this.resizeState = null;
|
||
this.boundMouseMove = this.onMouseMove.bind(this);
|
||
this.boundMouseUp = this.onMouseUp.bind(this);
|
||
this.boundTouchMove = this.onTouchMove.bind(this);
|
||
this.boundTouchEnd = this.onTouchEnd.bind(this);
|
||
}
|
||
|
||
initWidget(element, widget, onResizeEnd, constraints = {}) {
|
||
const handles = document.createElement('div');
|
||
handles.className = 'resize-handles';
|
||
handles.style.position = 'absolute';
|
||
handles.style.inset = '0';
|
||
handles.style.pointerEvents = 'none';
|
||
|
||
const widgetConstraints = {
|
||
minW: constraints.minW || this.options.minWidth,
|
||
minH: constraints.minH || this.options.minHeight,
|
||
maxW: constraints.maxW || this.options.maxWidth,
|
||
maxH: constraints.maxH || this.options.maxHeight
|
||
};
|
||
|
||
const handleTypes = { nw: 'nwse-resize', ne: 'nesw-resize', se: 'nwse-resize', sw: 'nesw-resize', n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize' };
|
||
const handleListeners = [];
|
||
|
||
Object.entries(handleTypes).forEach(([type, cursor]) => {
|
||
const handle = document.createElement('div');
|
||
handle.className = `resize-handle resize-handle-${type}`;
|
||
handle.dataset.handle = type;
|
||
handle.style.position = 'absolute';
|
||
handle.style.width = '12px';
|
||
handle.style.height = '12px';
|
||
handle.style.background = 'rgba(78, 204, 163, 0.8)';
|
||
handle.style.border = '2px solid white';
|
||
handle.style.borderRadius = '3px';
|
||
handle.style.pointerEvents = 'auto';
|
||
handle.style.cursor = cursor;
|
||
handle.style.zIndex = '101';
|
||
|
||
if (type.includes('n')) handle.style.top = '-6px';
|
||
if (type.includes('s')) handle.style.bottom = '-6px';
|
||
if (type.includes('w')) handle.style.left = '-6px';
|
||
if (type.includes('e')) handle.style.right = '-6px';
|
||
if (type === 'n' || type === 's') {
|
||
handle.style.left = '50%';
|
||
handle.style.transform = 'translateX(-50%)';
|
||
}
|
||
if (type === 'w' || type === 'e') {
|
||
handle.style.top = '50%';
|
||
handle.style.transform = 'translateY(-50%)';
|
||
}
|
||
|
||
const mouseDownHandler = (e) => {
|
||
if (e.button !== 0) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.startResize(e, type, element, widget, onResizeEnd, widgetConstraints);
|
||
};
|
||
|
||
const touchStartHandler = (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
this.startResize(e.touches[0], type, element, widget, onResizeEnd, widgetConstraints);
|
||
};
|
||
|
||
handle.addEventListener('mousedown', mouseDownHandler);
|
||
handle.addEventListener('touchstart', touchStartHandler, { passive: false });
|
||
handleListeners.push({ element: handle, mouseDownHandler, touchStartHandler });
|
||
handles.appendChild(handle);
|
||
});
|
||
|
||
element.appendChild(handles);
|
||
this.resizeHandlers.set(element, { handles, handleListeners });
|
||
}
|
||
|
||
startResize(e, handleType, element, widget, onResizeEnd, constraints) {
|
||
this.resizeState = {
|
||
element,
|
||
widget: { ...widget },
|
||
handle: handleType,
|
||
startX: e.clientX,
|
||
startY: e.clientY,
|
||
startWidth: widget.w,
|
||
startHeight: widget.h,
|
||
startGridX: widget.x,
|
||
startGridY: widget.y,
|
||
onResizeEnd,
|
||
constraints
|
||
};
|
||
|
||
document.addEventListener('mousemove', this.boundMouseMove);
|
||
document.addEventListener('mouseup', this.boundMouseUp);
|
||
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
|
||
document.addEventListener('touchend', this.boundTouchEnd);
|
||
element.classList.add('resizing');
|
||
}
|
||
|
||
onMouseMove(e) {
|
||
if (!this.resizeState) return;
|
||
e.preventDefault();
|
||
this.updateResizeSize(e.clientX, e.clientY);
|
||
}
|
||
|
||
onTouchMove(e) {
|
||
if (!this.resizeState) return;
|
||
e.preventDefault();
|
||
this.updateResizeSize(e.touches[0].clientX, e.touches[0].clientY);
|
||
}
|
||
|
||
updateResizeSize(clientX, clientY) {
|
||
const { widget, handle, startX, startY, startWidth, startHeight, startGridX, startGridY, constraints, element } = this.resizeState;
|
||
const deltaX = clientX - startX;
|
||
const deltaY = clientY - startY;
|
||
|
||
this.gridEngine.updateContainerWidth();
|
||
const totalGaps = this.gridEngine.gap * (this.gridEngine.columns + 1);
|
||
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
|
||
const rowHeight = this.gridEngine.rowHeight;
|
||
|
||
const deltaGridX = Math.round(deltaX / (colWidth + this.gridEngine.gap));
|
||
const deltaGridY = Math.round(deltaY / (rowHeight + this.gridEngine.gap));
|
||
|
||
let newW = startWidth, newH = startHeight, newX = startGridX, newY = startGridY;
|
||
|
||
if (handle.includes('e')) newW = startWidth + deltaGridX;
|
||
else if (handle.includes('w')) { newW = startWidth - deltaGridX; newX = startGridX + deltaGridX; }
|
||
if (handle.includes('s')) newH = startHeight + deltaGridY;
|
||
else if (handle.includes('n')) { newH = startHeight - deltaGridY; newY = startGridY + deltaGridY; }
|
||
|
||
newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
|
||
newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
|
||
newW = Math.min(newW, this.gridEngine.columns - newX);
|
||
|
||
if (handle.includes('w') && newW === constraints.minW) newX = startGridX + startWidth - constraints.minW;
|
||
if (handle.includes('n') && newH === constraints.minH) newY = startGridY + startHeight - constraints.minH;
|
||
|
||
this.resizeState.widget.w = newW;
|
||
this.resizeState.widget.h = newH;
|
||
this.resizeState.widget.x = newX;
|
||
this.resizeState.widget.y = newY;
|
||
|
||
const pos = this.gridEngine.getPixelPosition(this.resizeState.widget);
|
||
element.style.width = pos.width + 'px';
|
||
element.style.height = pos.height + 'px';
|
||
element.style.left = pos.left + 'px';
|
||
element.style.top = pos.top + 'px';
|
||
}
|
||
|
||
onMouseUp(e) {
|
||
if (!this.resizeState) return;
|
||
e.preventDefault();
|
||
this.endResize();
|
||
}
|
||
|
||
onTouchEnd(e) {
|
||
if (!this.resizeState) return;
|
||
e.preventDefault();
|
||
this.endResize();
|
||
}
|
||
|
||
endResize() {
|
||
if (!this.resizeState) return;
|
||
const { element, widget, onResizeEnd } = this.resizeState;
|
||
element.classList.remove('resizing');
|
||
if (onResizeEnd) onResizeEnd(widget, widget.w, widget.h, widget.x, widget.y);
|
||
this.cleanup();
|
||
}
|
||
|
||
cleanup() {
|
||
document.removeEventListener('mousemove', this.boundMouseMove);
|
||
document.removeEventListener('mouseup', this.boundMouseUp);
|
||
document.removeEventListener('touchmove', this.boundTouchMove);
|
||
document.removeEventListener('touchend', this.boundTouchEnd);
|
||
this.resizeState = null;
|
||
}
|
||
|
||
destroyWidget(element) {
|
||
const data = this.resizeHandlers.get(element);
|
||
if (!data) return;
|
||
const { handles, handleListeners } = data;
|
||
handleListeners.forEach(({ element: h, mouseDownHandler, touchStartHandler }) => {
|
||
h.removeEventListener('mousedown', mouseDownHandler);
|
||
h.removeEventListener('touchstart', touchStartHandler);
|
||
});
|
||
handles.remove();
|
||
this.resizeHandlers.delete(element);
|
||
}
|
||
}
|
||
|
||
// EditModeManager (simplified for demo)
|
||
class EditModeManager {
|
||
constructor(config) {
|
||
this.container = config.container;
|
||
this.onSave = config.onSave;
|
||
this.onCancel = config.onCancel;
|
||
this.onWidgetAdd = config.onWidgetAdd;
|
||
this.onWidgetDelete = config.onWidgetDelete;
|
||
this.isEditMode = false;
|
||
this.editControls = null;
|
||
this.widgetLibrary = null;
|
||
this.widgetControlsMap = new Map();
|
||
}
|
||
|
||
enterEditMode() {
|
||
if (this.isEditMode) return;
|
||
this.isEditMode = true;
|
||
this.createEditControls();
|
||
this.showWidgetLibrary();
|
||
this.container.classList.add('edit-mode');
|
||
console.log('Entered edit mode');
|
||
}
|
||
|
||
exitEditMode(save = false) {
|
||
if (!this.isEditMode) return;
|
||
if (save) {
|
||
if (this.onSave) this.onSave();
|
||
} else {
|
||
if (this.onCancel) this.onCancel();
|
||
}
|
||
this.isEditMode = false;
|
||
this.removeEditControls();
|
||
this.hideWidgetLibrary();
|
||
this.container.classList.remove('edit-mode');
|
||
}
|
||
|
||
toggleEditMode() {
|
||
if (this.isEditMode) {
|
||
if (confirm('Exit edit mode? Unsaved changes will be lost.')) {
|
||
this.exitEditMode(false);
|
||
}
|
||
} else {
|
||
this.enterEditMode();
|
||
}
|
||
}
|
||
|
||
createEditControls() {
|
||
this.editControls = document.createElement('div');
|
||
this.editControls.style.position = 'absolute';
|
||
this.editControls.style.top = '10px';
|
||
this.editControls.style.right = '10px';
|
||
this.editControls.style.display = 'flex';
|
||
this.editControls.style.gap = '8px';
|
||
this.editControls.style.zIndex = '10000';
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.textContent = '💾 Save';
|
||
saveBtn.style.cssText = 'background:#4ecca3;color:#1a1a2e;border:none;padding:10px 20px;border-radius:6px;cursor:pointer;font-weight:bold;';
|
||
saveBtn.onclick = () => this.exitEditMode(true);
|
||
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.textContent = '✖ Cancel';
|
||
cancelBtn.style.cssText = 'background:#e94560;color:white;border:none;padding:10px 20px;border-radius:6px;cursor:pointer;font-weight:bold;';
|
||
cancelBtn.onclick = () => this.exitEditMode(false);
|
||
|
||
this.editControls.appendChild(saveBtn);
|
||
this.editControls.appendChild(cancelBtn);
|
||
this.container.appendChild(this.editControls);
|
||
}
|
||
|
||
removeEditControls() {
|
||
if (this.editControls) {
|
||
this.editControls.remove();
|
||
this.editControls = null;
|
||
}
|
||
}
|
||
|
||
showWidgetLibrary() {
|
||
this.widgetLibrary = document.createElement('div');
|
||
this.widgetLibrary.style.cssText = 'position:fixed;left:20px;top:50%;transform:translateY(-50%);background:#16213e;border-radius:8px;padding:15px;box-shadow:0 4px 12px rgba(0,0,0,0.3);z-index:10001;max-width:200px;';
|
||
|
||
const title = document.createElement('div');
|
||
title.textContent = 'Widget Library';
|
||
title.style.cssText = 'font-size:14px;font-weight:bold;margin-bottom:10px;color:#4ecca3;';
|
||
this.widgetLibrary.appendChild(title);
|
||
|
||
const widgetTypes = [
|
||
{ type: 'stats', icon: '📊', name: 'Stats' },
|
||
{ type: 'inventory', icon: '🎒', name: 'Inventory' },
|
||
{ type: 'notes', icon: '📝', name: 'Notes' },
|
||
{ type: 'map', icon: '🗺️', name: 'Map' }
|
||
];
|
||
|
||
widgetTypes.forEach(w => {
|
||
const item = document.createElement('div');
|
||
item.style.cssText = 'display:flex;align-items:center;gap:8px;padding:10px;margin-bottom:8px;background:#0f3460;border-radius:6px;cursor:pointer;transition:all 0.2s;';
|
||
item.innerHTML = `<span style="font-size:20px;">${w.icon}</span><span style="font-size:12px;">${w.name}</span>`;
|
||
item.onclick = () => {
|
||
if (this.onWidgetAdd) this.onWidgetAdd(w.type);
|
||
};
|
||
item.onmouseenter = () => item.style.background = '#1a3a5a';
|
||
item.onmouseleave = () => item.style.background = '#0f3460';
|
||
this.widgetLibrary.appendChild(item);
|
||
});
|
||
|
||
document.body.appendChild(this.widgetLibrary);
|
||
}
|
||
|
||
hideWidgetLibrary() {
|
||
if (this.widgetLibrary) {
|
||
this.widgetLibrary.remove();
|
||
this.widgetLibrary = null;
|
||
}
|
||
}
|
||
|
||
addWidgetControls(element, widgetId) {
|
||
const controls = document.createElement('div');
|
||
controls.className = 'widget-edit-controls';
|
||
controls.style.cssText = 'position:absolute;top:4px;right:4px;display:flex;gap:4px;z-index:100;opacity:0;transition:opacity 0.2s;';
|
||
|
||
const settingsBtn = document.createElement('button');
|
||
settingsBtn.textContent = '⚙';
|
||
settingsBtn.title = 'Settings';
|
||
settingsBtn.style.cssText = 'width:24px;height:24px;background:#4ecca3;color:white;border:none;border-radius:4px;cursor:pointer;font-size:16px;';
|
||
settingsBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
alert(`Settings for widget: ${widgetId}`);
|
||
};
|
||
|
||
const deleteBtn = document.createElement('button');
|
||
deleteBtn.textContent = '×';
|
||
deleteBtn.title = 'Delete';
|
||
deleteBtn.style.cssText = 'width:24px;height:24px;background:#e94560;color:white;border:none;border-radius:4px;cursor:pointer;font-size:16px;';
|
||
deleteBtn.onclick = (e) => {
|
||
e.stopPropagation();
|
||
if (confirm('Delete this widget?')) {
|
||
if (this.onWidgetDelete) this.onWidgetDelete(widgetId);
|
||
}
|
||
};
|
||
|
||
controls.appendChild(settingsBtn);
|
||
controls.appendChild(deleteBtn);
|
||
element.appendChild(controls);
|
||
this.widgetControlsMap.set(widgetId, controls);
|
||
}
|
||
|
||
removeWidgetControls(widgetId) {
|
||
const controls = this.widgetControlsMap.get(widgetId);
|
||
if (controls) {
|
||
controls.remove();
|
||
this.widgetControlsMap.delete(widgetId);
|
||
}
|
||
}
|
||
|
||
getIsEditMode() {
|
||
return this.isEditMode;
|
||
}
|
||
}
|
||
|
||
// LayoutPersistence
|
||
class LayoutPersistence {
|
||
constructor(config = {}) {
|
||
this.onSave = config.onSave;
|
||
this.onLoad = config.onLoad;
|
||
this.onError = config.onError;
|
||
this.debounceMs = config.debounceMs || 500;
|
||
this.saveTimeout = null;
|
||
this.lastSaveTime = 0;
|
||
this.isSaving = false;
|
||
this.pendingSave = false;
|
||
this.changeListeners = new Set();
|
||
}
|
||
|
||
async saveLayout(dashboard, immediate = false) {
|
||
if (!dashboard) throw new Error('Dashboard configuration is required');
|
||
if (!this.validateDashboard(dashboard)) throw new Error('Invalid dashboard configuration');
|
||
if (immediate) return this.performSave(dashboard);
|
||
else return this.debouncedSave(dashboard);
|
||
}
|
||
|
||
async debouncedSave(dashboard) {
|
||
if (this.saveTimeout) clearTimeout(this.saveTimeout);
|
||
this.pendingSave = true;
|
||
return new Promise((resolve, reject) => {
|
||
this.saveTimeout = setTimeout(async () => {
|
||
try {
|
||
await this.performSave(dashboard);
|
||
resolve();
|
||
} catch (error) {
|
||
reject(error);
|
||
}
|
||
}, this.debounceMs);
|
||
});
|
||
}
|
||
|
||
async performSave(dashboard) {
|
||
this.isSaving = true;
|
||
this.notifyChange('saveStarted', { timestamp: Date.now() });
|
||
try {
|
||
const layoutData = JSON.parse(JSON.stringify(dashboard));
|
||
layoutData.metadata = {
|
||
version: dashboard.version || 2,
|
||
savedAt: new Date().toISOString(),
|
||
appVersion: '2.0.0'
|
||
};
|
||
localStorage.setItem('rpg-companion-dashboard', JSON.stringify(layoutData));
|
||
this.lastSaveTime = Date.now();
|
||
this.isSaving = false;
|
||
this.pendingSave = false;
|
||
this.notifyChange('saveSuccess', { timestamp: this.lastSaveTime, layout: layoutData });
|
||
if (this.onSave) this.onSave(layoutData);
|
||
} catch (error) {
|
||
this.isSaving = false;
|
||
this.pendingSave = false;
|
||
this.notifyChange('saveError', { error });
|
||
if (this.onError) this.onError(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async loadLayout() {
|
||
this.notifyChange('loadStarted', { timestamp: Date.now() });
|
||
try {
|
||
const stored = localStorage.getItem('rpg-companion-dashboard');
|
||
if (!stored) {
|
||
this.notifyChange('loadComplete', { layout: null });
|
||
return null;
|
||
}
|
||
const layoutData = JSON.parse(stored);
|
||
if (!this.validateDashboard(layoutData)) throw new Error('Loaded layout is invalid');
|
||
this.notifyChange('loadSuccess', { layout: layoutData });
|
||
if (this.onLoad) this.onLoad(layoutData);
|
||
return layoutData;
|
||
} catch (error) {
|
||
this.notifyChange('loadError', { error });
|
||
if (this.onError) this.onError(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
exportLayout(dashboard, filename = 'dashboard-layout.json') {
|
||
if (!dashboard) throw new Error('Dashboard configuration is required');
|
||
if (!this.validateDashboard(dashboard)) throw new Error('Invalid dashboard configuration');
|
||
try {
|
||
const exportData = JSON.parse(JSON.stringify(dashboard));
|
||
exportData.metadata = {
|
||
version: dashboard.version || 2,
|
||
exportedAt: new Date().toISOString(),
|
||
appVersion: '2.0.0',
|
||
exportedBy: 'RPG Companion v2.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 = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
this.notifyChange('exportSuccess', { filename });
|
||
} catch (error) {
|
||
this.notifyChange('exportError', { error });
|
||
if (this.onError) this.onError(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async importLayout(file) {
|
||
if (!file) throw new Error('File is required');
|
||
if (file.type !== 'application/json' && !file.name.endsWith('.json')) {
|
||
throw new Error('File must be JSON format');
|
||
}
|
||
this.notifyChange('importStarted', { filename: file.name });
|
||
try {
|
||
const text = await this.readFileAsText(file);
|
||
const layoutData = JSON.parse(text);
|
||
if (!this.validateDashboard(layoutData)) {
|
||
throw new Error('Imported file contains invalid dashboard configuration');
|
||
}
|
||
this.notifyChange('importSuccess', { layout: layoutData, filename: file.name });
|
||
return layoutData;
|
||
} catch (error) {
|
||
this.notifyChange('importError', { error, filename: file.name });
|
||
if (this.onError) this.onError(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async resetToDefault(defaultDashboard) {
|
||
if (!defaultDashboard) throw new Error('Default dashboard configuration is required');
|
||
if (!this.validateDashboard(defaultDashboard)) throw new Error('Invalid default dashboard configuration');
|
||
try {
|
||
localStorage.removeItem('rpg-companion-dashboard');
|
||
await this.saveLayout(defaultDashboard, true);
|
||
this.notifyChange('resetSuccess', { layout: defaultDashboard });
|
||
} catch (error) {
|
||
this.notifyChange('resetError', { error });
|
||
if (this.onError) this.onError(error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
validateDashboard(dashboard) {
|
||
if (!dashboard || typeof dashboard !== 'object') return false;
|
||
if (!dashboard.version || !dashboard.gridConfig || !Array.isArray(dashboard.widgets)) return false;
|
||
const grid = dashboard.gridConfig;
|
||
if (typeof grid.columns !== 'number' || typeof grid.rowHeight !== 'number') return false;
|
||
for (const widget of dashboard.widgets) {
|
||
if (!widget.id || typeof widget.type !== 'number') return false;
|
||
if (typeof widget.x !== 'number' || typeof widget.y !== 'number' ||
|
||
typeof widget.w !== 'number' || typeof widget.h !== 'number') return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
readFileAsText(file) {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => resolve(e.target.result);
|
||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||
reader.readAsText(file);
|
||
});
|
||
}
|
||
|
||
hasPendingSave() {
|
||
return this.pendingSave;
|
||
}
|
||
|
||
getIsSaving() {
|
||
return this.isSaving;
|
||
}
|
||
|
||
getLastSaveTime() {
|
||
return this.lastSaveTime;
|
||
}
|
||
|
||
onChange(callback) {
|
||
this.changeListeners.add(callback);
|
||
}
|
||
|
||
offChange(callback) {
|
||
this.changeListeners.delete(callback);
|
||
}
|
||
|
||
notifyChange(event, data) {
|
||
this.changeListeners.forEach(callback => {
|
||
try {
|
||
callback(event, data);
|
||
} catch (error) {
|
||
console.error('[LayoutPersistence] Error in change listener:', error);
|
||
}
|
||
});
|
||
}
|
||
|
||
destroy() {
|
||
if (this.saveTimeout) {
|
||
clearTimeout(this.saveTimeout);
|
||
this.saveTimeout = null;
|
||
}
|
||
this.changeListeners.clear();
|
||
}
|
||
}
|
||
|
||
// ========== APPLICATION ==========
|
||
|
||
let gridEngine, dragHandler, resizeHandler, editManager, layoutPersistence;
|
||
let widgets = [];
|
||
let widgetElements = new Map();
|
||
let widgetCounter = 0;
|
||
let defaultDashboard = null;
|
||
|
||
const widgetTypes = [
|
||
{ icon: '📊', name: 'Stats', color: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
|
||
{ icon: '🎒', name: 'Inventory', color: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)' },
|
||
{ icon: '📝', name: 'Notes', color: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
|
||
{ icon: '🗺️', name: 'Map', color: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' }
|
||
];
|
||
|
||
// Helper: Add event to log
|
||
function logEvent(type, message, details = {}) {
|
||
const log = document.getElementById('event-log');
|
||
const timestamp = new Date().toLocaleTimeString();
|
||
const colors = {
|
||
success: '#4ecca3',
|
||
error: '#e94560',
|
||
info: '#667eea',
|
||
warning: '#f5576c'
|
||
};
|
||
const color = colors[type] || '#aaa';
|
||
const entry = document.createElement('div');
|
||
entry.style.marginBottom = '8px';
|
||
entry.innerHTML = `<span style="color: ${color};">[${timestamp}]</span> ${message}`;
|
||
log.appendChild(entry);
|
||
log.scrollTop = log.scrollHeight;
|
||
console.log(`[${type.toUpperCase()}] ${message}`, details);
|
||
}
|
||
|
||
// Helper: Update save status indicator
|
||
function updateSaveStatus(status, saving = false) {
|
||
const statusEl = document.getElementById('save-status');
|
||
if (saving) {
|
||
statusEl.textContent = '⏳ Saving...';
|
||
statusEl.style.color = '#f5576c';
|
||
} else if (status === 'saved') {
|
||
const time = new Date().toLocaleTimeString();
|
||
statusEl.textContent = `✓ Saved at ${time}`;
|
||
statusEl.style.color = '#4ecca3';
|
||
} else if (status === 'pending') {
|
||
statusEl.textContent = '⚠ Unsaved changes';
|
||
statusEl.style.color = '#f5576c';
|
||
} else {
|
||
statusEl.textContent = status;
|
||
statusEl.style.color = '#aaa';
|
||
}
|
||
}
|
||
|
||
// Helper: Get current dashboard configuration
|
||
function getDashboardConfig() {
|
||
return {
|
||
version: 2,
|
||
gridConfig: {
|
||
columns: 12,
|
||
rowHeight: 80,
|
||
gap: 12
|
||
},
|
||
widgets: widgets.map(w => ({
|
||
id: w.id,
|
||
x: w.x,
|
||
y: w.y,
|
||
w: w.w,
|
||
h: w.h,
|
||
type: w.type
|
||
}))
|
||
};
|
||
}
|
||
|
||
// Helper: Apply loaded dashboard configuration
|
||
function applyDashboardConfig(config) {
|
||
// Clear existing widgets
|
||
widgets.forEach(w => {
|
||
const element = widgetElements.get(w.id);
|
||
if (element) {
|
||
dragHandler.destroyWidget(element);
|
||
resizeHandler.destroyWidget(element);
|
||
editManager.removeWidgetControls(w.id);
|
||
element.remove();
|
||
}
|
||
});
|
||
widgets = [];
|
||
widgetElements.clear();
|
||
|
||
// Add widgets from config
|
||
config.widgets.forEach(widgetData => {
|
||
const widget = { ...widgetData };
|
||
widgets.push(widget);
|
||
createWidgetElement(widget);
|
||
});
|
||
|
||
updateStats();
|
||
logEvent('success', `Loaded layout with ${config.widgets.length} widgets`);
|
||
}
|
||
|
||
// Helper: Trigger auto-save
|
||
function triggerAutoSave() {
|
||
updateSaveStatus('pending');
|
||
const config = getDashboardConfig();
|
||
layoutPersistence.saveLayout(config).catch(err => {
|
||
logEvent('error', 'Auto-save failed', { error: err.message });
|
||
});
|
||
}
|
||
|
||
function init() {
|
||
const container = document.getElementById('grid-container');
|
||
|
||
gridEngine = new GridEngine({ columns: 12, rowHeight: 80, gap: 12, container });
|
||
dragHandler = new DragDropHandler(gridEngine);
|
||
resizeHandler = new ResizeHandler(gridEngine);
|
||
|
||
editManager = new EditModeManager({
|
||
container,
|
||
onSave: () => {
|
||
logEvent('info', 'Edit mode saved');
|
||
triggerAutoSave();
|
||
updateModeIndicator();
|
||
},
|
||
onCancel: () => {
|
||
logEvent('warning', 'Edit mode cancelled');
|
||
updateModeIndicator();
|
||
},
|
||
onWidgetAdd: (type) => {
|
||
addWidget(type);
|
||
triggerAutoSave();
|
||
},
|
||
onWidgetDelete: (widgetId) => {
|
||
deleteWidget(widgetId);
|
||
triggerAutoSave();
|
||
}
|
||
});
|
||
|
||
// Initialize LayoutPersistence
|
||
layoutPersistence = new LayoutPersistence({
|
||
debounceMs: 500,
|
||
onSave: (layout) => {
|
||
updateSaveStatus('saved');
|
||
logEvent('success', 'Layout auto-saved successfully');
|
||
},
|
||
onLoad: (layout) => {
|
||
logEvent('success', 'Layout loaded from storage');
|
||
},
|
||
onError: (error) => {
|
||
logEvent('error', 'Persistence error: ' + error.message);
|
||
}
|
||
});
|
||
|
||
// Listen to persistence events
|
||
layoutPersistence.onChange((event, data) => {
|
||
if (event === 'saveStarted') {
|
||
updateSaveStatus('saving', true);
|
||
} else if (event === 'saveSuccess') {
|
||
updateSaveStatus('saved');
|
||
} else if (event === 'saveError') {
|
||
updateSaveStatus('Error saving');
|
||
logEvent('error', 'Save failed: ' + data.error.message);
|
||
} else if (event === 'exportSuccess') {
|
||
logEvent('success', `Layout exported: ${data.filename}`);
|
||
} else if (event === 'importSuccess') {
|
||
logEvent('success', `Layout imported: ${data.filename}`);
|
||
} else if (event === 'resetSuccess') {
|
||
logEvent('success', 'Layout reset to default');
|
||
}
|
||
});
|
||
|
||
// Button handlers
|
||
document.getElementById('edit-toggle').onclick = () => {
|
||
editManager.toggleEditMode();
|
||
updateModeIndicator();
|
||
};
|
||
|
||
document.getElementById('save-now').onclick = async () => {
|
||
try {
|
||
const config = getDashboardConfig();
|
||
await layoutPersistence.saveLayout(config, true);
|
||
logEvent('success', 'Manual save completed');
|
||
} catch (error) {
|
||
logEvent('error', 'Manual save failed: ' + error.message);
|
||
}
|
||
};
|
||
|
||
document.getElementById('export-layout').onclick = () => {
|
||
try {
|
||
const config = getDashboardConfig();
|
||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||
layoutPersistence.exportLayout(config, `dashboard-${timestamp}.json`);
|
||
} catch (error) {
|
||
logEvent('error', 'Export failed: ' + error.message);
|
||
}
|
||
};
|
||
|
||
document.getElementById('import-layout').onclick = () => {
|
||
document.getElementById('import-file-input').click();
|
||
};
|
||
|
||
document.getElementById('import-file-input').onchange = async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
try {
|
||
const config = await layoutPersistence.importLayout(file);
|
||
applyDashboardConfig(config);
|
||
await layoutPersistence.saveLayout(config, true);
|
||
} catch (error) {
|
||
logEvent('error', 'Import failed: ' + error.message);
|
||
}
|
||
e.target.value = ''; // Reset input
|
||
};
|
||
|
||
document.getElementById('reset-layout').onclick = async () => {
|
||
if (!confirm('Reset layout to default? This will overwrite your current layout.')) return;
|
||
try {
|
||
await layoutPersistence.resetToDefault(defaultDashboard);
|
||
applyDashboardConfig(defaultDashboard);
|
||
} catch (error) {
|
||
logEvent('error', 'Reset failed: ' + error.message);
|
||
}
|
||
};
|
||
|
||
// Create initial widgets and save as default
|
||
createInitialWidgets();
|
||
defaultDashboard = getDashboardConfig();
|
||
|
||
// Try to load saved layout
|
||
(async () => {
|
||
try {
|
||
const saved = await layoutPersistence.loadLayout();
|
||
if (saved) {
|
||
applyDashboardConfig(saved);
|
||
} else {
|
||
logEvent('info', 'No saved layout found, using default');
|
||
updateSaveStatus('Not saved');
|
||
}
|
||
} catch (error) {
|
||
logEvent('warning', 'Failed to load saved layout, using default');
|
||
updateSaveStatus('Load failed');
|
||
}
|
||
})();
|
||
|
||
updateStats();
|
||
}
|
||
|
||
function createInitialWidgets() {
|
||
const initial = [
|
||
{ x: 0, y: 0, w: 6, h: 3, type: 0 },
|
||
{ x: 6, y: 0, w: 6, h: 2, type: 1 },
|
||
{ x: 0, y: 3, w: 4, h: 3, type: 2 }
|
||
];
|
||
|
||
initial.forEach(cfg => {
|
||
const widget = { id: `widget-${widgetCounter++}`, x: cfg.x, y: cfg.y, w: cfg.w, h: cfg.h, type: cfg.type };
|
||
widgets.push(widget);
|
||
createWidgetElement(widget);
|
||
});
|
||
}
|
||
|
||
function createWidgetElement(widget) {
|
||
const container = document.getElementById('grid-container');
|
||
const type = widgetTypes[widget.type];
|
||
const element = document.createElement('div');
|
||
element.className = 'widget';
|
||
element.style.background = type.color;
|
||
element.innerHTML = `
|
||
<div class="widget-header">
|
||
<span class="widget-icon">${type.icon}</span>
|
||
<span class="widget-title">${type.name}</span>
|
||
</div>
|
||
<div class="widget-info">Position: (${widget.x}, ${widget.y})</div>
|
||
<div class="widget-info">Size: ${widget.w}×${widget.h}</div>
|
||
`;
|
||
|
||
container.appendChild(element);
|
||
widgetElements.set(widget.id, element);
|
||
positionWidget(element, widget);
|
||
|
||
dragHandler.initWidget(element, widget, (updated, newX, newY) => {
|
||
widget.x = newX;
|
||
widget.y = newY;
|
||
positionWidget(element, widget);
|
||
updateWidgetInfo(element, widget);
|
||
updateStats();
|
||
triggerAutoSave();
|
||
});
|
||
|
||
resizeHandler.initWidget(element, widget, (updated, newW, newH, newX, newY) => {
|
||
widget.w = newW;
|
||
widget.h = newH;
|
||
widget.x = newX;
|
||
widget.y = newY;
|
||
positionWidget(element, widget);
|
||
updateWidgetInfo(element, widget);
|
||
updateStats();
|
||
triggerAutoSave();
|
||
});
|
||
|
||
editManager.addWidgetControls(element, widget.id);
|
||
}
|
||
|
||
function positionWidget(element, widget) {
|
||
const pos = 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';
|
||
}
|
||
|
||
function updateWidgetInfo(element, widget) {
|
||
const infos = element.querySelectorAll('.widget-info');
|
||
infos[0].textContent = `Position: (${widget.x}, ${widget.y})`;
|
||
infos[1].textContent = `Size: ${widget.w}×${widget.h}`;
|
||
}
|
||
|
||
function addWidget(type) {
|
||
const typeIndex = widgetTypes.findIndex(t => t.name.toLowerCase() === type);
|
||
const widget = {
|
||
id: `widget-${widgetCounter++}`,
|
||
x: Math.floor(Math.random() * 6),
|
||
y: Math.floor(Math.random() * 2),
|
||
w: 4,
|
||
h: 2,
|
||
type: typeIndex >= 0 ? typeIndex : 0
|
||
};
|
||
widgets.push(widget);
|
||
createWidgetElement(widget);
|
||
updateStats();
|
||
}
|
||
|
||
function deleteWidget(widgetId) {
|
||
const index = widgets.findIndex(w => w.id === widgetId);
|
||
if (index === -1) return;
|
||
widgets.splice(index, 1);
|
||
const element = widgetElements.get(widgetId);
|
||
if (element) {
|
||
dragHandler.destroyWidget(element);
|
||
resizeHandler.destroyWidget(element);
|
||
editManager.removeWidgetControls(widgetId);
|
||
element.remove();
|
||
widgetElements.delete(widgetId);
|
||
}
|
||
updateStats();
|
||
}
|
||
|
||
function updateStats() {
|
||
document.getElementById('widget-count').textContent = widgets.length;
|
||
const totalUnits = widgets.reduce((sum, w) => sum + (w.w * w.h), 0);
|
||
document.getElementById('total-units').textContent = totalUnits;
|
||
}
|
||
|
||
function updateModeIndicator() {
|
||
const indicator = document.getElementById('mode-indicator');
|
||
const toggle = document.getElementById('edit-toggle');
|
||
if (editManager.getIsEditMode()) {
|
||
indicator.textContent = 'EDIT';
|
||
indicator.className = 'mode-indicator edit';
|
||
toggle.textContent = 'Exit Edit Mode';
|
||
toggle.classList.add('active');
|
||
} else {
|
||
indicator.textContent = 'VIEW';
|
||
indicator.className = 'mode-indicator view';
|
||
toggle.textContent = 'Edit Layout';
|
||
toggle.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|