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 `
+
+
+
+ ${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 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 = `
+
+ `;
+
+ 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;