From 9f92c4af87f3e2756b07808581506c94feaa792b Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 30 Oct 2025 08:40:46 +1100 Subject: [PATCH] feat(dashboard): add quest widget + fix 4-tab header layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quest Widget Integration: - Created questsWidget.js with Main/Optional quest sub-tabs - Added dedicated Quests tab (4th tab after Inventory) - Registered quest widget in dashboardIntegration.js - Widget features: inline editing, add/remove quests, contenteditable - Fixed tab switching to use inline re-rendering (not full widget render) Header Layout Fixes (4+ Tabs): - Changed header flex-wrap from wrap to nowrap (prevents button wrapping) - Added icon-only mode for 4+ tabs (disables hover expansion) - Tab count detection in renderTabs() adds rpg-tabs-icon-only class - Prevents layout breaking when tabs expand on hover Technical Details: - Quest widget follows inventory widget pattern (sub-tabs, per-instance state) - Split event handlers: attachQuestHandlers (tabs) + attachQuestContentHandlers (buttons) - Tab switching updates innerHTML inline and re-attaches content handlers - Default size: 2w × 5h, category: 'scene' Benefits: - Quest tracking fully integrated with Dashboard v2 drag/drop - No header wrapping issues with 4 tabs - Cleaner icon-only UX when space is constrained - Horizontal scrolling handles overflow gracefully --- src/systems/dashboard/dashboardIntegration.js | 4 + src/systems/dashboard/dashboardManager.js | 7 + src/systems/dashboard/defaultLayout.js | 20 + src/systems/dashboard/widgets/questsWidget.js | 441 ++++++++++++++++++ style.css | 7 +- 5 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 src/systems/dashboard/widgets/questsWidget.js diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js index 749bf8d..5e77321 100644 --- a/src/systems/dashboard/dashboardIntegration.js +++ b/src/systems/dashboard/dashboardIntegration.js @@ -24,6 +24,7 @@ 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'; +import { registerQuestsWidget } from './widgets/questsWidget.js'; // Global dashboard manager instance let dashboardManager = null; @@ -215,6 +216,9 @@ function registerAllWidgets(registry, dependencies) { // Inventory widget registerInventoryWidget(registry, dependencies); + // Quest widget + registerQuestsWidget(registry, dependencies); + console.log(`[RPG Companion] Registered ${registry.getAll().length} widgets`); } diff --git a/src/systems/dashboard/dashboardManager.js b/src/systems/dashboard/dashboardManager.js index 772db81..2ccb46d 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -405,6 +405,13 @@ export class DashboardManager { this.tabContainer.appendChild(button); }); + // Icon-only mode when 4+ tabs to prevent header wrapping on hover + if (tabs.length > 3) { + this.tabContainer.classList.add('rpg-tabs-icon-only'); + } else { + this.tabContainer.classList.remove('rpg-tabs-icon-only'); + } + console.log(`[DashboardManager] Rendered ${tabs.length} tabs`); } diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js index ffc66ac..d2ab2c7 100644 --- a/src/systems/dashboard/defaultLayout.js +++ b/src/systems/dashboard/defaultLayout.js @@ -178,6 +178,26 @@ export function generateDefaultDashboard() { } } ] + }, + // Tab 4: Quests (Full tab for quest system) + { + id: 'tab-quests', + name: 'Quests', + icon: 'fa-solid fa-scroll', + order: 3, + widgets: [ + { + id: 'widget-quests', + type: 'quests', + x: 0, + y: 0, + w: 2, + h: 5, + config: { + defaultSubTab: 'main' + } + } + ] } ], diff --git a/src/systems/dashboard/widgets/questsWidget.js b/src/systems/dashboard/widgets/questsWidget.js new file mode 100644 index 0000000..f944665 --- /dev/null +++ b/src/systems/dashboard/widgets/questsWidget.js @@ -0,0 +1,441 @@ +/** + * Quests Widget + * + * Quest tracking system with two sub-tabs: + * - Main Quest: Single primary objective + * - Optional Quests: Multiple side objectives + * + * Features: + * - Add/edit/remove quests + * - Inline editing for quest titles + * - Sub-tab navigation + */ + +import { showAlertDialog } from '../confirmDialog.js'; + +/** + * Escape HTML to prevent XSS + */ +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Renders the quests sub-tab navigation + */ +function renderQuestsSubTabs(activeTab = 'main') { + return ` +
+ + +
+ `; +} + +/** + * Renders the main quest view + */ +function renderMainQuestView(mainQuest) { + const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : ''; + const hasQuest = questDisplay.length > 0; + + return ` +
+
+

Main Quest

