/** * Inventory Widget * * Comprehensive inventory management with three sub-tabs: * - On Person: Items currently carried * - Stored: Items in storage locations * - Assets: Vehicles, property, major possessions * * Features: * - List/Grid view modes per sub-tab * - Add/remove items and storage locations * - Collapsible storage locations * - Editable item names * - Inline forms for adding items */ import { parseItems, serializeItems } from '../../../utils/itemParser.js'; import { sanitizeItemName, sanitizeLocationName } from '../../../utils/security.js'; import { showAlertDialog } from '../confirmDialog.js'; /** * Convert location name to safe HTML ID */ function getLocationId(locationName) { return locationName.replace(/[^a-zA-Z0-9\s]/g, '').replace(/\s+/g, '-'); } /** * Escape HTML to prevent XSS */ function escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } /** * Register Inventory Widget */ export function registerInventoryWidget(registry, dependencies) { const { getExtensionSettings, onDataChange } = dependencies; // Widget state (per-instance) const widgetStates = new Map(); function getWidgetState(widgetId) { if (!widgetStates.has(widgetId)) { widgetStates.set(widgetId, { activeSubTab: 'onPerson', collapsedLocations: [], viewModes: { onPerson: 'list', stored: 'list', assets: 'list' } }); } return widgetStates.get(widgetId); } registry.register('inventory', { name: 'Inventory', icon: '🎒', description: 'Full inventory system with On Person, Stored, and Assets', category: 'inventory', minSize: { w: 2, h: 4 }, // Column-aware sizing: compact on mobile, spacious on desktop defaultSize: (columns) => { if (columns <= 2) { return { w: 2, h: 5 }; // Mobile: 2×5 (full width, compact) } return { w: 2, h: 6 }; // Desktop: 2×6 (default) }, maxAutoSize: (columns) => { if (columns <= 2) { return { w: 2, h: 8 }; // Mobile: 2×8 max (increased for expansion headroom) } return { w: 3, h: 8 }; // Desktop: 3×8 max (can expand) }, requiresSchema: false, render(container, config = {}) { const settings = getExtensionSettings(); const inventory = settings.userStats.inventory || { version: 2, onPerson: 'None', stored: {}, assets: 'None' }; // Get or create widget state const widgetId = container.closest('.dashboard-widget')?.dataset?.widgetId || 'default'; const state = getWidgetState(widgetId); // Build HTML const html = `
${renderSubTabs(state.activeSubTab)}
${renderActiveView(inventory, state)}
`; container.innerHTML = html; // Attach event handlers attachInventoryHandlers(container, widgetId, inventory, state, dependencies); }, getConfig() { return { compactMode: { type: 'boolean', label: 'Compact Mode', default: false } }; }, onConfigChange(container, newConfig) { this.render(container, newConfig); }, onResize(container, newW, newH) { // Re-render widget to update internal layout for new dimensions // This ensures sub-tabs, item lists, and storage locations adapt correctly this.render(container, this.config || {}); // Apply compact mode styling if needed const widget = container.querySelector('.rpg-inventory-widget'); if (widget) { if (newW < 6) { widget.classList.add('rpg-inventory-compact'); } else { widget.classList.remove('rpg-inventory-compact'); } } }, onRemove(widgetId) { // Clean up widget state widgetStates.delete(widgetId); } }); /** * Render sub-tab navigation */ function renderSubTabs(activeTab) { return `
`; } /** * Render active view based on state */ function renderActiveView(inventory, state) { switch (state.activeSubTab) { case 'onPerson': return renderOnPersonView(inventory.onPerson, state.viewModes.onPerson); case 'stored': return renderStoredView(inventory.stored, state.collapsedLocations, state.viewModes.stored); case 'assets': return renderAssetsView(inventory.assets, state.viewModes.assets); default: return renderOnPersonView(inventory.onPerson, state.viewModes.onPerson); } } /** * Render On Person view */ function renderOnPersonView(onPersonItems, viewMode) { const items = parseItems(onPersonItems); const itemsHtml = items.length === 0 ? '
No items carried
' : renderItemList(items, 'onPerson', null, viewMode); return `

Items Currently Carried

${renderViewToggle('onPerson', viewMode)}
${itemsHtml}
`; } /** * Render Stored view */ function renderStoredView(stored, collapsedLocations, viewMode) { const locations = Object.keys(stored || {}); let locationsHtml = ''; if (locations.length === 0) { locationsHtml = `
No storage locations yet. Click "Add Location" to create one.
`; } else { locationsHtml = locations.map(location => { const items = parseItems(stored[location]); const isCollapsed = collapsedLocations.includes(location); const locationId = getLocationId(location); const itemsHtml = items.length === 0 ? '
No items stored here
' : renderItemList(items, 'stored', location, viewMode); return `
${escapeHtml(location)}
${itemsHtml}
`; }).join(''); } return `

Storage Locations

${renderViewToggle('stored', viewMode)}
${locationsHtml}
`; } /** * Render Assets view */ function renderAssetsView(assets, viewMode) { const items = parseItems(assets); const itemsHtml = items.length === 0 ? '
No assets owned
' : renderItemList(items, 'assets', null, viewMode); return `

Vehicles, Property & Major Possessions

${renderViewToggle('assets', viewMode)}
${itemsHtml}
Assets include vehicles (cars, motorcycles), property (homes, apartments), and major equipment (workshop tools, special items).
`; } /** * Render view toggle buttons */ function renderViewToggle(field, viewMode) { return `
`; } /** * Render item list (list or grid view) */ function renderItemList(items, field, location, viewMode) { const locationAttr = location ? `data-location="${escapeHtml(location)}"` : ''; if (viewMode === 'grid') { return items.map((item, index) => `
${escapeHtml(item)}
`).join(''); } else { return items.map((item, index) => `
${escapeHtml(item)}
`).join(''); } } /** * Attach all event handlers */ function attachInventoryHandlers(container, widgetId, inventory, state, dependencies) { const widget = container.querySelector('.rpg-inventory-widget'); if (!widget) return; // Sub-tab switching widget.querySelectorAll('.rpg-inventory-subtab').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.tab; state.activeSubTab = tab; // Re-render const settings = getExtensionSettings(); const inv = settings.userStats.inventory; widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inv, state); // Update active tab styling widget.querySelectorAll('.rpg-inventory-subtab').forEach(b => b.classList.remove('active')); btn.classList.add('active'); // Re-attach handlers for new view attachInventoryHandlers(container, widgetId, inv, state, dependencies); }); }); // View mode toggle widget.querySelectorAll('[data-action="switch-view"]').forEach(btn => { btn.addEventListener('click', () => { const field = btn.dataset.field; const view = btn.dataset.view; state.viewModes[field] = view; // Re-render active view const settings = getExtensionSettings(); const inv = settings.userStats.inventory; widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inv, state); attachInventoryHandlers(container, widgetId, inv, state, dependencies); }); }); // Add item button widget.querySelectorAll('[data-action="add-item"]').forEach(btn => { btn.addEventListener('click', () => { const field = btn.dataset.field; const location = btn.dataset.location; showAddItemForm(widget, field, location); }); }); // Cancel add item widget.querySelectorAll('[data-action="cancel-add-item"]').forEach(btn => { btn.addEventListener('click', () => { const field = btn.dataset.field; const location = btn.dataset.location; hideAddItemForm(widget, field, location); }); }); // Save add item widget.querySelectorAll('[data-action="save-add-item"]').forEach(btn => { btn.addEventListener('click', () => { const field = btn.dataset.field; const location = btn.dataset.location; saveAddItem(container, widgetId, field, location, state, dependencies); }); }); // Enter key in add item form widget.querySelectorAll('.rpg-inline-form input').forEach(input => { input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); const form = input.closest('.rpg-inline-form'); const saveBtn = form.querySelector('[data-action="save-add-item"], [data-action="save-add-location"]'); if (saveBtn) saveBtn.click(); } if (e.key === 'Escape') { e.preventDefault(); const form = input.closest('.rpg-inline-form'); const cancelBtn = form.querySelector('[data-action="cancel-add-item"], [data-action="cancel-add-location"]'); if (cancelBtn) cancelBtn.click(); } }); }); // Remove item widget.querySelectorAll('[data-action="remove-item"]').forEach(btn => { btn.addEventListener('click', () => { const field = btn.dataset.field; const index = parseInt(btn.dataset.index); const location = btn.dataset.location; removeItem(container, widgetId, field, index, location, state, dependencies); }); }); // Edit item name widget.querySelectorAll('.rpg-item-name.rpg-editable').forEach(field => { let originalValue = field.textContent.trim(); field.addEventListener('focus', () => { originalValue = field.textContent.trim(); const range = document.createRange(); range.selectNodeContents(field); const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); }); field.addEventListener('blur', () => { const newValue = field.textContent.trim(); if (newValue && newValue !== originalValue) { const fieldName = field.dataset.field; const index = parseInt(field.dataset.index); const location = field.dataset.location; updateItemName(container, widgetId, fieldName, index, newValue, location, state, dependencies); } else if (!newValue) { field.textContent = originalValue; } }); field.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); field.blur(); } if (e.key === 'Escape') { e.preventDefault(); field.textContent = originalValue; field.blur(); } }); field.addEventListener('paste', (e) => { e.preventDefault(); const text = (e.clipboardData || window.clipboardData).getData('text/plain'); document.execCommand('insertText', false, text); }); }); // Add location const addLocationBtn = widget.querySelector('[data-action="add-location"]'); if (addLocationBtn) { addLocationBtn.addEventListener('click', () => { showAddLocationForm(widget); }); } // Cancel add location const cancelAddLocationBtn = widget.querySelector('[data-action="cancel-add-location"]'); if (cancelAddLocationBtn) { cancelAddLocationBtn.addEventListener('click', () => { hideAddLocationForm(widget); }); } // Save add location const saveAddLocationBtn = widget.querySelector('[data-action="save-add-location"]'); if (saveAddLocationBtn) { saveAddLocationBtn.addEventListener('click', () => { saveAddLocation(container, widgetId, state, dependencies); }); } // Toggle location collapse widget.querySelectorAll('[data-action="toggle-location"]').forEach(btn => { btn.addEventListener('click', () => { const location = btn.dataset.location; toggleLocationCollapse(widget, location, state); }); }); // Remove location widget.querySelectorAll('[data-action="remove-location"]').forEach(btn => { btn.addEventListener('click', () => { const location = btn.dataset.location; showRemoveLocationConfirm(widget, location); }); }); // Cancel remove location widget.querySelectorAll('[data-action="cancel-remove-location"]').forEach(btn => { btn.addEventListener('click', () => { const location = btn.dataset.location; hideRemoveLocationConfirm(widget, location); }); }); // Confirm remove location widget.querySelectorAll('[data-action="confirm-remove-location"]').forEach(btn => { btn.addEventListener('click', () => { const location = btn.dataset.location; removeLocation(container, widgetId, location, state, dependencies); }); }); } /** * Show add item form */ function showAddItemForm(widget, field, location) { let formSelector; if (field === 'stored') { const locationId = getLocationId(location); formSelector = `[data-form="add-item-stored-${locationId}"]`; } else { formSelector = `[data-form="add-item-${field}"]`; } const form = widget.querySelector(formSelector); if (form) { form.style.display = 'block'; const input = form.querySelector('input'); if (input) { input.value = ''; input.focus(); } } } /** * Hide add item form */ function hideAddItemForm(widget, field, location) { let formSelector; if (field === 'stored') { const locationId = getLocationId(location); formSelector = `[data-form="add-item-stored-${locationId}"]`; } else { formSelector = `[data-form="add-item-${field}"]`; } const form = widget.querySelector(formSelector); if (form) { form.style.display = 'none'; const input = form.querySelector('input'); if (input) input.value = ''; } } /** * Save new item */ function saveAddItem(container, widgetId, field, location, state, dependencies) { const widget = container.querySelector('.rpg-inventory-widget'); let formSelector; if (field === 'stored') { const locationId = getLocationId(location); formSelector = `[data-form="add-item-stored-${locationId}"]`; } else { formSelector = `[data-form="add-item-${field}"]`; } const form = widget.querySelector(formSelector); if (!form) return; const input = form.querySelector('input'); const rawItemName = input.value.trim(); if (!rawItemName) { hideAddItemForm(widget, field, location); return; } const itemName = sanitizeItemName(rawItemName); if (!itemName) { showAlertDialog({ title: 'Invalid Item', message: 'Please enter a valid item name.', variant: 'warning' }); hideAddItemForm(widget, field, location); return; } const settings = getExtensionSettings(); const inventory = settings.userStats.inventory; // Get current items let currentString; if (field === 'stored') { currentString = inventory.stored[location] || 'None'; } else { currentString = inventory[field] || 'None'; } const items = parseItems(currentString); items.push(itemName); const newString = serializeItems(items); // Save back if (field === 'stored') { inventory.stored[location] = newString; } else { inventory[field] = newString; } // Trigger change callback if (onDataChange) { onDataChange('inventory', field, newString, location); } hideAddItemForm(widget, field, location); // Re-render view widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); attachInventoryHandlers(container, widgetId, inventory, state, dependencies); } /** * Remove item */ function removeItem(container, widgetId, field, index, location, state, dependencies) { const settings = getExtensionSettings(); const inventory = settings.userStats.inventory; // Get current items let currentString; if (field === 'stored') { currentString = inventory.stored[location] || 'None'; } else { currentString = inventory[field] || 'None'; } const items = parseItems(currentString); items.splice(index, 1); const newString = serializeItems(items); // Save back if (field === 'stored') { inventory.stored[location] = newString; } else { inventory[field] = newString; } // Trigger change callback if (onDataChange) { onDataChange('inventory', field, newString, location); } // Re-render view const widget = container.querySelector('.rpg-inventory-widget'); widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); attachInventoryHandlers(container, widgetId, inventory, state, dependencies); } /** * Update item name */ function updateItemName(container, widgetId, field, index, newName, location, state, dependencies) { const sanitized = sanitizeItemName(newName); if (!sanitized) return; const settings = getExtensionSettings(); const inventory = settings.userStats.inventory; // Get current items let currentString; if (field === 'stored') { currentString = inventory.stored[location] || 'None'; } else { currentString = inventory[field] || 'None'; } const items = parseItems(currentString); items[index] = sanitized; const newString = serializeItems(items); // Save back if (field === 'stored') { inventory.stored[location] = newString; } else { inventory[field] = newString; } // Trigger change callback if (onDataChange) { onDataChange('inventory', field, newString, location); } } /** * Show add location form */ function showAddLocationForm(widget) { const form = widget.querySelector('[data-form="add-location"]'); if (form) { form.style.display = 'block'; const input = form.querySelector('input'); if (input) { input.value = ''; input.focus(); } } } /** * Hide add location form */ function hideAddLocationForm(widget) { const form = widget.querySelector('[data-form="add-location"]'); if (form) { form.style.display = 'none'; const input = form.querySelector('input'); if (input) input.value = ''; } } /** * Save new location */ function saveAddLocation(container, widgetId, state, dependencies) { const widget = container.querySelector('.rpg-inventory-widget'); const form = widget.querySelector('[data-form="add-location"]'); if (!form) return; const input = form.querySelector('input'); const rawLocationName = input.value.trim(); if (!rawLocationName) { hideAddLocationForm(widget); return; } const locationName = sanitizeLocationName(rawLocationName); if (!locationName) { showAlertDialog({ title: 'Invalid Location', message: 'Please enter a valid location name.', variant: 'warning' }); hideAddLocationForm(widget); return; } const settings = getExtensionSettings(); const inventory = settings.userStats.inventory; // Check if location already exists if (inventory.stored[locationName]) { showAlertDialog({ title: 'Duplicate Location', message: 'A location with this name already exists.', variant: 'warning' }); hideAddLocationForm(widget); return; } // Add new location inventory.stored[locationName] = 'None'; // Trigger change callback if (onDataChange) { onDataChange('inventory', 'stored', inventory.stored); } hideAddLocationForm(widget); // Re-render view widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); attachInventoryHandlers(container, widgetId, inventory, state, dependencies); } /** * Toggle location collapse */ function toggleLocationCollapse(widget, location, state) { const index = state.collapsedLocations.indexOf(location); if (index === -1) { state.collapsedLocations.push(location); } else { state.collapsedLocations.splice(index, 1); } // Update DOM const locationDiv = widget.querySelector(`.rpg-storage-location[data-location="${location}"]`); if (locationDiv) { const content = locationDiv.querySelector('.rpg-storage-content'); const icon = locationDiv.querySelector('.rpg-storage-toggle i'); if (index === -1) { // Now collapsed locationDiv.classList.add('collapsed'); content.style.display = 'none'; icon.className = 'fa-solid fa-chevron-right'; } else { // Now expanded locationDiv.classList.remove('collapsed'); content.style.display = 'block'; icon.className = 'fa-solid fa-chevron-down'; } } } /** * Show remove location confirmation */ function showRemoveLocationConfirm(widget, location) { const locationId = getLocationId(location); const confirm = widget.querySelector(`[data-confirm="remove-location-${locationId}"]`); if (confirm) { confirm.style.display = 'block'; } } /** * Hide remove location confirmation */ function hideRemoveLocationConfirm(widget, location) { const locationId = getLocationId(location); const confirm = widget.querySelector(`[data-confirm="remove-location-${locationId}"]`); if (confirm) { confirm.style.display = 'none'; } } /** * Remove location */ function removeLocation(container, widgetId, location, state, dependencies) { const settings = getExtensionSettings(); const inventory = settings.userStats.inventory; delete inventory.stored[location]; // Remove from collapsed locations const index = state.collapsedLocations.indexOf(location); if (index !== -1) { state.collapsedLocations.splice(index, 1); } // Trigger change callback if (onDataChange) { onDataChange('inventory', 'stored', inventory.stored); } // Re-render view const widget = container.querySelector('.rpg-inventory-widget'); widget.querySelector('.rpg-inventory-views').innerHTML = renderActiveView(inventory, state); attachInventoryHandlers(container, widgetId, inventory, state, dependencies); } }