Files
rpg-companion-sillytavern/src/systems/dashboard/widgets/userStatsWidget.js
T
Lucas 'Paperboy' Rose-Winters 8dc07a938a feat: implement responsive dashboard layout with column-aware widget sizing
**Status Tab Layout Changes:**
- User Info widget: 1x2 vertical (left column) instead of 2x1 horizontal
- User Stats widget: scales from 1x3 (narrow) to 2x3 (wide)
- User Mood widget: 1x1 positioned below User Info
- User Attributes widget: scales from 2x4 (narrow) to 3x4 (wide), full width

**Technical Changes:**
- Update widget definitions to use column-aware defaultSize() functions
- userInfoWidget: Returns 1x2 for desktop, 1x1 for mobile
- userStatsWidget: Returns 1x3 for 2 cols, 2x3 for 3+ cols
- userAttributesWidget: Returns 2x4 for 2 cols, 3x4 for 3+ cols
- Remove autoLayout from resetLayout() to preserve default positions
- Add resetWidgetSizesToDefault() to apply column-aware sizes
- Update CSS for 1x1 compact avatar (round) and 1x2 wide avatar layouts

**User Info Widget Improvements:**
- 1x2 layout: Horizontal split with name left, level right over avatar
- 1x1 layout: Round avatar with bottom nameplate (flush positioning)
- Transparent glass-style backgrounds for better avatar visibility
- Proper aspect-ratio for circular avatar in compact mode

**Result:**
- Widgets scale intelligently based on panel width (2-4 columns)
- Desktop users get larger, more spacious layouts
- Mobile/narrow screens get efficient vertical stacking
- Reset Layout respects custom positions while applying responsive sizes
- Window resize triggers autoLayout via ResizeObserver for reflow
2025-11-06 20:42:57 +11:00

274 lines
10 KiB
JavaScript

