feat(dashboard): add quest widget + fix 4-tab header layout

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
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-30 08:40:46 +11:00
parent ad2efa55f0
commit 9f92c4af87
5 changed files with 478 additions and 1 deletions
@@ -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`);
}
@@ -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`);
}
+20
View File
@@ -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'
}
}
]
}
],
@@ -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 `
<div class="rpg-inventory-subtabs">
<button class="rpg-inventory-subtab ${activeTab === 'main' ? 'active' : ''}" data-tab="main">
<i class="fa-solid fa-scroll"></i>
<span class="rpg-subtab-label">Main Quest</span>
</button>
<button class="rpg-inventory-subtab ${activeTab === 'optional' ? 'active' : ''}" data-tab="optional">
<i class="fa-solid fa-list-check"></i>
<span class="rpg-subtab-label">Optional</span>
</button>
</div>
`;
}
/**
* Renders the main quest view
*/
function renderMainQuestView(mainQuest) {
const questDisplay = (mainQuest && mainQuest !== 'None') ? mainQuest : '';
const hasQuest = questDisplay.length > 0;
return `
<div class="rpg-quest-section">
<div class="rpg-quest-header">
<h3 class="rpg-quest-section-title">Main Quest</h3>
${!hasQuest ? `<button class="rpg-add-quest-btn" data-action="add-quest" data-field="main" title="Add main quest">
<i class="fa-solid fa-plus"></i> Add Quest
</button>` : ''}
</div>
<div class="rpg-quest-content">
${hasQuest ? `
<div class="rpg-inline-form" id="rpg-edit-quest-form-main" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-edit-quest-main" value="${escapeHtml(questDisplay)}" />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-edit-quest" data-field="main">
<i class="fa-solid fa-times"></i> Cancel
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-edit-quest" data-field="main">
<i class="fa-solid fa-check"></i> Save
</button>
</div>
</div>
<div class="rpg-quest-item" data-field="main">
<div class="rpg-quest-title">${escapeHtml(questDisplay)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-edit" data-action="edit-quest" data-field="main" title="Edit quest">
<i class="fa-solid fa-edit"></i>
</button>
<button class="rpg-quest-remove" data-action="remove-quest" data-field="main" title="Complete/Remove quest">
<i class="fa-solid fa-check"></i>
</button>
</div>
</div>
` : `
<div class="rpg-inline-form" id="rpg-add-quest-form-main" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-main" placeholder="Enter main quest title..." />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="main">
<i class="fa-solid fa-times"></i> Cancel
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="main">
<i class="fa-solid fa-check"></i> Add
</button>
</div>
</div>
<div class="rpg-quest-empty">No active main quest</div>
`}
</div>
<div class="rpg-quest-hint">
<i class="fa-solid fa-lightbulb"></i>
The main quest represents your primary objective in the story.
</div>
</div>
`;
}
/**
* Renders the optional quests view
*/
function renderOptionalQuestsView(optionalQuests) {
const quests = optionalQuests.filter(q => q && q !== 'None');
let questsHtml = '';
if (quests.length === 0) {
questsHtml = '<div class="rpg-quest-empty">No active optional quests</div>';
} else {
questsHtml = quests.map((quest, index) => `
<div class="rpg-quest-item" data-field="optional" data-index="${index}">
<div class="rpg-quest-title rpg-editable" contenteditable="true" data-field="optional" data-index="${index}" title="Click to edit">${escapeHtml(quest)}</div>
<div class="rpg-quest-actions">
<button class="rpg-quest-remove" data-action="remove-quest" data-field="optional" data-index="${index}" title="Complete/Remove quest">
<i class="fa-solid fa-check"></i>
</button>
</div>
</div>
`).join('');
}
return `
<div class="rpg-quest-section">
<div class="rpg-quest-header">
<h3 class="rpg-quest-section-title">Optional Quests</h3>
<button class="rpg-add-quest-btn" data-action="add-quest" data-field="optional" title="Add optional quest">
<i class="fa-solid fa-plus"></i> Add Quest
</button>
</div>
<div class="rpg-quest-content">
<div class="rpg-inline-form" id="rpg-add-quest-form-optional" style="display: none;">
<input type="text" class="rpg-inline-input" id="rpg-new-quest-optional" placeholder="Enter optional quest title..." />
<div class="rpg-inline-buttons">
<button class="rpg-inline-btn rpg-inline-cancel" data-action="cancel-add-quest" data-field="optional">
<i class="fa-solid fa-times"></i> Cancel
</button>
<button class="rpg-inline-btn rpg-inline-save" data-action="save-add-quest" data-field="optional">
<i class="fa-solid fa-check"></i> Add
</button>
</div>
</div>
<div class="rpg-quest-list">
${questsHtml}
</div>
</div>
<div class="rpg-quest-hint">
<i class="fa-solid fa-info-circle"></i>
Optional quests are side objectives that complement your main story.
</div>
</div>
`;
}
/**
* 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: '<i class="fa-solid fa-scroll"></i>',
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 = `
<div class="rpg-quests-widget" data-widget-id="${widgetId}">
${renderQuestsSubTabs(state.activeSubTab)}
<div class="rpg-quests-views">
${contentHtml}
</div>
</div>
`;
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);
}
});
}
+6 -1
View File
@@ -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;