4644e0fd93
- Add new desktopStripWidgets settings in state.js with toggles for weather, clock, date, location, stats, and attributes - Add strip widget container in template.html with animated clock face - Add CSS styles for strip widgets with wider collapsed panel (5rem), vertical layout, and theme support - Implement updateStripWidgets() in desktop.js to populate widgets from tracker data - Wire up settings handlers in index.js for all strip widget toggles - Call updateStripWidgets() on data updates in sillytavern.js integration - Trigger widget update when panel is collapsed in layout.js The strip widgets display compact stats/info in the collapsed panel strip on desktop, similar to mobile FAB widgets, eliminating the need to expand the panel to view basic data.
449 lines
18 KiB
JavaScript
449 lines
18 KiB
JavaScript
/**
|
|
* Desktop UI Module
|
|
* Handles desktop-specific UI functionality: tab navigation and strip widgets
|
|
*/
|
|
|
|
import { i18n } from '../../core/i18n.js';
|
|
import { extensionSettings, lastGeneratedData, committedTrackerData } from '../../core/state.js';
|
|
|
|
/**
|
|
* Helper to parse time string and calculate clock hand angles
|
|
*/
|
|
function parseTimeForClock(timeStr) {
|
|
const timeMatch = timeStr.match(/(\d+):(\d+)/);
|
|
if (timeMatch) {
|
|
const hours = parseInt(timeMatch[1]);
|
|
const minutes = parseInt(timeMatch[2]);
|
|
const hourAngle = (hours % 12) * 30 + minutes * 0.5; // 30° per hour + 0.5° per minute
|
|
const minuteAngle = minutes * 6; // 6° per minute
|
|
return { hourAngle, minuteAngle };
|
|
}
|
|
return { hourAngle: 0, minuteAngle: 0 };
|
|
}
|
|
|
|
/**
|
|
* Updates the desktop strip widgets display based on current tracker data and settings.
|
|
* Strip widgets are shown vertically in the collapsed panel strip.
|
|
*/
|
|
export function updateStripWidgets() {
|
|
const $panel = $('#rpg-companion-panel');
|
|
const $container = $('#rpg-strip-widget-container');
|
|
|
|
if ($panel.length === 0 || $container.length === 0) return;
|
|
|
|
// Check if strip widgets are enabled
|
|
const widgetSettings = extensionSettings.desktopStripWidgets;
|
|
if (!widgetSettings || !widgetSettings.enabled) {
|
|
$panel.removeClass('rpg-strip-widgets-enabled');
|
|
$container.find('.rpg-strip-widget').removeClass('rpg-strip-widget-visible');
|
|
return;
|
|
}
|
|
|
|
// Add enabled class to panel for CSS styling (wider collapsed width)
|
|
$panel.addClass('rpg-strip-widgets-enabled');
|
|
|
|
// Get tracker data - use imported state directly
|
|
const infoBox = lastGeneratedData?.infoBox || committedTrackerData?.infoBox;
|
|
|
|
// Parse infoBox if it's a string
|
|
let infoData = null;
|
|
if (infoBox) {
|
|
try {
|
|
infoData = typeof infoBox === 'string' ? JSON.parse(infoBox) : infoBox;
|
|
} catch (e) {
|
|
console.warn('[RPG Strip Widgets] Failed to parse infoBox:', e);
|
|
}
|
|
}
|
|
|
|
// Weather Icon Widget (with description)
|
|
const $weatherWidget = $container.find('.rpg-strip-widget-weather');
|
|
if (widgetSettings.weatherIcon?.enabled && infoData?.weather?.emoji) {
|
|
$weatherWidget.find('.rpg-strip-widget-icon').text(infoData.weather.emoji);
|
|
// Show weather description truncated
|
|
const forecast = infoData.weather.forecast || '';
|
|
const displayForecast = forecast.length > 12 ? forecast.substring(0, 10) + '…' : forecast;
|
|
$weatherWidget.find('.rpg-strip-widget-desc').text(displayForecast);
|
|
$weatherWidget.attr('title', forecast || 'Weather');
|
|
$weatherWidget.addClass('rpg-strip-widget-visible');
|
|
} else {
|
|
$weatherWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
|
|
// Clock Widget with animated face
|
|
const $clockWidget = $container.find('.rpg-strip-widget-clock');
|
|
if (widgetSettings.clock?.enabled && infoData?.time) {
|
|
const timeStr = infoData.time.end || infoData.time.value || infoData.time.start || '';
|
|
if (timeStr) {
|
|
// Update clock hands
|
|
const { hourAngle, minuteAngle } = parseTimeForClock(timeStr);
|
|
$clockWidget.find('.rpg-strip-clock-hour').css('transform', `rotate(${hourAngle}deg)`);
|
|
$clockWidget.find('.rpg-strip-clock-minute').css('transform', `rotate(${minuteAngle}deg)`);
|
|
$clockWidget.find('.rpg-strip-widget-value').text(timeStr);
|
|
$clockWidget.attr('title', `Time: ${timeStr}`);
|
|
$clockWidget.addClass('rpg-strip-widget-visible');
|
|
} else {
|
|
$clockWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
} else {
|
|
$clockWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
|
|
// Date Widget
|
|
const $dateWidget = $container.find('.rpg-strip-widget-date');
|
|
if (widgetSettings.date?.enabled && infoData?.date?.value) {
|
|
const dateVal = infoData.date.value;
|
|
// Truncate long dates for display
|
|
const displayDate = dateVal.length > 20 ? dateVal.substring(0, 18) + '…' : dateVal;
|
|
$dateWidget.find('.rpg-strip-widget-value').text(displayDate);
|
|
$dateWidget.attr('title', dateVal);
|
|
$dateWidget.addClass('rpg-strip-widget-visible');
|
|
} else {
|
|
$dateWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
|
|
// Location Widget
|
|
const $locationWidget = $container.find('.rpg-strip-widget-location');
|
|
if (widgetSettings.location?.enabled && infoData?.location?.value) {
|
|
const loc = infoData.location.value;
|
|
// Truncate long locations for display
|
|
const displayLoc = loc.length > 15 ? loc.substring(0, 13) + '…' : loc;
|
|
$locationWidget.find('.rpg-strip-widget-value').text(displayLoc);
|
|
$locationWidget.attr('title', loc);
|
|
$locationWidget.addClass('rpg-strip-widget-visible');
|
|
} else {
|
|
$locationWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
|
|
// Stats Widget - get from lastGeneratedData or committedTrackerData first, fallback to extensionSettings
|
|
const $statsWidget = $container.find('.rpg-strip-widget-stats');
|
|
if (widgetSettings.stats?.enabled) {
|
|
let allStats = [];
|
|
|
|
// Try to get stats from tracker data first (most current)
|
|
const userStatsData = lastGeneratedData?.userStats || committedTrackerData?.userStats;
|
|
if (userStatsData) {
|
|
try {
|
|
const parsedStats = typeof userStatsData === 'string' ? JSON.parse(userStatsData) : userStatsData;
|
|
if (parsedStats?.stats) {
|
|
allStats = parsedStats.stats;
|
|
}
|
|
} catch (e) {
|
|
console.warn('[RPG Strip Widgets] Failed to parse tracker userStats:', e);
|
|
}
|
|
}
|
|
|
|
// Fallback to extensionSettings.userStats
|
|
if (allStats.length === 0 && extensionSettings.userStats) {
|
|
try {
|
|
const userStatsJson = extensionSettings.userStats;
|
|
const parsedUserStats = typeof userStatsJson === 'string' ? JSON.parse(userStatsJson) : userStatsJson;
|
|
if (parsedUserStats?.stats) {
|
|
allStats = parsedUserStats.stats;
|
|
}
|
|
} catch (e) {
|
|
console.warn('[RPG Strip Widgets] Failed to parse extensionSettings.userStats:', e);
|
|
}
|
|
}
|
|
|
|
if (allStats.length > 0) {
|
|
// Get enabled stats from trackerConfig
|
|
const configuredStats = extensionSettings.trackerConfig?.userStats?.customStats || [];
|
|
const enabledStatMap = new Map();
|
|
configuredStats.forEach(s => {
|
|
if (s.enabled !== false) {
|
|
enabledStatMap.set(s.id?.toLowerCase(), true);
|
|
enabledStatMap.set(s.name?.toLowerCase(), true);
|
|
}
|
|
});
|
|
|
|
const $statsList = $statsWidget.find('.rpg-strip-stats-list');
|
|
$statsList.empty();
|
|
|
|
allStats.forEach(stat => {
|
|
// Filter by config if available - but if no config, show all
|
|
if (configuredStats.length > 0) {
|
|
const statId = stat.id?.toLowerCase();
|
|
const statName = stat.name?.toLowerCase();
|
|
if (!enabledStatMap.has(statId) && !enabledStatMap.has(statName)) return;
|
|
}
|
|
|
|
const value = typeof stat.value === 'number' ? stat.value : parseInt(stat.value) || 0;
|
|
const color = getStatColor(value);
|
|
const abbr = stat.name.substring(0, 3).toUpperCase();
|
|
|
|
const $item = $(`<div class="rpg-strip-stat-item" title="${stat.name}: ${value}">
|
|
<span class="rpg-strip-stat-name">${abbr}</span>
|
|
<span class="rpg-strip-stat-value" style="color: ${color};">${value}</span>
|
|
</div>`);
|
|
$statsList.append($item);
|
|
});
|
|
|
|
if ($statsList.children().length > 0) {
|
|
$statsWidget.addClass('rpg-strip-widget-visible');
|
|
} else {
|
|
$statsWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
} else {
|
|
$statsWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
} else {
|
|
$statsWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
|
|
// Attributes Widget
|
|
const $attrsWidget = $container.find('.rpg-strip-widget-attributes');
|
|
if (widgetSettings.attributes?.enabled) {
|
|
const showRPGAttributes = extensionSettings.trackerConfig?.userStats?.showRPGAttributes !== false;
|
|
|
|
if (showRPGAttributes && extensionSettings.classicStats) {
|
|
// Get enabled attributes from trackerConfig
|
|
const configuredAttrs = extensionSettings.trackerConfig?.userStats?.rpgAttributes || [];
|
|
const enabledAttrIds = configuredAttrs.filter(a => a.enabled !== false).map(a => a.id);
|
|
|
|
const attrs = extensionSettings.classicStats;
|
|
const $attrsGrid = $attrsWidget.find('.rpg-strip-attributes-grid');
|
|
$attrsGrid.empty();
|
|
|
|
Object.entries(attrs).forEach(([key, value]) => {
|
|
// Filter by config if available
|
|
if (enabledAttrIds.length > 0 && !enabledAttrIds.includes(key.toLowerCase())) {
|
|
return;
|
|
}
|
|
|
|
const $item = $(`<div class="rpg-strip-attr-item" title="${key.toUpperCase()}: ${value}">
|
|
<span class="rpg-strip-attr-name">${key.toUpperCase()}</span>
|
|
<span class="rpg-strip-attr-value">${value}</span>
|
|
</div>`);
|
|
$attrsGrid.append($item);
|
|
});
|
|
|
|
if ($attrsGrid.children().length > 0) {
|
|
$attrsWidget.addClass('rpg-strip-widget-visible');
|
|
} else {
|
|
$attrsWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
} else {
|
|
$attrsWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
} else {
|
|
$attrsWidget.removeClass('rpg-strip-widget-visible');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets a color interpolated between low and high based on stat value (0-100).
|
|
* @param {number} value - The stat value (0-100)
|
|
* @returns {string} CSS color value
|
|
*/
|
|
function getStatColor(value) {
|
|
const lowColor = extensionSettings.statBarColorLow || '#cc3333';
|
|
const highColor = extensionSettings.statBarColorHigh || '#33cc66';
|
|
|
|
// Simple linear interpolation between low and high colors
|
|
const percent = Math.min(100, Math.max(0, value)) / 100;
|
|
|
|
// Parse colors
|
|
const lowRGB = hexToRgb(lowColor);
|
|
const highRGB = hexToRgb(highColor);
|
|
|
|
if (!lowRGB || !highRGB) return value > 50 ? highColor : lowColor;
|
|
|
|
const r = Math.round(lowRGB.r + (highRGB.r - lowRGB.r) * percent);
|
|
const g = Math.round(lowRGB.g + (highRGB.g - lowRGB.g) * percent);
|
|
const b = Math.round(lowRGB.b + (highRGB.b - lowRGB.b) * percent);
|
|
|
|
return `rgb(${r}, ${g}, ${b})`;
|
|
}
|
|
|
|
/**
|
|
* Converts a hex color to RGB object.
|
|
* @param {string} hex - Hex color string (e.g., "#cc3333")
|
|
* @returns {{r: number, g: number, b: number}|null}
|
|
*/
|
|
function hexToRgb(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
return result ? {
|
|
r: parseInt(result[1], 16),
|
|
g: parseInt(result[2], 16),
|
|
b: parseInt(result[3], 16)
|
|
} : null;
|
|
}
|
|
|
|
/**
|
|
* Sets up desktop tab navigation for organizing content.
|
|
* Only runs on desktop viewports (>1000px).
|
|
* Creates two tabs: Status (Stats/Info/Thoughts) and Inventory.
|
|
*/
|
|
export function setupDesktopTabs() {
|
|
const isDesktop = window.innerWidth > 1000;
|
|
if (!isDesktop) return;
|
|
|
|
// Check if tabs already exist
|
|
if ($('.rpg-tabs-nav').length > 0) return;
|
|
|
|
const $contentBox = $('.rpg-content-box');
|
|
|
|
// Get existing sections
|
|
const $userStats = $('#rpg-user-stats');
|
|
const $infoBox = $('#rpg-info-box');
|
|
const $thoughts = $('#rpg-thoughts');
|
|
const $inventory = $('#rpg-inventory');
|
|
const $quests = $('#rpg-quests');
|
|
|
|
// If no sections exist, nothing to organize
|
|
if ($userStats.length === 0 && $infoBox.length === 0 && $thoughts.length === 0 && $inventory.length === 0 && $quests.length === 0) {
|
|
return;
|
|
}
|
|
|
|
// Build tab navigation dynamically based on enabled settings
|
|
const tabButtons = [];
|
|
const hasInventory = $inventory.length > 0 && extensionSettings.showInventory;
|
|
const hasQuests = $quests.length > 0 && extensionSettings.showQuests;
|
|
|
|
// Status tab (always present if any status content exists)
|
|
tabButtons.push(`
|
|
<button class="rpg-tab-btn active" data-tab="status">
|
|
<i class="fa-solid fa-chart-simple"></i>
|
|
<span data-i18n-key="global.status">Status</span>
|
|
</button>
|
|
`);
|
|
|
|
// Inventory tab (only if enabled in settings)
|
|
if (hasInventory) {
|
|
tabButtons.push(`
|
|
<button class="rpg-tab-btn" data-tab="inventory">
|
|
<i class="fa-solid fa-box"></i>
|
|
<span data-i18n-key="global.inventory">Inventory</span>
|
|
</button>
|
|
`);
|
|
}
|
|
|
|
// Quests tab (only if enabled in settings)
|
|
if (hasQuests) {
|
|
tabButtons.push(`
|
|
<button class="rpg-tab-btn" data-tab="quests">
|
|
<i class="fa-solid fa-scroll"></i>
|
|
<span data-i18n-key="global.quests">Quests</span>
|
|
</button>
|
|
`);
|
|
}
|
|
|
|
const $tabNav = $(`<div class="rpg-tabs-nav">${tabButtons.join('')}</div>`);
|
|
|
|
// Create tab content containers
|
|
const $statusTab = $('<div class="rpg-tab-content active" data-tab-content="status"></div>');
|
|
const $inventoryTab = $('<div class="rpg-tab-content" data-tab-content="inventory"></div>');
|
|
const $questsTab = $('<div class="rpg-tab-content" data-tab-content="quests"></div>');
|
|
|
|
// Move sections into their respective tabs (detach to preserve event handlers)
|
|
if ($userStats.length > 0) {
|
|
$statusTab.append($userStats.detach());
|
|
if (extensionSettings.showUserStats) $userStats.show();
|
|
}
|
|
if ($infoBox.length > 0) {
|
|
$statusTab.append($infoBox.detach());
|
|
// Only show if enabled and has data
|
|
if (extensionSettings.showInfoBox) {
|
|
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
|
|
if (infoBoxData) $infoBox.show();
|
|
}
|
|
}
|
|
if ($thoughts.length > 0) {
|
|
$statusTab.append($thoughts.detach());
|
|
if (extensionSettings.showCharacterThoughts) $thoughts.show();
|
|
}
|
|
if ($inventory.length > 0) {
|
|
$inventoryTab.append($inventory.detach());
|
|
// Only show if enabled (will be part of tab structure)
|
|
if (hasInventory) $inventory.show();
|
|
}
|
|
if ($quests.length > 0) {
|
|
$questsTab.append($quests.detach());
|
|
// Only show if enabled (will be part of tab structure)
|
|
if (hasQuests) $quests.show();
|
|
}
|
|
|
|
// Hide dividers on desktop tabs (tabs separate content naturally)
|
|
$('.rpg-divider').hide();
|
|
|
|
// Build desktop tab structure
|
|
const $tabsContainer = $('<div class="rpg-tabs-container"></div>');
|
|
$tabsContainer.append($tabNav);
|
|
$tabsContainer.append($statusTab);
|
|
|
|
// Always append inventory and quests tabs to preserve the elements
|
|
// But they'll only show if enabled (via tab button visibility)
|
|
$tabsContainer.append($inventoryTab);
|
|
$tabsContainer.append($questsTab);
|
|
|
|
// Replace content box with tabs container
|
|
$contentBox.html('').append($tabsContainer);
|
|
i18n.applyTranslations($tabsContainer[0]);
|
|
|
|
// Handle tab switching
|
|
$tabNav.find('.rpg-tab-btn').on('click', function() {
|
|
const tabName = $(this).data('tab');
|
|
|
|
// Update active tab button
|
|
$tabNav.find('.rpg-tab-btn').removeClass('active');
|
|
$(this).addClass('active');
|
|
|
|
// Update active tab content
|
|
$('.rpg-tab-content').removeClass('active');
|
|
$(`.rpg-tab-content[data-tab-content="${tabName}"]`).addClass('active');
|
|
});
|
|
|
|
|
|
}
|
|
|
|
/**
|
|
* Removes desktop tab navigation and restores original layout.
|
|
* Used when transitioning from desktop to mobile.
|
|
*/
|
|
export function removeDesktopTabs() {
|
|
// Get sections from tabs before removing
|
|
const $userStats = $('#rpg-user-stats').detach();
|
|
const $infoBox = $('#rpg-info-box').detach();
|
|
const $thoughts = $('#rpg-thoughts').detach();
|
|
const $inventory = $('#rpg-inventory').detach();
|
|
const $quests = $('#rpg-quests').detach();
|
|
|
|
// Remove tabs container
|
|
$('.rpg-tabs-container').remove();
|
|
|
|
// Get dividers
|
|
const $dividerStats = $('#rpg-divider-stats');
|
|
const $dividerInfo = $('#rpg-divider-info');
|
|
const $dividerThoughts = $('#rpg-divider-thoughts');
|
|
|
|
// Restore original sections to content box in correct order
|
|
const $contentBox = $('.rpg-content-box');
|
|
|
|
// Re-insert sections in original order: User Stats, Info Box, Thoughts, Inventory, Quests
|
|
if ($dividerStats.length) {
|
|
$dividerStats.before($userStats);
|
|
$dividerInfo.before($infoBox);
|
|
$dividerThoughts.before($thoughts);
|
|
$contentBox.append($inventory);
|
|
$contentBox.append($quests);
|
|
} else {
|
|
// Fallback if dividers don't exist
|
|
$contentBox.append($userStats);
|
|
$contentBox.append($infoBox);
|
|
$contentBox.append($thoughts);
|
|
$contentBox.append($inventory);
|
|
$contentBox.append($quests);
|
|
}
|
|
|
|
// Show/hide sections based on settings (respect visibility settings)
|
|
if (extensionSettings.showUserStats) $userStats.show();
|
|
if (extensionSettings.showInfoBox) {
|
|
const infoBoxData = window.lastGeneratedData?.infoBox || window.committedTrackerData?.infoBox;
|
|
if (infoBoxData) $infoBox.show();
|
|
}
|
|
if (extensionSettings.showCharacterThoughts) $thoughts.show();
|
|
if (extensionSettings.showInventory) $inventory.show();
|
|
if (extensionSettings.showQuests) $quests.show();
|
|
$('.rpg-divider').show();
|
|
}
|