+ ${!hasQuest ? `` : ''} +
+
+ ${hasQuest ? ` + +
+
${escapeHtml(questDisplay)}
+
+ + +
+
+ ` : ` + +
No active main quest
+ `} +
+
+ + The main quest represents your primary objective in the story. +
+
+ `; +} + +/** + * Renders the optional quests view + */ +function renderOptionalQuestsView(optionalQuests) { + const quests = optionalQuests.filter(q => q && q !== 'None'); + + let questsHtml = ''; + if (quests.length === 0) { + questsHtml = '
No active optional quests
'; + } else { + questsHtml = quests.map((quest, index) => ` +
+
${escapeHtml(quest)}
+
+ +
+
+ `).join(''); + } + + return ` +
+
+

Optional Quests

+ +
+
+ +
+ ${questsHtml} +
+
+
+ + Optional quests are side objectives that complement your main story. +
+
+ `; +} + +/** + * Attach handlers for quest content (buttons, inputs) + * Separated so it can be re-attached after tab switching + */ +function attachQuestContentHandlers(container, widgetId, state, dependencies) { + const { getExtensionSettings, onDataChange } = dependencies; + const widgetContainer = container.querySelector('.rpg-quests-widget'); + + if (!widgetContainer) return; + + // Add quest button + widgetContainer.querySelectorAll('[data-action="add-quest"]').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.field; + const form = widgetContainer.querySelector(`#rpg-add-quest-form-${field}`); + const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`); + if (form) form.style.display = 'block'; + if (input) input.focus(); + }); + }); + + // Cancel add quest + widgetContainer.querySelectorAll('[data-action="cancel-add-quest"]').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.field; + const form = widgetContainer.querySelector(`#rpg-add-quest-form-${field}`); + const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`); + if (form) form.style.display = 'none'; + if (input) input.value = ''; + }); + }); + + // Save add quest + widgetContainer.querySelectorAll('[data-action="save-add-quest"]').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.field; + const input = widgetContainer.querySelector(`#rpg-new-quest-${field}`); + const questTitle = input?.value.trim(); + + if (questTitle) { + const settings = getExtensionSettings(); + if (field === 'main') { + settings.quests.main = questTitle; + } else { + if (!settings.quests.optional) { + settings.quests.optional = []; + } + settings.quests.optional.push(questTitle); + } + + // Trigger data change callback + onDataChange('quests', field, questTitle); + + // Re-render the widget + const widgetEl = container.closest('.dashboard-widget'); + if (widgetEl && widgetEl._widgetInstance) { + widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config); + } + } + }); + }); + + // Edit quest (main only) + widgetContainer.querySelectorAll('[data-action="edit-quest"]').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.field; + const form = widgetContainer.querySelector(`#rpg-edit-quest-form-${field}`); + const questItem = widgetContainer.querySelector('.rpg-quest-item[data-field="main"]'); + const input = widgetContainer.querySelector(`#rpg-edit-quest-${field}`); + + if (form) form.style.display = 'block'; + if (questItem) questItem.style.display = 'none'; + if (input) input.focus(); + }); + }); + + // Cancel edit quest + widgetContainer.querySelectorAll('[data-action="cancel-edit-quest"]').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.field; + const form = widgetContainer.querySelector(`#rpg-edit-quest-form-${field}`); + const questItem = widgetContainer.querySelector('.rpg-quest-item[data-field="main"]'); + + if (form) form.style.display = 'none'; + if (questItem) questItem.style.display = 'flex'; + }); + }); + + // Save edit quest + widgetContainer.querySelectorAll('[data-action="save-edit-quest"]').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.field; + const input = widgetContainer.querySelector(`#rpg-edit-quest-${field}`); + const questTitle = input?.value.trim(); + + if (questTitle) { + const settings = getExtensionSettings(); + settings.quests.main = questTitle; + + // Trigger data change callback + onDataChange('quests', 'main', questTitle); + + // Re-render the widget + const widgetEl = container.closest('.dashboard-widget'); + if (widgetEl && widgetEl._widgetInstance) { + widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config); + } + } + }); + }); + + // Remove quest + widgetContainer.querySelectorAll('[data-action="remove-quest"]').forEach(btn => { + btn.addEventListener('click', () => { + const field = btn.dataset.field; + const index = parseInt(btn.dataset.index); + const settings = getExtensionSettings(); + + if (field === 'main') { + settings.quests.main = 'None'; + onDataChange('quests', 'main', 'None'); + } else { + if (settings.quests.optional && index !== undefined && !isNaN(index)) { + settings.quests.optional.splice(index, 1); + onDataChange('quests', 'optional', settings.quests.optional); + } + } + + // Re-render the widget + const widgetEl = container.closest('.dashboard-widget'); + if (widgetEl && widgetEl._widgetInstance) { + widgetEl._widgetInstance.render(container, widgetEl._widgetInstance.config); + } + }); + }); + + // Inline editing for optional quests + widgetContainer.querySelectorAll('.rpg-quest-title.rpg-editable').forEach(el => { + el.addEventListener('blur', () => { + const field = el.dataset.field; + const index = parseInt(el.dataset.index); + const newTitle = el.textContent.trim(); + const settings = getExtensionSettings(); + + if (newTitle && field === 'optional' && index !== undefined && !isNaN(index)) { + if (settings.quests.optional && settings.quests.optional[index] !== undefined) { + settings.quests.optional[index] = newTitle; + onDataChange('quests', 'optional', settings.quests.optional); + } + } + }); + }); + + // Enter key to save in forms + widgetContainer.querySelectorAll('.rpg-inline-input').forEach(input => { + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + const inputId = input.id; + const isEdit = inputId.includes('edit'); + const field = inputId.replace('rpg-edit-quest-', '').replace('rpg-new-quest-', ''); + + const actionBtn = widgetContainer.querySelector( + isEdit + ? `[data-action="save-edit-quest"][data-field="${field}"]` + : `[data-action="save-add-quest"][data-field="${field}"]` + ); + + if (actionBtn) actionBtn.click(); + } + }); + }); +} + +/** + * Attach all event handlers for quest widget + */ +function attachQuestHandlers(container, widgetId, quests, state, dependencies) { + const { getExtensionSettings } = dependencies; + const widgetContainer = container.querySelector('.rpg-quests-widget'); + + if (!widgetContainer) return; + + // Sub-tab switching + widgetContainer.querySelectorAll('.rpg-inventory-subtab').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + state.activeSubTab = tab; + + // Re-render the views container inline + const settings = getExtensionSettings(); + const questData = settings.quests || { main: 'None', optional: [] }; + + let contentHtml = ''; + if (tab === 'main') { + contentHtml = renderMainQuestView(questData.main); + } else { + contentHtml = renderOptionalQuestsView(questData.optional || []); + } + + widgetContainer.querySelector('.rpg-quests-views').innerHTML = contentHtml; + + // Update active tab styling + widgetContainer.querySelectorAll('.rpg-inventory-subtab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Re-attach handlers for the new content + attachQuestContentHandlers(container, widgetId, state, dependencies); + }); + }); + + // Attach content handlers initially + attachQuestContentHandlers(container, widgetId, state, dependencies); +} + +/** + * Register Quests Widget + */ +export function registerQuestsWidget(registry, dependencies) { + const { getExtensionSettings } = dependencies; + + // Widget state (per-instance) + const widgetStates = new Map(); + + function getWidgetState(widgetId) { + if (!widgetStates.has(widgetId)) { + widgetStates.set(widgetId, { + activeSubTab: 'main' + }); + } + return widgetStates.get(widgetId); + } + + registry.register('quests', { + name: 'Quests', + icon: '', + description: 'Quest tracking with main and optional quests', + category: 'scene', + minSize: { w: 2, h: 4 }, + defaultSize: { w: 2, h: 5 }, + maxAutoSize: { w: 3, h: 7 }, + requiresSchema: false, + + render(container, config = {}) { + const settings = getExtensionSettings(); + const quests = settings.quests || { + main: 'None', + optional: [] + }; + + // Get or create widget state + const widgetId = container.closest('.dashboard-widget')?.dataset?.widgetId || 'default'; + const state = getWidgetState(widgetId); + + // Build HTML + let contentHtml = ''; + if (state.activeSubTab === 'main') { + contentHtml = renderMainQuestView(quests.main); + } else { + contentHtml = renderOptionalQuestsView(quests.optional || []); + } + + const html = ` +
+ ${renderQuestsSubTabs(state.activeSubTab)} +
+ ${contentHtml} +
+
+ `; + + container.innerHTML = html; + + // Attach event handlers + attachQuestHandlers(container, widgetId, quests, state, dependencies); + }, + + // Called when widget data changes externally + onDataUpdate(container, config = {}) { + this.render(container, config); + } + }); +} diff --git a/style.css b/style.css index ab0365b..44ada3e 100644 --- a/style.css +++ b/style.css @@ -1062,7 +1062,7 @@ body:has(.rpg-panel.rpg-position-left) #sheld { align-items: center; padding: 0; gap: 0.5rem; - flex-wrap: wrap; + flex-wrap: nowrap; /* Prevent wrapping when tabs expand - rely on horizontal scroll */ overflow: visible; /* Prevent clipping of dropdown menu */ } @@ -1170,6 +1170,11 @@ body:has(.rpg-panel.rpg-position-left) #sheld { margin-left: 0.3rem; } +/* Icon-only mode when 4+ tabs - prevents layout issues from hover expansion */ +.rpg-dashboard-tabs.rpg-tabs-icon-only .rpg-dashboard-tab:hover .rpg-tab-name { + display: none; +} + /* Tab Navigation Arrows */ .rpg-tab-nav-arrow { position: absolute;