Files
rpg-companion-sillytavern/src/systems/dashboard/layoutPersistence.standalone.test.html
T
Lucas 'Paperboy' Rose-Winters ecf7e88bb4 feat: complete Task 1.8 - Layout Persistence System
- 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! 🎉
2025-10-23 10:34:47 +11:00

1447 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>