diff --git a/src/systems/dashboard/widgets/inventoryWidget.js b/src/systems/dashboard/widgets/inventoryWidget.js new file mode 100644 index 0000000..13c414c --- /dev/null +++ b/src/systems/dashboard/widgets/inventoryWidget.js @@ -0,0 +1,925 @@ +/** + * 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'; + +/** + * 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', + minSize: { w: 6, h: 4 }, + defaultSize: { w: 8, h: 6 }, + 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) { + // Adjust layout for narrow widgets + const widget = container.querySelector('.rpg-inventory-widget'); + if (!widget) return; + + 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) { + alert('Invalid item name.'); + 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) { + alert('Invalid location name.'); + hideAddLocationForm(widget); + return; + } + + const settings = getExtensionSettings(); + const inventory = settings.userStats.inventory; + + // Check if location already exists + if (inventory.stored[locationName]) { + alert('A location with this name already exists.'); + 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); + } +}