/**
* User Stats Widget (Refactored - Modular)
*
* Displays user vital statistics as progress bars:
* - Health, Satiety, Energy, Hygiene, Arousal
*
* Features:
* - Editable stat values with live update
* - Progress bars with customizable colors
* - Configurable visible stats
* - Smart content-aware sizing (more bars = needs more height)
*/
import { createProgressBar, attachEditableHandlers, parseNumber } from '../widgetBase.js';
/**
* Register User Stats Widget
* @param {WidgetRegistry} registry - Widget registry instance
* @param {Object} dependencies - External dependencies
* @param {Function} dependencies.getContext - Get SillyTavern context
* @param {Function} dependencies.getExtensionSettings - Get extension settings
* @param {Function} dependencies.onStatsChange - Callback when stats change
*/
export function registerUserStatsWidget(registry, dependencies) {
const {
getExtensionSettings,
onStatsChange
} = dependencies;
registry.register('userStats', {
name: 'User Stats',
icon: '❤️',
description: 'Health, energy, satiety bars',
category: 'user',
minSize: { w: 1, h: 2 },
// Column-aware sizing: narrow and tall at 2 cols, wider at 3+ cols
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 1, h: 3 }; // Mobile: 1 col wide, 3 rows tall
}
return { w: 2, h: 3 }; // Desktop: 2 cols wide, 3 rows tall
},
// Column-aware max size: same as default to prevent expansion
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 1, h: 3 }; // Mobile: 1x3
}
return { w: 2, h: 3 }; // Desktop: 2x3
},
requiresSchema: false,
/**
* Render widget content
* @param {HTMLElement} container - Widget container
* @param {Object} config - Widget configuration
*/
render(container, config = {}) {
const settings = getExtensionSettings();
const stats = settings.userStats;
const trackerConfig = settings.trackerConfig?.userStats;
// Get globally enabled stats from trackerConfig
const globallyEnabledStats = trackerConfig?.customStats
?.filter(stat => stat.enabled)
.map(stat => ({ id: stat.id, name: stat.name })) || [];
// If no globally enabled stats, fall back to defaults
const availableStats = globallyEnabledStats.length > 0
? globallyEnabledStats
: [
{ id: 'health', name: 'Health' },
{ id: 'satiety', name: 'Satiety' },
{ id: 'energy', name: 'Energy' },
{ id: 'hygiene', name: 'Hygiene' },
{ id: 'arousal', name: 'Arousal' }
];
// Apply widget-level filter if specified (config.visibleStats overrides)
let visibleStats = availableStats;
if (config.visibleStats && config.visibleStats.length > 0) {
visibleStats = availableStats.filter(stat =>
config.visibleStats.includes(stat.id)
);
}
// Merge default config with user config
const finalConfig = {
statBarGradient: true,
...config
};
// Create gradient for stat bars
const gradient = finalConfig.statBarGradient
? `linear-gradient(to right, ${settings.statBarColorLow}, ${settings.statBarColorHigh})`
: settings.statBarColorHigh;
// Build progress bars HTML using trackerConfig names
const progressBarsHtml = visibleStats.map(stat => {
return createProgressBar({
label: stat.name,
value: stats[stat.id] || 0,
gradient,
editable: true,
field: stat.id
});
}).join('');
// Render HTML
const html = `
<div class="rpg-stats-content rpg-stats-modular">
<div class="rpg-stats-grid">
${progressBarsHtml}
</div>
</div>
`;
container.innerHTML = html;
// Attach event handlers
attachEventHandlers(container, settings, onStatsChange);
},
/**
* Get configuration options
* @returns {Object} Configuration schema
*/
getConfig() {
const settings = getExtensionSettings();
const trackerConfig = settings.trackerConfig?.userStats;
// Get enabled stats from trackerConfig for options
const enabledStats = trackerConfig?.customStats
?.filter(stat => stat.enabled)
.map(stat => ({ value: stat.id, label: stat.name })) || [
{ value: 'health', label: 'Health' },
{ value: 'satiety', label: 'Satiety' },
{ value: 'energy', label: 'Energy' },
{ value: 'hygiene', label: 'Hygiene' },
{ value: 'arousal', label: 'Arousal' }
];
return {
statBarGradient: {
type: 'boolean',
label: 'Use Gradient for Stat Bars',
default: true,
description: 'Show progress bars with color gradient from low to high'
},
visibleStats: {
type: 'multiselect',
label: 'Visible Stats',
default: null, // null means "show all enabled stats"
options: enabledStats,
description: 'Select which stats to show in this widget (leave empty to show all enabled stats)',
hint: 'To add/remove/rename stats globally, use Tracker Settings'
}
};
},
/**
* Handle configuration changes
* @param {HTMLElement} container - Widget container
* @param {Object} newConfig - New configuration
*/
onConfigChange(container, newConfig) {
// Re-render with new config
this.render(container, newConfig);
},
/**
* Handle widget resize
* @param {HTMLElement} container - Widget container
* @param {number} newW - New width
* @param {number} newH - New height
*/
onResize(container, newW, newH) {
// Layout adjustments if needed (currently none)
},
/**
* 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 settings = getExtensionSettings();
const trackerConfig = settings.trackerConfig?.userStats;
// Count globally enabled stats
const globallyEnabledCount = trackerConfig?.customStats
?.filter(stat => stat.enabled).length || 5;
// If widget has visibleStats override, use that count
const visibleStatCount = config.visibleStats?.length || globallyEnabledCount;
// 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)
};
}
});
}
/**
* Attach event handlers to widget
* @private
*/
function attachEventHandlers(container, settings, onStatsChange) {
// Handle editable stat value changes (health, satiety, etc.)
const editableStats = container.querySelectorAll('.rpg-editable-stat');
editableStats.forEach(field => {
const fieldName = field.dataset.field;
let originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100);
field.addEventListener('focus', () => {
originalValue = parseNumber(field.textContent.replace('%', '').trim(), 0, 0, 100);
// Select all text
const range = document.createRange();
range.selectNodeContents(field);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
field.addEventListener('blur', () => {
const textValue = field.textContent.replace('%', '').trim();
const value = parseNumber(textValue, originalValue, 0, 100);
// Update display
field.textContent = `${value}%`;
// Update settings if changed
if (value !== originalValue) {
settings.userStats[fieldName] = value;
// Update the bar fill
const bar = field.parentElement.querySelector('.rpg-stat-fill');
if (bar) {
bar.style.width = `${100 - value}%`;
}
// Trigger change callback
if (onStatsChange) {
onStatsChange('userStats', fieldName, value);
}
}
});
field.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
field.blur();
}
if (e.key === 'Escape') {
e.preventDefault();
field.textContent = `${originalValue}%`;
field.blur();
}
});
// Prevent paste with formatting
field.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
});
}