Files
rpg-companion-sillytavern/src/systems/dashboard/widgets/questsWidget.js
T
Lucas 'Paperboy' Rose-Winters 1fd6720e6b fix: update widget sizing for 1080p screens - Scene, Inventory, and Quests tabs
**Scene Tab (presentCharacters):**
- Desktop: 3×2 (wide and short, fits 1080p viewport)
- Mobile: 2×4 (narrow and tall for vertical stacking)

**Inventory Tab:**
- Desktop: 3×7 (full width, spacious) instead of 2×6
- Mobile: 2×5 (full width, compact)

**Quests Tab:**
- Desktop: 3×7 (full width, spacious) instead of 2×5
- Mobile: 2×5 (full width, compact)

All widgets now use full width at their respective column counts and
are properly sized to fit within 1080p screens without scrolling off.
2025-11-06 20:50:16 +11:00

473 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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><span class="rpg-btn-label"> Add Quest</span>
</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><span class="rpg-btn-label"> Add Quest</span>
</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: 'quests',
minSize: { w: 2, h: 4 },
// Column-aware sizing: compact on mobile, full width on desktop
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 5 }; // Mobile: 2×5 (full width, compact)
}
return { w: 3, h: 7 }; // Desktop: 3×7 (full width, spacious for 1080p)
},
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 8 }; // Mobile: 2×8 max
}
return { w: 3, h: 10 }; // Desktop: 3×10 max (can expand)
},
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);
},
// Called when widget is resized
onResize(container, newW, newH) {
// Re-render widget to update layout for new dimensions
this.render(container, this.config || {});
// Apply width-aware styling
const widget = container.querySelector('.rpg-quests-widget');
if (widget) {
if (newW >= 3) {
// Wide layout: constrain title width
widget.classList.add('rpg-quests-wide');
widget.classList.remove('rpg-quests-compact');
} else {
// Narrow layout: compact mode with truncated headers
widget.classList.remove('rpg-quests-wide');
widget.classList.add('rpg-quests-compact');
}
}
}
});
}