feat(dashboard): redesign Scene Info widget with compact grid layout
Complete redesign of Scene Info widget following UX best practices: BEFORE: - Tab-based interface with 5 separate views - Only 1 data point visible at a time (poor scannability) - Size: 2×3 (oversized, wasted vertical space) - Didn't fit in desktop side panel - Poor information density AFTER: - Grid-based layout showing all 5 data points simultaneously - High information density and scannability - Compact size: 2×2 (reduced from 2×3) - Inspired by Apple Widgets / Material Design patterns - Mobile-responsive with breakpoints at 1000px and 340px - Zero interaction needed - all data visible at once Changes: - sceneInfoWidget.js: Complete rewrite (390→309 lines) - Removed tab logic and state management - Added data formatting helpers (formatDate, formatTime, etc.) - Grid HTML structure with semantic CSS classes - Maintained inline editing for all fields - Simplified configuration - style.css: Added comprehensive grid styling (lines 2647-2811) - CSS Grid layout with named areas - Responsive typography and spacing - Hover states and focus styles - 2 mobile breakpoints for optimal scaling - defaultLayout.js: Updated Scene Info widget - Changed height: 3→2 rows - Adjusted Y positions for widgets below - Simplified config (removed view selection) Design Principles: - All information visible simultaneously (zero interaction) - High scannability for quick information gathering - Proper information density for simple data points - Grid structure: 2 columns, 3 rows (location full-width header) - Mobile-first responsive design Layout: ┌─────────────────────────────────┐ │ 📍 Location │ ├──────────────────┬──────────────┤ │ 📅 Date │ 🕐 Time │ ├──────────────────┼──────────────┤ │ 🌤️ Weather │ 🌡️ Temp │ └──────────────────┴──────────────┘
This commit is contained in:
@@ -89,38 +89,34 @@ export function generateDefaultDashboard() {
|
|||||||
icon: 'fa-solid fa-map',
|
icon: 'fa-solid fa-map',
|
||||||
order: 1,
|
order: 1,
|
||||||
widgets: [
|
widgets: [
|
||||||
// Row 0-2: Scene Info (combined: calendar, weather, temp, clock, location)
|
// Row 0-1: Scene Info (combined: calendar, weather, temp, clock, location)
|
||||||
{
|
{
|
||||||
id: 'widget-sceneinfo',
|
id: 'widget-sceneinfo',
|
||||||
type: 'sceneInfo',
|
type: 'sceneInfo',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
w: 2,
|
w: 2,
|
||||||
h: 3,
|
h: 2,
|
||||||
config: {
|
config: {}
|
||||||
views: ['calendar', 'weather', 'temperature', 'clock', 'location'],
|
|
||||||
defaultView: 'calendar',
|
|
||||||
showEmptyViews: false
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// Row 3-4: Recent Events (notebook style, full width)
|
// Row 2-3: Recent Events (notebook style, full width)
|
||||||
{
|
{
|
||||||
id: 'widget-recentevents',
|
id: 'widget-recentevents',
|
||||||
type: 'recentEvents',
|
type: 'recentEvents',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 3,
|
y: 2,
|
||||||
w: 2,
|
w: 2,
|
||||||
h: 2,
|
h: 2,
|
||||||
config: {
|
config: {
|
||||||
maxEvents: 3
|
maxEvents: 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Row 5-8: Present Characters (full width, will expand with auto-layout)
|
// Row 4-7: Present Characters (full width, will expand with auto-layout)
|
||||||
{
|
{
|
||||||
id: 'widget-presentchars',
|
id: 'widget-presentchars',
|
||||||
type: 'presentCharacters',
|
type: 'presentCharacters',
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 5,
|
y: 4,
|
||||||
w: 2,
|
w: 2,
|
||||||
h: 4,
|
h: 4,
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@@ -1,186 +1,231 @@
|
|||||||
/**
|
/**
|
||||||
* Scene Info Multi-View Widget
|
* Scene Info Grid Widget
|
||||||
*
|
*
|
||||||
* Combines Calendar, Weather, Temperature, Clock, and Location widgets into one
|
* Displays calendar, weather, temperature, clock, and location in a compact
|
||||||
* tabbed interface to reduce vertical scroll on mobile.
|
* information-dense grid layout. All data points visible at once for maximum
|
||||||
|
* scannability.
|
||||||
*
|
*
|
||||||
* Features:
|
* Design: 2-column grid with location header + 4 data cards
|
||||||
* - Tab switching between different scene info views
|
* Inspiration: Apple Widgets, Material Design, modern dashboard patterns
|
||||||
* - Reuses existing infoBox widget render functions (no code duplication)
|
|
||||||
* - Smart empty state detection (hides tabs for widgets with no data)
|
|
||||||
* - Configurable view selection
|
|
||||||
* - Per-instance state management
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseInfoBoxData } from './infoBoxWidgets.js';
|
import { parseInfoBoxData } from './infoBoxWidgets.js';
|
||||||
|
|
||||||
// Per-widget instance state
|
/**
|
||||||
const widgetStates = new Map();
|
* Format date for display
|
||||||
|
* @param {string} date - Date value
|
||||||
|
* @param {string} month - Month name
|
||||||
|
* @param {string} weekday - Weekday name
|
||||||
|
* @returns {Object} Formatted date parts
|
||||||
|
*/
|
||||||
|
function formatDate(date, month, weekday) {
|
||||||
|
if (!date && !month && !weekday) {
|
||||||
|
return { value: 'No Date', label: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthShort = month ? month.substring(0, 3).toUpperCase() : 'MON';
|
||||||
|
const dayNum = date || '1';
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: `${monthShort} ${dayNum}`,
|
||||||
|
label: weekday ? weekday.substring(0, 3) : ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get or create widget state
|
* Format time for display
|
||||||
* @param {string} widgetId - Widget instance ID
|
* @param {string} timeStart - Start time
|
||||||
* @returns {Object} Widget state
|
* @param {string} timeEnd - End time
|
||||||
|
* @returns {Object} Formatted time parts
|
||||||
*/
|
*/
|
||||||
function getWidgetState(widgetId) {
|
function formatTime(timeStart, timeEnd) {
|
||||||
if (!widgetStates.has(widgetId)) {
|
const timeDisplay = timeEnd || timeStart || '12:00';
|
||||||
widgetStates.set(widgetId, {
|
|
||||||
activeSubTab: 'calendar' // Default view
|
return {
|
||||||
|
value: timeDisplay,
|
||||||
|
label: '' // Could add timezone if available
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format weather for display
|
||||||
|
* @param {string} weatherEmoji - Weather emoji
|
||||||
|
* @param {string} weatherForecast - Weather description
|
||||||
|
* @returns {Object} Formatted weather parts
|
||||||
|
*/
|
||||||
|
function formatWeather(weatherEmoji, weatherForecast) {
|
||||||
|
const emoji = weatherEmoji || '🌤️';
|
||||||
|
const forecast = weatherForecast || 'Clear';
|
||||||
|
|
||||||
|
return {
|
||||||
|
icon: emoji,
|
||||||
|
value: forecast.split(' ')[0] || forecast, // First word
|
||||||
|
label: forecast
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format temperature for display
|
||||||
|
* @param {string} temperature - Temperature value
|
||||||
|
* @returns {Object} Formatted temperature parts
|
||||||
|
*/
|
||||||
|
function formatTemp(temperature) {
|
||||||
|
if (!temperature) {
|
||||||
|
return { value: '20°C', label: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: temperature,
|
||||||
|
label: '' // Could add "Feels like" if available
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format location for display
|
||||||
|
* @param {string} location - Location name
|
||||||
|
* @returns {Object} Formatted location parts
|
||||||
|
*/
|
||||||
|
function formatLocation(location) {
|
||||||
|
if (!location || location === 'Location') {
|
||||||
|
return { value: 'No Location', label: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split on comma or dash for secondary text
|
||||||
|
const parts = location.split(/[,\-]/);
|
||||||
|
return {
|
||||||
|
value: parts[0].trim(),
|
||||||
|
label: parts.slice(1).join(', ').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render info grid item
|
||||||
|
* @param {Object} item - Item data
|
||||||
|
* @param {string} item.icon - Icon emoji
|
||||||
|
* @param {string} item.value - Primary value
|
||||||
|
* @param {string} item.label - Secondary label
|
||||||
|
* @param {string} field - Field name for editing
|
||||||
|
* @param {string} gridArea - CSS grid area name
|
||||||
|
* @returns {string} HTML for grid item
|
||||||
|
*/
|
||||||
|
function renderInfoItem(item, field, gridArea) {
|
||||||
|
const hasLabel = item.label && item.label !== '';
|
||||||
|
const areaClass = gridArea ? `rpg-info-${gridArea}` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rpg-info-item ${areaClass}" data-field="${field}">
|
||||||
|
<span class="item-icon">${item.icon}</span>
|
||||||
|
<div class="item-content">
|
||||||
|
<span class="item-value rpg-editable" contenteditable="true" data-field="${field}" title="Click to edit">${item.value}</span>
|
||||||
|
${hasLabel ? `<span class="item-label">${item.label}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render location header (full width)
|
||||||
|
* @param {Object} location - Location data
|
||||||
|
* @returns {string} HTML for location header
|
||||||
|
*/
|
||||||
|
function renderLocationHeader(location) {
|
||||||
|
const hasDescription = location.label && location.label !== '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="rpg-info-item rpg-info-location" data-field="location">
|
||||||
|
<span class="item-icon">📍</span>
|
||||||
|
<div class="item-content">
|
||||||
|
<span class="item-value rpg-editable" contenteditable="true" data-field="location" title="Click to edit">${location.value}</span>
|
||||||
|
${hasDescription ? `<span class="item-label">${location.label}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach edit handlers to editable fields
|
||||||
|
* @param {HTMLElement} container - Widget container
|
||||||
|
* @param {Object} dependencies - Widget dependencies
|
||||||
|
*/
|
||||||
|
function attachEditHandlers(container, dependencies) {
|
||||||
|
const editableFields = container.querySelectorAll('.rpg-editable');
|
||||||
|
|
||||||
|
editableFields.forEach(field => {
|
||||||
|
const fieldName = field.dataset.field;
|
||||||
|
let originalValue = field.textContent.trim();
|
||||||
|
|
||||||
|
field.addEventListener('focus', () => {
|
||||||
|
originalValue = field.textContent.trim();
|
||||||
|
|
||||||
|
// Select all text on focus
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(field);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
});
|
||||||
|
|
||||||
|
field.addEventListener('blur', () => {
|
||||||
|
const value = field.textContent.trim();
|
||||||
|
if (value && value !== originalValue) {
|
||||||
|
updateInfoBoxField(dependencies, 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return widgetStates.get(widgetId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View metadata (icons, labels, etc.)
|
* Update info box field in shared data
|
||||||
|
* @param {Object} dependencies - Widget dependencies
|
||||||
|
* @param {string} field - Field name
|
||||||
|
* @param {string} value - New value
|
||||||
*/
|
*/
|
||||||
const VIEW_META = {
|
function updateInfoBoxField(dependencies, field, value) {
|
||||||
calendar: { icon: '📅', label: 'Cal', fullLabel: 'Calendar' },
|
const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies;
|
||||||
weather: { icon: '🌤️', label: 'Wea', fullLabel: 'Weather' },
|
let infoBoxData = getInfoBoxData() || '';
|
||||||
temperature: { icon: '🌡️', label: 'Tmp', fullLabel: 'Temperature' },
|
|
||||||
clock: { icon: '🕐', label: 'Clk', fullLabel: 'Clock' },
|
// Simple replace for now - could be more sophisticated
|
||||||
location: { icon: '📍', label: 'Loc', fullLabel: 'Location' }
|
const fieldMap = {
|
||||||
|
'date': /Date: [^\n]+/,
|
||||||
|
'time': /Time: [^\n]+/,
|
||||||
|
'weather': /Weather: [^\n]+/,
|
||||||
|
'temperature': /Temperature: [^\n]+/,
|
||||||
|
'location': /Location: [^\n]+/
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
const pattern = fieldMap[field];
|
||||||
* Check if a view has data
|
if (pattern) {
|
||||||
* @param {string} viewType - Widget type (calendar, weather, etc.)
|
const replacement = `${field.charAt(0).toUpperCase() + field.slice(1)}: ${value}`;
|
||||||
* @param {Object} data - Parsed info box data
|
if (pattern.test(infoBoxData)) {
|
||||||
* @returns {boolean} True if view has data
|
infoBoxData = infoBoxData.replace(pattern, replacement);
|
||||||
*/
|
} else {
|
||||||
function hasViewData(viewType, data) {
|
infoBoxData += `\n${replacement}`;
|
||||||
switch (viewType) {
|
|
||||||
case 'calendar':
|
|
||||||
return !!(data.date && data.date !== '');
|
|
||||||
case 'weather':
|
|
||||||
return !!(data.weatherEmoji || data.weatherForecast);
|
|
||||||
case 'temperature':
|
|
||||||
return !!(data.temperature && data.temperature !== '');
|
|
||||||
case 'clock':
|
|
||||||
return !!(data.timeStart || data.timeEnd);
|
|
||||||
case 'location':
|
|
||||||
return !!(data.location && data.location !== 'Location' && data.location !== '');
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
setInfoBoxData(infoBoxData);
|
||||||
* Filter views based on data availability
|
if (onDataChange) {
|
||||||
* @param {Array<string>} views - List of view types
|
onDataChange('infoBox', field, value);
|
||||||
* @param {Object} data - Parsed info box data
|
|
||||||
* @param {Object} config - Widget configuration
|
|
||||||
* @returns {Array<string>} Filtered views
|
|
||||||
*/
|
|
||||||
function filterEmptyViews(views, data, config) {
|
|
||||||
if (config.showEmptyViews) {
|
|
||||||
return views;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return views.filter(viewType => hasViewData(viewType, data));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Render tab bar
|
|
||||||
* @param {Array<string>} views - List of view types
|
|
||||||
* @param {string} activeView - Currently active view
|
|
||||||
* @returns {string} Tab bar HTML
|
|
||||||
*/
|
|
||||||
function renderViewTabs(views, activeView) {
|
|
||||||
if (views.length === 0) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="rpg-inventory-subtabs">
|
|
||||||
${views.map(viewType => {
|
|
||||||
const meta = VIEW_META[viewType] || { icon: '📄', label: viewType };
|
|
||||||
const isActive = activeView === viewType;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<button class="rpg-inventory-subtab ${isActive ? 'active' : ''}"
|
|
||||||
data-tab="${viewType}"
|
|
||||||
title="${meta.fullLabel}"
|
|
||||||
aria-label="Switch to ${meta.fullLabel}">
|
|
||||||
<span style="font-size: 1.2rem;">${meta.icon}</span>
|
|
||||||
<span class="rpg-subtab-label">${meta.label}</span>
|
|
||||||
</button>
|
|
||||||
`;
|
|
||||||
}).join('')}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render all views (hidden initially, toggle visibility)
|
|
||||||
* @param {Array<string>} views - List of view types
|
|
||||||
* @param {string} activeView - Currently active view
|
|
||||||
* @param {Object} registry - Widget registry
|
|
||||||
* @param {Object} dependencies - Widget dependencies
|
|
||||||
* @returns {string} Views container HTML
|
|
||||||
*/
|
|
||||||
function renderAllViews(views, activeView, registry, dependencies) {
|
|
||||||
const viewsHtml = views.map(viewType => {
|
|
||||||
const widgetDef = registry.get(viewType);
|
|
||||||
if (!widgetDef) {
|
|
||||||
console.warn(`[SceneInfoWidget] Widget type "${viewType}" not found in registry`);
|
|
||||||
return `
|
|
||||||
<div class="rpg-scene-info-view" data-view="${viewType}" style="display: none;">
|
|
||||||
<div class="rpg-scene-empty">Widget "${viewType}" not available</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temporary container for widget render
|
|
||||||
const tempContainer = document.createElement('div');
|
|
||||||
tempContainer.className = 'rpg-scene-info-view';
|
|
||||||
tempContainer.dataset.view = viewType;
|
|
||||||
tempContainer.style.display = viewType === activeView ? 'block' : 'none';
|
|
||||||
|
|
||||||
// Call existing widget's render function
|
|
||||||
try {
|
|
||||||
widgetDef.render(tempContainer, {});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[SceneInfoWidget] Error rendering ${viewType}:`, error);
|
|
||||||
tempContainer.innerHTML = `<div class="rpg-scene-empty">Error rendering ${viewType}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tempContainer.outerHTML;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
return `<div class="rpg-scene-info-views">${viewsHtml}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach tab switching event handlers
|
|
||||||
* @param {HTMLElement} container - Widget container
|
|
||||||
* @param {string} widgetId - Widget instance ID
|
|
||||||
*/
|
|
||||||
function attachTabHandlers(container, widgetId) {
|
|
||||||
const widget = container.querySelector('.rpg-scene-info-widget');
|
|
||||||
if (!widget) return;
|
|
||||||
|
|
||||||
const state = getWidgetState(widgetId);
|
|
||||||
|
|
||||||
// Tab click handlers
|
|
||||||
widget.querySelectorAll('.rpg-inventory-subtab').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const tab = btn.dataset.tab;
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
state.activeSubTab = tab;
|
|
||||||
|
|
||||||
// Toggle view visibility
|
|
||||||
widget.querySelectorAll('.rpg-scene-info-view').forEach(view => {
|
|
||||||
view.style.display = view.dataset.view === tab ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update active tab styling
|
|
||||||
widget.querySelectorAll('.rpg-inventory-subtab').forEach(b =>
|
|
||||||
b.classList.remove('active'));
|
|
||||||
btn.classList.add('active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -190,11 +235,11 @@ export function registerSceneInfoWidget(registry, dependencies) {
|
|||||||
registry.register('sceneInfo', {
|
registry.register('sceneInfo', {
|
||||||
name: 'Scene Info',
|
name: 'Scene Info',
|
||||||
icon: '🗺️',
|
icon: '🗺️',
|
||||||
description: 'Multi-view scene information (calendar, weather, time, location)',
|
description: 'Compact scene information grid (calendar, weather, time, location)',
|
||||||
category: 'scene',
|
category: 'scene',
|
||||||
minSize: { w: 2, h: 2 },
|
minSize: { w: 2, h: 2 },
|
||||||
defaultSize: { w: 2, h: 3 },
|
defaultSize: { w: 2, h: 2 },
|
||||||
maxAutoSize: { w: 2, h: 4 },
|
maxAutoSize: { w: 2, h: 3 },
|
||||||
requiresSchema: false,
|
requiresSchema: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -203,53 +248,31 @@ export function registerSceneInfoWidget(registry, dependencies) {
|
|||||||
* @param {Object} config - Widget configuration
|
* @param {Object} config - Widget configuration
|
||||||
*/
|
*/
|
||||||
render(container, config = {}) {
|
render(container, config = {}) {
|
||||||
// Get widget ID from parent element
|
|
||||||
const widgetElement = container.closest('.rpg-widget');
|
|
||||||
const widgetId = widgetElement?.dataset?.widgetId || 'scene-info-default';
|
|
||||||
|
|
||||||
// Get or create widget state
|
|
||||||
const state = getWidgetState(widgetId);
|
|
||||||
|
|
||||||
// Default configuration
|
|
||||||
const defaultViews = ['calendar', 'weather', 'temperature', 'clock', 'location'];
|
|
||||||
const views = config.views || defaultViews;
|
|
||||||
|
|
||||||
// Get data and filter empty views
|
|
||||||
const { getInfoBoxData } = dependencies;
|
const { getInfoBoxData } = dependencies;
|
||||||
const data = parseInfoBoxData(getInfoBoxData());
|
const data = parseInfoBoxData(getInfoBoxData());
|
||||||
const availableViews = filterEmptyViews(views, data, config);
|
|
||||||
|
|
||||||
// Handle case where no views are available
|
// Format data for display
|
||||||
if (availableViews.length === 0) {
|
const date = formatDate(data.date, data.month, data.weekday);
|
||||||
container.innerHTML = `
|
const time = formatTime(data.timeStart, data.timeEnd);
|
||||||
<div class="rpg-dashboard-widget">
|
const weather = formatWeather(data.weatherEmoji, data.weatherForecast);
|
||||||
<div class="rpg-scene-empty" style="padding: 1rem; text-align: center; color: var(--rpg-text); opacity: 0.6;">
|
const temp = formatTemp(data.temperature);
|
||||||
No scene information available
|
const location = formatLocation(data.location);
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure active tab is valid
|
// Build grid HTML
|
||||||
if (!availableViews.includes(state.activeSubTab)) {
|
|
||||||
state.activeSubTab = config.defaultView || availableViews[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render widget HTML
|
|
||||||
const html = `
|
const html = `
|
||||||
<div class="rpg-dashboard-widget">
|
<div class="rpg-dashboard-widget">
|
||||||
<div class="rpg-scene-info-widget" data-widget-id="${widgetId}">
|
<div class="rpg-scene-info-grid">
|
||||||
${renderViewTabs(availableViews, state.activeSubTab)}
|
${renderLocationHeader(location)}
|
||||||
${renderAllViews(availableViews, state.activeSubTab, registry, dependencies)}
|
${renderInfoItem({ icon: '📅', value: date.value, label: date.label }, 'date', 'calendar')}
|
||||||
|
${renderInfoItem({ icon: '🕐', value: time.value, label: time.label }, 'time', 'clock')}
|
||||||
|
${renderInfoItem({ icon: weather.icon, value: weather.value, label: weather.label }, 'weather', 'weather')}
|
||||||
|
${renderInfoItem({ icon: '🌡️', value: temp.value, label: temp.label }, 'temperature', 'temperature')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
|
attachEditHandlers(container, dependencies);
|
||||||
// Attach event handlers
|
|
||||||
attachTabHandlers(container, widgetId);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,37 +281,17 @@ export function registerSceneInfoWidget(registry, dependencies) {
|
|||||||
*/
|
*/
|
||||||
getConfig() {
|
getConfig() {
|
||||||
return {
|
return {
|
||||||
views: {
|
showLabels: {
|
||||||
type: 'multiselect',
|
|
||||||
label: 'Visible Views',
|
|
||||||
default: ['calendar', 'weather', 'temperature', 'clock', 'location'],
|
|
||||||
options: [
|
|
||||||
{ value: 'calendar', label: 'Calendar' },
|
|
||||||
{ value: 'weather', label: 'Weather' },
|
|
||||||
{ value: 'temperature', label: 'Temperature' },
|
|
||||||
{ value: 'clock', label: 'Clock' },
|
|
||||||
{ value: 'location', label: 'Location' }
|
|
||||||
],
|
|
||||||
description: 'Select which views to show in the widget'
|
|
||||||
},
|
|
||||||
defaultView: {
|
|
||||||
type: 'select',
|
|
||||||
label: 'Default View',
|
|
||||||
default: 'calendar',
|
|
||||||
options: [
|
|
||||||
{ value: 'calendar', label: 'Calendar' },
|
|
||||||
{ value: 'weather', label: 'Weather' },
|
|
||||||
{ value: 'temperature', label: 'Temperature' },
|
|
||||||
{ value: 'clock', label: 'Clock' },
|
|
||||||
{ value: 'location', label: 'Location' }
|
|
||||||
],
|
|
||||||
description: 'Which view to show by default'
|
|
||||||
},
|
|
||||||
showEmptyViews: {
|
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Show Empty Views',
|
label: 'Show Secondary Labels',
|
||||||
|
default: true,
|
||||||
|
description: 'Show secondary text (weekday, timezone, etc.)'
|
||||||
|
},
|
||||||
|
compactMode: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Compact Mode',
|
||||||
default: false,
|
default: false,
|
||||||
description: 'Show tabs even when they have no data'
|
description: 'Reduce padding and font sizes'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2644,6 +2644,172 @@ body:has(.rpg-panel.rpg-position-left) #sheld {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
Scene Info Grid Widget
|
||||||
|
Compact information-dense layout showing all scene data at once
|
||||||
|
============================================================================ */
|
||||||
|
|
||||||
|
.rpg-scene-info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: auto 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
grid-template-areas:
|
||||||
|
"location location"
|
||||||
|
"calendar clock"
|
||||||
|
"weather temperature";
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-info-item {
|
||||||
|
background: var(--rpg-panel);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-info-item:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid area assignments */
|
||||||
|
.rpg-info-location {
|
||||||
|
grid-area: location;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-info-calendar { grid-area: calendar; }
|
||||||
|
.rpg-info-clock { grid-area: clock; }
|
||||||
|
.rpg-info-weather { grid-area: weather; }
|
||||||
|
.rpg-info-temperature { grid-area: temperature; }
|
||||||
|
|
||||||
|
/* Icon styling */
|
||||||
|
.rpg-info-item .item-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content layout */
|
||||||
|
.rpg-info-item .item-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0; /* Prevent overflow */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary value (large, bold) */
|
||||||
|
.rpg-info-item .item-value {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--rpg-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary label (small, subdued) */
|
||||||
|
.rpg-info-item .item-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--rpg-text);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Location-specific styling */
|
||||||
|
.rpg-info-location .item-value {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-info-location .item-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: -0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Editable field styling */
|
||||||
|
.rpg-info-item .rpg-editable {
|
||||||
|
cursor: text;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
margin: -0.15rem -0.3rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-info-item .rpg-editable:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-info-item .rpg-editable:focus {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
outline: 1px solid var(--rpg-highlight);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive (max-width: 1000px) */
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.rpg-widget .rpg-scene-info-grid {
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item {
|
||||||
|
padding: 0.6rem !important;
|
||||||
|
gap: 0.6rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item .item-icon {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item .item-value {
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item .item-label {
|
||||||
|
font-size: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-location .item-value {
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-location .item-label {
|
||||||
|
font-size: 0.8rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra small mobile (max-width: 340px) */
|
||||||
|
@media (max-width: 340px) {
|
||||||
|
.rpg-widget .rpg-scene-info-grid {
|
||||||
|
gap: 0.4rem !important;
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item {
|
||||||
|
padding: 0.5rem !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item .item-icon {
|
||||||
|
font-size: 1.1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item .item-value {
|
||||||
|
font-size: 0.9rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rpg-widget .rpg-info-item .item-label {
|
||||||
|
font-size: 0.7rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Character Status Cards */
|
/* Character Status Cards */
|
||||||
.rpg-character-status {
|
.rpg-character-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user