feat(dashboard): split user

Stats widget into 4 modular widgets

- Create userInfoWidget (avatar, name, level)
- Refactor userStatsWidget (stats bars only with smart sizing)
- Create userMoodWidget (mood emoji, conditions)
- Create userAttributesWidget (STR/DEX/CON/INT/WIS/CHA)
- Add category field to widgets for auto-layout grouping
- Register all new modular widgets in dashboardIntegration.js

All widgets include getOptimalSize() for smart content-aware auto-layout.
Part of Phase 1 & 3.1 of dashboard modularization plan.
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-23 15:48:02 +11:00
parent 264ea2fc4c
commit aeb3ad1b9b
8 changed files with 674 additions and 253 deletions
+14 -4
View File
@@ -13,7 +13,10 @@ import { DashboardManager } from './dashboardManager.js';
import { WidgetRegistry } from './widgetRegistry.js';
// Widget imports
import { registerUserInfoWidget } from './widgets/userInfoWidget.js';
import { registerUserStatsWidget } from './widgets/userStatsWidget.js';
import { registerUserMoodWidget } from './widgets/userMoodWidget.js';
import { registerUserAttributesWidget } from './widgets/userAttributesWidget.js';
import { registerCalendarWidget, registerWeatherWidget, registerTemperatureWidget, registerClockWidget, registerLocationWidget } from './widgets/infoBoxWidgets.js';
import { registerPresentCharactersWidget } from './widgets/presentCharactersWidget.js';
import { registerInventoryWidget } from './widgets/inventoryWidget.js';
@@ -165,18 +168,25 @@ function getInlineDashboardTemplate() {
function registerAllWidgets(registry, dependencies) {
console.log('[RPG Companion] Registering widgets...');
// Core widgets
// User modular widgets
registerUserInfoWidget(registry, dependencies);
registerUserStatsWidget(registry, dependencies);
registerPresentCharactersWidget(registry, dependencies);
registerInventoryWidget(registry, dependencies);
registerUserMoodWidget(registry, dependencies);
registerUserAttributesWidget(registry, dependencies);
// Info Box modular widgets
// Scene info widgets
registerCalendarWidget(registry, dependencies);
registerWeatherWidget(registry, dependencies);
registerTemperatureWidget(registry, dependencies);
registerClockWidget(registry, dependencies);
registerLocationWidget(registry, dependencies);
// Social widgets
registerPresentCharactersWidget(registry, dependencies);
// Inventory widget
registerInventoryWidget(registry, dependencies);
console.log(`[RPG Companion] Registered ${registry.getAll().length} widgets`);
}
+33 -6
View File
@@ -102,7 +102,34 @@ export class DashboardManager {
container: this.gridContainer,
onColumnsChange: (newCols, oldCols) => {
console.log('[DashboardManager] Grid columns changed:', oldCols, '→', newCols);
// Re-render all widgets when column count changes
// Fix widget dimensions when column count changes
// This prevents widgets from shrinking when grid switches between 2/3/4 columns
const currentTab = this.tabManager.getTab(this.currentTabId);
if (currentTab) {
currentTab.widgets.forEach(widget => {
// If widget was full-width in old grid, make it full-width in new grid
if (widget.w === oldCols) {
console.log(`[DashboardManager] Adjusting full-width widget ${widget.id}: w=${widget.w}${newCols}`);
widget.w = newCols;
}
// If widget is wider than new grid, clamp it
else if (widget.w > newCols) {
console.log(`[DashboardManager] Clamping oversized widget ${widget.id}: w=${widget.w}${newCols}`);
widget.w = newCols;
}
// If widget x position is out of bounds, reset to 0
if (widget.x >= newCols) {
console.log(`[DashboardManager] Resetting out-of-bounds widget ${widget.id}: x=${widget.x} → 0`);
widget.x = 0;
}
});
// Save changes
this.triggerAutoSave();
}
// Re-render all widgets with adjusted dimensions
this.renderAllWidgets();
}
});
@@ -182,9 +209,6 @@ export class DashboardManager {
// Measure container width and set up responsive sizing
this.setupContainerSizing();
// Migrate old 12-column layouts to new responsive grid
this.migrateOldLayouts();
// Render tab navigation
this.renderTabs();
@@ -515,8 +539,11 @@ export class DashboardManager {
element.dataset.widgetId = widget.id;
element.dataset.widgetType = widget.type;
// Position widget using grid engine (responsive units for scaling)
const pos = this.gridEngine.getWidgetPosition(widget);
// Validate widget dimensions (defensive check - shouldn't be needed if onColumnsChange works)
const validated = this.gridEngine.validateWidget(widget, definition.minSize || { w: 1, h: 1 });
// Position widget using validated dimensions
const pos = this.gridEngine.getWidgetPosition(validated);
element.style.position = 'absolute';
element.style.left = pos.left; // % of container (e.g., "5.23%")
element.style.top = pos.top; // vh units (e.g., "10.45vh")
@@ -62,6 +62,7 @@ export function registerInventoryWidget(registry, dependencies) {
name: 'Inventory',
icon: '🎒',
description: 'Full inventory system with On Person, Stored, and Assets',
category: 'inventory',
minSize: { w: 2, h: 4 },
defaultSize: { w: 2, h: 6 },
requiresSchema: false,
@@ -235,6 +235,7 @@ export function registerPresentCharactersWidget(registry, dependencies) {
name: 'Present Characters',
icon: '👥',
description: 'Character cards with avatars, traits, and relationships',
category: 'social',
minSize: { w: 2, h: 2 },
defaultSize: { w: 2, h: 3 },
requiresSchema: false,
@@ -0,0 +1,197 @@
/**
* User Attributes Widget
*
* Displays classic D&D-style attribute scores with +/- adjustment buttons.
* Shows STR, DEX, CON, INT, WIS, CHA stats.
*
* Features:
* - 6 classic RPG attributes
* - +/- buttons for quick adjustments (1-20 range)
* - Responsive grid layout
* - Smart sizing: compact for narrow, grid for wide
*/
import { parseNumber } from '../widgetBase.js';
/**
* Register User Attributes Widget
* @param {WidgetRegistry} registry - Widget registry instance
* @param {Object} dependencies - External dependencies
* @param {Function} dependencies.getExtensionSettings - Get extension settings
* @param {Function} dependencies.onStatsChange - Callback when stats change
*/
export function registerUserAttributesWidget(registry, dependencies) {
const {
getExtensionSettings,
onStatsChange
} = dependencies;
registry.register('userAttributes', {
name: 'User Attributes',
icon: '⚔️',
description: 'Classic RPG stats (STR, DEX, CON, INT, WIS, CHA)',
category: 'user',
minSize: { w: 1, h: 2 },
defaultSize: { w: 2, h: 2 },
requiresSchema: false,
/**
* Render widget content
* @param {HTMLElement} container - Widget container
* @param {Object} config - Widget configuration
*/
render(container, config = {}) {
const settings = getExtensionSettings();
const classicStats = settings.classicStats;
// Merge default config
const finalConfig = {
visibleStats: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
showLabels: true,
...config
};
// Build stats HTML
const statsHtml = finalConfig.visibleStats.map(stat => `
<div class="rpg-classic-stat" data-stat="${stat}">
${finalConfig.showLabels ? `<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('');
// Render HTML
const html = `
<div class="rpg-classic-stats">
<div class="rpg-classic-stats-grid">
${statsHtml}
</div>
</div>
`;
container.innerHTML = html;
// Attach event handlers
attachEventHandlers(container, settings, onStatsChange);
},
/**
* Get configuration options
* @returns {Object} Configuration schema
*/
getConfig() {
return {
visibleStats: {
type: 'multiselect',
label: 'Visible Attributes',
default: ['str', 'dex', 'con', 'int', 'wis', 'cha'],
options: [
{ value: 'str', label: 'Strength (STR)' },
{ value: 'dex', label: 'Dexterity (DEX)' },
{ value: 'con', label: 'Constitution (CON)' },
{ value: 'int', label: 'Intelligence (INT)' },
{ value: 'wis', label: 'Wisdom (WIS)' },
{ value: 'cha', label: 'Charisma (CHA)' }
]
},
showLabels: {
type: 'boolean',
label: 'Show Stat Labels',
default: true
}
};
},
/**
* Handle configuration changes
* @param {HTMLElement} container - Widget container
* @param {Object} newConfig - New configuration
*/
onConfigChange(container, newConfig) {
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) {
const statsGrid = container.querySelector('.rpg-classic-stats-grid');
if (!statsGrid) return;
// Compact single-column layout for narrow widgets
if (newW < 2) {
statsGrid.style.gridTemplateColumns = '1fr';
} else {
// 2-column grid for wider widgets
statsGrid.style.gridTemplateColumns = 'repeat(2, 1fr)';
}
},
/**
* Calculate optimal size based on content
* Used by smart auto-layout to determine ideal widget dimensions
* @param {Object} config - Widget configuration
* @returns {Object} Optimal size { w, h }
*/
getOptimalSize(config = {}) {
const visibleStatCount = config.visibleStats?.length || 6;
// Each stat needs ~0.35 rows in 2-column grid
// For 6 stats: 3 rows (0.5 row padding = 3.5 total)
const optimalHeight = Math.ceil((visibleStatCount / 2) * 0.7 + 0.5);
return {
w: 2, // Prefer 2-column grid layout
h: Math.max(this.minSize.h, optimalHeight)
};
}
});
}
/**
* Attach event handlers to widget
* @private
*/
function attachEventHandlers(container, settings, onStatsChange) {
// 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);
}
});
});
}
@@ -0,0 +1,187 @@
/**
* User Info Widget
*
* Displays user avatar, name, and level.
* Compact widget showing basic user identity with editable level.
*
* Features:
* - User portrait/avatar display
* - User name from SillyTavern context
* - Editable level field (1-100)
* - Compact horizontal layout
*/
import { parseNumber } from '../widgetBase.js';
/**
* Register User Info 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 registerUserInfoWidget(registry, dependencies) {
const {
getContext,
getUserAvatar,
getExtensionSettings,
onStatsChange
} = dependencies;
registry.register('userInfo', {
name: 'User Info',
icon: '👤',
description: 'User avatar, name, and level display',
category: 'user',
minSize: { w: 1, h: 1 },
defaultSize: { w: 2, h: 1 },
requiresSchema: false,
/**
* Render widget content
* @param {HTMLElement} container - Widget container
* @param {Object} config - Widget configuration
*/
render(container, config = {}) {
const settings = getExtensionSettings();
const context = getContext();
const userName = context.name1;
const userPortrait = getUserAvatar();
// Merge default config
const finalConfig = {
showAvatar: true,
showName: true,
showLevel: true,
...config
};
// Build HTML
const html = `
<div class="rpg-user-info-row">
${finalConfig.showAvatar ? `<img src="${userPortrait}" alt="${userName}" class="rpg-user-portrait" onerror="this.style.opacity='0.5';this.onerror=null;" />` : ''}
${finalConfig.showName ? `<span class="rpg-user-name">${userName}</span>` : ''}
${finalConfig.showLevel ? `
<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>
`;
container.innerHTML = html;
// Attach event handlers
attachEventHandlers(container, settings, onStatsChange);
},
/**
* Get configuration options
* @returns {Object} Configuration schema
*/
getConfig() {
return {
showAvatar: {
type: 'boolean',
label: 'Show Avatar',
default: true
},
showName: {
type: 'boolean',
label: 'Show User Name',
default: true
},
showLevel: {
type: 'boolean',
label: 'Show Level',
default: true
}
};
},
/**
* Handle configuration changes
* @param {HTMLElement} container - Widget container
* @param {Object} newConfig - New configuration
*/
onConfigChange(container, newConfig) {
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) {
// Responsive adjustments if needed
const infoRow = container.querySelector('.rpg-user-info-row');
if (!infoRow) return;
// Stack vertically on very narrow widgets
if (newW < 2) {
infoRow.style.flexDirection = 'column';
infoRow.style.alignItems = 'center';
} else {
infoRow.style.flexDirection = 'row';
infoRow.style.alignItems = 'center';
}
}
});
}
/**
* Attach event handlers to widget
* @private
*/
function attachEventHandlers(container, settings, onStatsChange) {
// Handle level editing
const levelValue = container.querySelector('.rpg-level-value.rpg-editable');
if (!levelValue) return;
let originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
levelValue.addEventListener('focus', () => {
originalLevel = parseNumber(levelValue.textContent.trim(), 1, 1, 100);
// Select all text
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();
}
});
// Prevent paste with formatting
levelValue.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
}
@@ -0,0 +1,210 @@
/**
* User Mood Widget
*
* Displays user's current mood emoji and active conditions.
* Compact widget showing emotional state and status effects.
*
* Features:
* - Large mood emoji (editable)
* - Conditions/status effects text (editable)
* - Responsive layout
*/
/**
* Register User Mood Widget
* @param {WidgetRegistry} registry - Widget registry instance
* @param {Object} dependencies - External dependencies
* @param {Function} dependencies.getExtensionSettings - Get extension settings
* @param {Function} dependencies.onStatsChange - Callback when stats change
*/
export function registerUserMoodWidget(registry, dependencies) {
const {
getExtensionSettings,
onStatsChange
} = dependencies;
registry.register('userMood', {
name: 'User Mood',
icon: '😊',
description: 'Mood emoji and active conditions',
category: 'user',
minSize: { w: 1, h: 1 },
defaultSize: { w: 2, h: 1 },
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;
// Merge default config
const finalConfig = {
showMoodEmoji: true,
showConditions: true,
...config
};
// Build HTML
const html = `
<div class="rpg-mood">
${finalConfig.showMoodEmoji ? `<div class="rpg-mood-emoji rpg-editable" contenteditable="true" data-field="mood" title="Click to edit emoji">${stats.mood}</div>` : ''}
${finalConfig.showConditions ? `<div class="rpg-mood-conditions rpg-editable" contenteditable="true" data-field="conditions" title="Click to edit conditions">${stats.conditions}</div>` : ''}
</div>
`;
container.innerHTML = html;
// Attach event handlers
attachEventHandlers(container, settings, onStatsChange);
},
/**
* Get configuration options
* @returns {Object} Configuration schema
*/
getConfig() {
return {
showMoodEmoji: {
type: 'boolean',
label: 'Show Mood Emoji',
default: true
},
showConditions: {
type: 'boolean',
label: 'Show Conditions',
default: true
}
};
},
/**
* Handle configuration changes
* @param {HTMLElement} container - Widget container
* @param {Object} newConfig - New configuration
*/
onConfigChange(container, newConfig) {
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) {
// Responsive adjustments if needed
const mood = container.querySelector('.rpg-mood');
if (!mood) return;
// Adjust layout for narrow widgets
if (newW < 2) {
mood.style.flexDirection = 'column';
} else {
mood.style.flexDirection = 'row';
}
}
});
}
/**
* Attach event handlers to widget
* @private
*/
function attachEventHandlers(container, settings, onStatsChange) {
// 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();
}
});
// Prevent paste with formatting
moodEmoji.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
}
// 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();
}
});
// Prevent paste with formatting
moodConditions.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
}
}
+29 -241
View File
@@ -1,15 +1,14 @@
/**
* User Stats Widget
* User Stats Widget (Refactored - Modular)
*
* Displays user health/satiety/energy/hygiene/arousal bars,
* mood/conditions, and classic D&D stats (STR/DEX/CON/INT/WIS/CHA).
* Displays user vital statistics as progress bars:
* - Health, Satiety, Energy, Hygiene, Arousal
*
* Features:
* - Editable stat values with live update
* - Progress bars with customizable colors
* - User portrait and level display
* - Classic stats with +/- buttons
* - Mobile-responsive layout
* - Configurable visible stats
* - Smart content-aware sizing (more bars = needs more height)
*/
import { createProgressBar, attachEditableHandlers, parseNumber } from '../widgetBase.js';
@@ -19,14 +18,11 @@ import { createProgressBar, attachEditableHandlers, parseNumber } from '../widge
* @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;
@@ -34,9 +30,10 @@ export function registerUserStatsWidget(registry, dependencies) {
registry.register('userStats', {
name: 'User Stats',
icon: '❤️',
description: 'Health, energy, satiety bars and classic RPG stats',
description: 'Health, energy, satiety bars',
category: 'user',
minSize: { w: 1, h: 2 },
defaultSize: { w: 2, h: 3 },
defaultSize: { w: 2, h: 2 },
requiresSchema: false,
/**
@@ -47,16 +44,9 @@ export function registerUserStatsWidget(registry, dependencies) {
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
@@ -79,56 +69,12 @@ export function registerUserStatsWidget(registry, dependencies) {
});
}).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
// Render HTML
const html = `
<div class="rpg-stats-content">
<div class="rpg-stats-left">
${portraitHtml}
<div class="rpg-stats-content rpg-stats-modular">
<div class="rpg-stats-grid">
${progressBarsHtml}
</div>
${moodHtml}
</div>
${classicStatsHtml}
</div>
`;
@@ -144,21 +90,6 @@ export function registerUserStatsWidget(registry, dependencies) {
*/
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',
@@ -196,16 +127,26 @@ export function registerUserStatsWidget(registry, dependencies) {
* @param {number} newH - New height
*/
onResize(container, newW, newH) {
// Adjust layout based on size
const statsContent = container.querySelector('.rpg-stats-content');
if (!statsContent) return;
// Layout adjustments if needed (currently none)
},
// Stack vertically on narrow widgets
if (newW < 5) {
statsContent.style.flexDirection = 'column';
} else {
statsContent.style.flexDirection = 'row';
}
/**
* Calculate optimal size based on content
* Used by smart auto-layout to determine ideal widget dimensions
* @param {Object} config - Widget configuration
* @returns {Object} Optimal size { w, h }
*/
getOptimalSize(config = {}) {
const visibleStatCount = config.visibleStats?.length || 5;
// Each stat bar needs ~0.4 rows of height
// Add 0.5 row for padding/margins
const optimalHeight = Math.ceil(visibleStatCount * 0.4 + 0.5);
return {
w: 2, // Prefer full width for readability
h: Math.max(this.minSize.h, optimalHeight)
};
}
});
}
@@ -274,157 +215,4 @@ function attachEventHandlers(container, settings, onStatsChange) {
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);
}
});
});
}