From a9d98a307632fc4969451e53620a55472ccfcc41 Mon Sep 17 00:00:00 2001 From: Lucas 'Paperboy' Rose-Winters Date: Thu, 6 Nov 2025 22:08:48 +1100 Subject: [PATCH] feat: implement Skills widget with level progression and categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive Skills widget to dashboard system with category organization, XP tracking, level progression, and multiple view modes. Widget Features: - Three sub-tabs: All Skills, By Category, Quick View - Level-up and level-down buttons for manual progression - XP progress bars with visual feedback - Search and filter functionality - Category collapse/expand in By Category view - Editable skill names and categories - Delete skills and categories - Add new skills and categories - Configurable max level and XP display UI Improvements: - Scrollable content area for large skill lists - Responsive card layout - Shortened tab labels for compact display ("All", "Quick" vs "All Skills", "Quick View") - Proper flex layout for skill names (no longer truncated) - Level badges and action buttons Technical Implementation: - Event handler deduplication to prevent exponential level-up bug - Flag-based handler attachment: container.dataset.handlersAttached - Nested flex containers for proper space distribution - Scrollable views wrapper matching Inventory/Quests pattern Dashboard Integration: - Added Skills tab to defaultLayout.js (tab 5) - Icon: fa-solid fa-book (fixed invalid fa-book-sparkles) - Dimensions: 3x7 grid cells - Default config: All Skills tab, show XP, show categories - Auto-arrange support in dashboardManager.js - Skills category group with priority order 6 - Auto-creates Skills tab when skills widgets detected - Widget registration in dashboardIntegration.js Widget Files: - src/systems/dashboard/widgets/userSkillsWidget.js (new) - Full widget implementation with all sub-tabs and features - State management with Map-based storage - Category-based and flat views - Search/filter/sort functionality Styling: - style.css: Added skills widget styles - Skill cards, headers, action buttons - Level-down button with accent color - XP progress bars - Category sections Fixes from iteration: 1. Invalid FontAwesome icon (fa-book-sparkles → fa-book) 2. Tab labels too wide (shortened to single words) 3. Skill names truncated (fixed with proper flex structure) 4. Widget height incorrect (adjusted to h:7) 5. Level-up exponential bug (duplicate handlers, added flag guard) 6. No level-down button (added with minimum level 1) 7. No scrollbar on long lists (added .rpg-skills-views wrapper) Category: skills Integration: Fully integrated with dashboard v2.0 system Tested: Layout, interactions, scrolling, level progression Refs: AI tracker integration (separate commit) --- src/systems/dashboard/dashboardIntegration.js | 4 + src/systems/dashboard/dashboardManager.js | 19 +- src/systems/dashboard/defaultLayout.js | 23 + .../dashboard/widgets/userSkillsWidget.js | 1079 +++++++++++++++++ style.css | 698 +++++++++++ 5 files changed, 1821 insertions(+), 2 deletions(-) create mode 100644 src/systems/dashboard/widgets/userSkillsWidget.js diff --git a/src/systems/dashboard/dashboardIntegration.js b/src/systems/dashboard/dashboardIntegration.js index 3d8f299..79ef448 100644 --- a/src/systems/dashboard/dashboardIntegration.js +++ b/src/systems/dashboard/dashboardIntegration.js @@ -27,6 +27,7 @@ import { registerSceneInfoWidget } from './widgets/sceneInfoWidget.js'; import { registerPresentCharactersWidget } from './widgets/presentCharactersWidget.js'; import { registerInventoryWidget } from './widgets/inventoryWidget.js'; import { registerQuestsWidget } from './widgets/questsWidget.js'; +import { registerUserSkillsWidget } from './widgets/userSkillsWidget.js'; // Global dashboard manager instance let dashboardManager = null; @@ -254,6 +255,9 @@ function registerAllWidgets(registry, dependencies) { // Quest widget registerQuestsWidget(registry, dependencies); + // Skills widget + registerUserSkillsWidget(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 d6e5f32..289b63e 100644 --- a/src/systems/dashboard/dashboardManager.js +++ b/src/systems/dashboard/dashboardManager.js @@ -949,7 +949,8 @@ export class DashboardManager { scene: [], social: [], inventory: [], - quests: [] + quests: [], + skills: [] }; widgets.forEach(widget => { @@ -1031,6 +1032,19 @@ export class DashboardManager { this.gridEngine.autoLayout(groups.quests, { preserveOrder: true }); } + // Create Skills tab if there are skills widgets + if (groups.skills.length > 0) { + this.dashboard.tabs.push({ + id: 'tab-skills', + name: 'Skills', + icon: 'fa-solid fa-book', + order: 5, + widgets: groups.skills + }); + + this.gridEngine.autoLayout(groups.skills, { preserveOrder: true }); + } + console.log('[DashboardManager] Created', this.dashboard.tabs.length, 'tabs'); // Re-render tabs and switch to first tab @@ -1070,7 +1084,8 @@ export class DashboardManager { 'social': 3, 'inventory': 4, 'quests': 5, - 'other': 6 + 'skills': 6, + 'other': 7 }; // Specific widget type ordering within user category diff --git a/src/systems/dashboard/defaultLayout.js b/src/systems/dashboard/defaultLayout.js index aac58d5..31232b9 100644 --- a/src/systems/dashboard/defaultLayout.js +++ b/src/systems/dashboard/defaultLayout.js @@ -167,6 +167,29 @@ export function generateDefaultDashboard() { } } ] + }, + // Tab 5: Skills (Full tab for skills system) + { + id: 'tab-skills', + name: 'Skills', + icon: 'fa-solid fa-book', + order: 4, + widgets: [ + { + id: 'widget-userskills', + type: 'userSkills', + x: 0, + y: 0, + w: 3, + h: 7, + config: { + defaultSubTab: 'all', + showXP: true, + showCategories: true, + maxLevel: 10 + } + } + ] } ], diff --git a/src/systems/dashboard/widgets/userSkillsWidget.js b/src/systems/dashboard/widgets/userSkillsWidget.js new file mode 100644 index 0000000..ea6608a --- /dev/null +++ b/src/systems/dashboard/widgets/userSkillsWidget.js @@ -0,0 +1,1079 @@ +/** + * User Skills Widget + * + * Comprehensive skills tracking system with categories, levels, and XP progress. + * Features three sub-tabs, multiple view modes, and full CRUD operations. + * + * Data Model: + * skills: { + * version: 1, + * categories: { + * 'Combat': [{ name: 'Swordsmanship', level: 5, xp: 75, maxXP: 100 }, ...], + * 'Magic': [...] + * }, + * uncategorized: [...] + * } + */ + +import { parseItems, serializeItems } from '../../../utils/itemParser.js'; + +// Per-widget state storage (Map: widgetId => state) +const widgetStates = new Map(); + +/** + * Get or initialize widget state + */ +function getWidgetState(widgetId) { + if (!widgetStates.has(widgetId)) { + widgetStates.set(widgetId, { + activeSubTab: 'all', + viewModes: { + all: 'list', + categories: 'list', + quick: 'grid' + }, + collapsedCategories: [], + sortBy: 'level', // 'level', 'name', 'xp' + filterText: '' + }); + } + return widgetStates.get(widgetId); +} + +/** + * Migrate old string format to structured format + */ +function migrateSkillsData(oldSkills) { + // Already in new format + if (oldSkills && typeof oldSkills === 'object' && oldSkills.version) { + return oldSkills; + } + + // Old string format: "Swordsmanship, Lockpicking, Alchemy" + if (typeof oldSkills === 'string' && oldSkills.trim()) { + const skillNames = parseItems(oldSkills); + return { + version: 1, + categories: {}, + uncategorized: skillNames.map(name => ({ + name, + level: 1, + xp: 0, + maxXP: 100 + })) + }; + } + + // Empty or null + return { + version: 1, + categories: {}, + uncategorized: [] + }; +} + +/** + * Get all skills as flat array + */ +function getAllSkills(skillsData) { + const skills = []; + + // Add skills from categories + for (const [category, categorySkills] of Object.entries(skillsData.categories || {})) { + categorySkills.forEach(skill => { + skills.push({ ...skill, category }); + }); + } + + // Add uncategorized skills + (skillsData.uncategorized || []).forEach(skill => { + skills.push({ ...skill, category: null }); + }); + + return skills; +} + +/** + * Sort skills + */ +function sortSkills(skills, sortBy) { + const sorted = [...skills]; + + switch (sortBy) { + case 'level': + sorted.sort((a, b) => b.level - a.level || a.name.localeCompare(b.name)); + break; + case 'name': + sorted.sort((a, b) => a.name.localeCompare(b.name)); + break; + case 'xp': + sorted.sort((a, b) => { + const progressA = a.xp / a.maxXP; + const progressB = b.xp / b.maxXP; + return progressB - progressA || b.level - a.level; + }); + break; + } + + return sorted; +} + +/** + * Filter skills by search text + */ +function filterSkills(skills, filterText) { + if (!filterText.trim()) return skills; + + const search = filterText.toLowerCase(); + return skills.filter(skill => + skill.name.toLowerCase().includes(search) || + (skill.category && skill.category.toLowerCase().includes(search)) + ); +} + +/** + * Sanitize skill name + */ +function sanitizeSkillName(name) { + return name.trim().replace(/[<>]/g, '').slice(0, 100); +} + +/** + * Sanitize category name + */ +function sanitizeCategoryName(name) { + return name.trim().replace(/[<>]/g, '').slice(0, 50); +} + +/** + * Register User Skills Widget + */ +export function registerUserSkillsWidget(registry, dependencies) { + const { getExtensionSettings, onDataChange } = dependencies; + + registry.register('userSkills', { + name: 'User Skills', + icon: '⚔️', + description: 'Character skills with categories, levels, and XP tracking', + category: 'skills', + minSize: { w: 2, h: 4 }, + // Large widget like Inventory/Quests + defaultSize: (columns) => { + if (columns <= 2) { + return { w: 2, h: 6 }; // Mobile: 2 cols (full), 6 rows + } + return { w: 3, h: 7 }; // Desktop: 3 cols (full), 7 rows + }, + maxAutoSize: (columns) => { + if (columns <= 2) { + return { w: 2, h: 8 }; + } + return { w: 3, h: 10 }; + }, + requiresSchema: false, + + /** + * Render widget content + */ + render(container, config = {}) { + const settings = getExtensionSettings(); + const skillsConfig = settings.trackerConfig?.userStats?.skillsSection; + + // Check if skills tracking is enabled + if (!skillsConfig?.enabled) { + container.innerHTML = ` +
+ +

Skills tracking is disabled

+ Enable in Tracker Settings +
+ `; + return; + } + + // Migrate and get skills data + let skillsData = settings.userStats?.skills; + skillsData = migrateSkillsData(skillsData); + + // Save migrated data + if (!settings.userStats) settings.userStats = {}; + settings.userStats.skills = skillsData; + + // Get widget ID from container + const widgetId = container.closest('[data-widget-id]')?.dataset.widgetId || 'default'; + const state = getWidgetState(widgetId); + + // Build UI based on active sub-tab + const html = renderSkillsUI(skillsData, state, config, widgetId); + container.innerHTML = html; + + // Attach event handlers + attachSkillsHandlers(container, widgetId, dependencies, config); + }, + + /** + * Get widget configuration schema + */ + getConfig() { + return { + showXP: { + type: 'boolean', + label: 'Show XP Progress Bars', + default: true, + description: 'Display XP progress bars for each skill' + }, + showCategories: { + type: 'boolean', + label: 'Show Category Tags', + default: true, + description: 'Show category labels on skill cards' + }, + defaultSort: { + type: 'select', + label: 'Default Sort Order', + options: [ + { value: 'level', label: 'By Level (High to Low)' }, + { value: 'name', label: 'By Name (A-Z)' }, + { value: 'xp', label: 'By XP Progress' } + ], + default: 'level', + description: 'How to sort skills in All Skills view' + }, + maxLevel: { + type: 'number', + label: 'Maximum Skill Level', + default: 10, + min: 1, + max: 100, + description: 'Highest level a skill can reach' + } + }; + }, + + /** + * Handle widget resize + */ + onResize(container, newW, newH) { + // Add compact class for narrow widths + if (newW <= 2) { + container.classList.add('rpg-skills-compact'); + container.classList.remove('rpg-skills-wide'); + } else { + container.classList.add('rpg-skills-wide'); + container.classList.remove('rpg-skills-compact'); + } + } + }); +} + +/** + * Render skills UI + */ +function renderSkillsUI(skillsData, state, config, widgetId) { + const allSkills = getAllSkills(skillsData); + const hasSkills = allSkills.length > 0; + + let html = '
'; + + // Sub-tab navigation + html += renderSubTabs(state.activeSubTab); + + // Scrollable content area + html += '
'; + + // Content based on active tab + switch (state.activeSubTab) { + case 'all': + html += renderAllSkillsTab(skillsData, state, config); + break; + case 'categories': + html += renderCategoriesTab(skillsData, state, config); + break; + case 'quick': + html += renderQuickViewTab(skillsData, state, config); + break; + } + + html += '
'; // Close rpg-skills-views + html += '
'; // Close rpg-skills-widget + return html; +} + +/** + * Render sub-tab navigation + */ +function renderSubTabs(activeTab) { + const tabs = [ + { id: 'all', label: 'All', icon: 'fa-list' }, + { id: 'categories', label: 'By Category', icon: 'fa-folder-tree' }, + { id: 'quick', label: 'Quick', icon: 'fa-bolt' } + ]; + + let html = '
'; + tabs.forEach(tab => { + const active = tab.id === activeTab ? 'active' : ''; + html += ` + + `; + }); + html += '
'; + + return html; +} + +/** + * Render All Skills tab + */ +function renderAllSkillsTab(skillsData, state, config) { + const allSkills = getAllSkills(skillsData); + + let html = '
'; + + // Header with controls + html += ` +
+
+ + All Skills +
+
+ +
+ + +
+ +
+
+ `; + + // Search/filter + html += ` +
+ + +
+ `; + + // Add skill form (hidden by default) + html += renderAddSkillForm(skillsData); + + // Skills list/grid + if (allSkills.length === 0) { + html += ` +
+ +

No skills yet

+ Click the + button to add your first skill +
+ `; + } else { + let filtered = filterSkills(allSkills, state.filterText); + let sorted = sortSkills(filtered, state.sortBy); + + const viewMode = state.viewModes.all; + html += `
`; + sorted.forEach(skill => { + html += renderSkillCard(skill, config, viewMode); + }); + html += '
'; + + if (filtered.length === 0 && allSkills.length > 0) { + html += ` +
+ +

No skills match your search

+
+ `; + } + } + + html += '
'; + return html; +} + +/** + * Render By Category tab + */ +function renderCategoriesTab(skillsData, state, config) { + let html = '
'; + + // Header + html += ` +
+
+ + Skills by Category +
+
+
+ + +
+ +
+
+ `; + + // Add category form (hidden) + html += renderAddCategoryForm(); + + const viewMode = state.viewModes.categories; + const categories = Object.keys(skillsData.categories || {}).sort(); + const uncategorized = skillsData.uncategorized || []; + + if (categories.length === 0 && uncategorized.length === 0) { + html += ` +
+ +

No categories yet

+ Click the folder+ button to create a category +
+ `; + } else { + // Render categories + categories.forEach(category => { + html += renderCategory(category, skillsData.categories[category], state, config, viewMode); + }); + + // Render uncategorized + if (uncategorized.length > 0) { + html += renderCategory('Uncategorized', uncategorized, state, config, viewMode, true); + } + } + + html += '
'; + return html; +} + +/** + * Render Quick View tab + */ +function renderQuickViewTab(skillsData, state, config) { + const allSkills = getAllSkills(skillsData); + const topSkills = allSkills.sort((a, b) => b.level - a.level).slice(0, 12); + + let html = '
'; + + html += ` +
+
+ + Quick View +
+
+ `; + + html += `
+ + Showing your top skills for quick reference +
`; + + if (topSkills.length === 0) { + html += ` +
+ +

No skills to display

+ Add skills in the "All Skills" tab +
+ `; + } else { + html += '
'; + topSkills.forEach(skill => { + html += renderSkillCard(skill, config, 'quick'); + }); + html += '
'; + } + + html += '
'; + return html; +} + +/** + * Render category section + */ +function renderCategory(categoryName, skills, state, config, viewMode, isUncategorized = false) { + const isCollapsed = state.collapsedCategories.includes(categoryName); + + let html = '
'; + + // Category header + html += ` +
+ +
${categoryName}
+
${skills.length}
+ ${!isUncategorized ? ` + + + ` : ''} + +
+ `; + + // Category content + if (!isCollapsed) { + html += renderAddSkillForm(null, categoryName, true); + html += `
`; + skills.forEach(skill => { + html += renderSkillCard({ ...skill, category: isUncategorized ? null : categoryName }, config, viewMode); + }); + html += '
'; + } + + html += '
'; + return html; +} + +/** + * Render skill card + */ +function renderSkillCard(skill, config, viewMode) { + const xpPercent = (skill.xp / skill.maxXP) * 100; + const showXP = config.showXP !== false; + const showCategory = config.showCategories !== false && skill.category; + const isQuickView = viewMode === 'quick'; + + let html = `
`; + + // Skill info wrapper (name, level, XP bar) + html += '
'; + + // Header row with name and level + html += '
'; + html += `
${skill.name}
`; + html += `
Lv ${skill.level}
`; + html += '
'; + + // XP bar (if not quick view) + if (showXP && !isQuickView) { + html += ` +
+
+
${skill.xp}/${skill.maxXP} XP
+
+ `; + } + + html += '
'; // Close rpg-skill-info + + // Actions + html += '
'; + if (!isQuickView) { + html += ` + + + + `; + } else { + html += ` + + `; + } + html += '
'; + + html += '
'; + return html; +} + +/** + * Render add skill form + */ +function renderAddSkillForm(skillsData, targetCategory = null, isInCategory = false) { + const categories = skillsData ? Object.keys(skillsData.categories || {}).sort() : []; + + let html = `'; + return html; +} + +/** + * Render add category form + */ +function renderAddCategoryForm() { + let html = ''; + return html; +} + +/** + * Attach event handlers + */ +function attachSkillsHandlers(container, widgetId, dependencies, config) { + const { getExtensionSettings, onDataChange } = dependencies; + + // Check if handlers are already attached to prevent duplicate listeners + if (container.dataset.handlersAttached === 'true') { + return; + } + container.dataset.handlersAttached = 'true'; + + // Event delegation + container.addEventListener('click', (e) => { + const target = e.target.closest('[data-action]'); + if (!target) return; + + const action = target.dataset.action; + handleAction(action, target, container, widgetId, dependencies, config); + }); + + // Filter input + const filterInput = container.querySelector('.rpg-filter-input'); + if (filterInput) { + filterInput.addEventListener('input', (e) => { + const state = getWidgetState(widgetId); + state.filterText = e.target.value; + rerender(container, widgetId, dependencies, config); + }); + } + + // Sort select + const sortSelect = container.querySelector('.rpg-sort-select'); + if (sortSelect) { + sortSelect.addEventListener('change', (e) => { + const state = getWidgetState(widgetId); + state.sortBy = e.target.value; + rerender(container, widgetId, dependencies, config); + }); + } + + // Skill name editing + container.addEventListener('blur', (e) => { + if (e.target.hasAttribute('contenteditable') && e.target.dataset.action === 'edit-skill-name') { + const skillName = e.target.dataset.original; + const newName = sanitizeSkillName(e.target.textContent); + + if (newName && newName !== skillName) { + updateSkillName(skillName, e.target.closest('.rpg-skill-card').dataset.category, newName, dependencies); + rerender(container, widgetId, dependencies, config); + } else { + e.target.textContent = skillName; + } + } + }, true); + + // Keyboard shortcuts + container.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + const target = e.target; + if (target.classList.contains('rpg-inline-input')) { + e.preventDefault(); + const saveBtn = target.closest('.rpg-inline-form').querySelector('.rpg-inline-save'); + if (saveBtn) saveBtn.click(); + } else if (target.hasAttribute('contenteditable')) { + e.preventDefault(); + target.blur(); + } + } else if (e.key === 'Escape') { + const target = e.target; + if (target.classList.contains('rpg-inline-input')) { + const cancelBtn = target.closest('.rpg-inline-form').querySelector('.rpg-inline-cancel'); + if (cancelBtn) cancelBtn.click(); + } else if (target.hasAttribute('contenteditable')) { + const original = target.dataset.original; + target.textContent = original; + target.blur(); + } + } + }); +} + +/** + * Handle actions + */ +function handleAction(action, target, container, widgetId, dependencies, config) { + const settings = dependencies.getExtensionSettings(); + const state = getWidgetState(widgetId); + + switch (action) { + case 'switch-tab': + state.activeSubTab = target.dataset.tab; + rerender(container, widgetId, dependencies, config); + break; + + case 'change-view': + state.viewModes[target.dataset.tab] = target.dataset.view; + rerender(container, widgetId, dependencies, config); + break; + + case 'show-add-skill': + showAddSkillForm(container); + break; + + case 'show-add-skill-to-category': + showAddSkillForm(container, target.dataset.category); + break; + + case 'cancel-add-skill': + hideAddSkillForm(container, target); + break; + + case 'save-add-skill': + saveNewSkill(container, target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'show-add-category': + showAddCategoryForm(container); + break; + + case 'cancel-add-category': + hideAddCategoryForm(container); + break; + + case 'save-add-category': + saveNewCategory(container, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'toggle-category': + toggleCategory(target.dataset.category, state); + rerender(container, widgetId, dependencies, config); + break; + + case 'level-up': + levelUpSkill(target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'level-down': + levelDownSkill(target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'delete-skill': + deleteSkill(target, dependencies); + rerender(container, widgetId, dependencies, config); + break; + + case 'delete-category': + deleteCategory(target.dataset.category, dependencies); + rerender(container, widgetId, dependencies, config); + break; + } +} + +/** + * Show add skill form + */ +function showAddSkillForm(container, targetCategory = null) { + const form = targetCategory + ? container.querySelector(`.rpg-add-skill-form[data-target-category="${targetCategory}"]`) + : container.querySelector('.rpg-add-skill-form:not([data-target-category])'); + + if (form) { + form.style.display = 'block'; + const input = form.querySelector('input[data-field="name"]'); + if (input) input.focus(); + } +} + +/** + * Hide add skill form + */ +function hideAddSkillForm(container, cancelBtn) { + const form = cancelBtn.closest('.rpg-add-skill-form'); + if (form) { + form.style.display = 'none'; + form.querySelectorAll('input').forEach(input => input.value = ''); + } +} + +/** + * Save new skill + */ +function saveNewSkill(container, saveBtn, dependencies) { + const form = saveBtn.closest('.rpg-add-skill-form'); + const nameInput = form.querySelector('[data-field="name"]'); + const levelInput = form.querySelector('[data-field="level"]'); + const categorySelect = form.querySelector('[data-field="category"]'); + + const name = sanitizeSkillName(nameInput.value); + const level = parseInt(levelInput.value) || 1; + const category = form.dataset.targetCategory || (categorySelect ? categorySelect.value : null); + + if (!name) return; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const newSkill = { + name, + level: Math.max(1, Math.min(100, level)), + xp: 0, + maxXP: 100 + }; + + if (category && category !== '' && category !== 'Uncategorized') { + if (!skillsData.categories[category]) { + skillsData.categories[category] = []; + } + skillsData.categories[category].push(newSkill); + } else { + skillsData.uncategorized.push(newSkill); + } + + saveSkillsData(settings, skillsData, dependencies); + + form.style.display = 'none'; + form.querySelectorAll('input').forEach(input => input.value = ''); +} + +/** + * Show add category form + */ +function showAddCategoryForm(container) { + const form = container.querySelector('.rpg-add-category-form'); + if (form) { + form.style.display = 'block'; + const input = form.querySelector('input'); + if (input) input.focus(); + } +} + +/** + * Hide add category form + */ +function hideAddCategoryForm(container) { + const form = container.querySelector('.rpg-add-category-form'); + if (form) { + form.style.display = 'none'; + form.querySelector('input').value = ''; + } +} + +/** + * Save new category + */ +function saveNewCategory(container, dependencies) { + const form = container.querySelector('.rpg-add-category-form'); + const input = form.querySelector('input'); + const name = sanitizeCategoryName(input.value); + + if (!name) return; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + if (!skillsData.categories[name]) { + skillsData.categories[name] = []; + saveSkillsData(settings, skillsData, dependencies); + } + + form.style.display = 'none'; + input.value = ''; +} + +/** + * Toggle category collapsed state + */ +function toggleCategory(categoryName, state) { + const index = state.collapsedCategories.indexOf(categoryName); + if (index >= 0) { + state.collapsedCategories.splice(index, 1); + } else { + state.collapsedCategories.push(categoryName); + } +} + +/** + * Level up skill + */ +function levelUpSkill(target, dependencies) { + const card = target.closest('.rpg-skill-card'); + const skillName = card.dataset.skill; + const category = card.dataset.category === 'Uncategorized' ? null : card.dataset.category; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const skill = findSkill(skillsData, skillName, category); + if (skill) { + skill.level++; + skill.xp = 0; // Reset XP on level up + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Level down skill + */ +function levelDownSkill(target, dependencies) { + const card = target.closest('.rpg-skill-card'); + const skillName = card.dataset.skill; + const category = card.dataset.category === 'Uncategorized' ? null : card.dataset.category; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const skill = findSkill(skillsData, skillName, category); + if (skill && skill.level > 1) { + skill.level--; + skill.xp = 0; // Reset XP on level change + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Delete skill + */ +function deleteSkill(target, dependencies) { + const card = target.closest('.rpg-skill-card'); + const skillName = card.dataset.skill; + const category = card.dataset.category === 'Uncategorized' ? null : card.dataset.category; + + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + removeSkill(skillsData, skillName, category); + saveSkillsData(settings, skillsData, dependencies); +} + +/** + * Delete category + */ +function deleteCategory(categoryName, dependencies) { + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + if (skillsData.categories[categoryName]) { + // Move skills to uncategorized + const skills = skillsData.categories[categoryName]; + skillsData.uncategorized.push(...skills); + delete skillsData.categories[categoryName]; + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Update skill name + */ +function updateSkillName(oldName, category, newName, dependencies) { + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + + const skill = findSkill(skillsData, oldName, category === 'Uncategorized' ? null : category); + if (skill) { + skill.name = newName; + saveSkillsData(settings, skillsData, dependencies); + } +} + +/** + * Find skill in data + */ +function findSkill(skillsData, name, category) { + if (category) { + const categorySkills = skillsData.categories[category]; + return categorySkills ? categorySkills.find(s => s.name === name) : null; + } else { + return skillsData.uncategorized.find(s => s.name === name); + } +} + +/** + * Remove skill from data + */ +function removeSkill(skillsData, name, category) { + if (category) { + const categorySkills = skillsData.categories[category]; + if (categorySkills) { + const index = categorySkills.findIndex(s => s.name === name); + if (index >= 0) categorySkills.splice(index, 1); + } + } else { + const index = skillsData.uncategorized.findIndex(s => s.name === name); + if (index >= 0) skillsData.uncategorized.splice(index, 1); + } +} + +/** + * Save skills data + */ +function saveSkillsData(settings, skillsData, dependencies) { + settings.userStats.skills = skillsData; + + if (dependencies.onDataChange) { + dependencies.onDataChange('userStats', 'skills', skillsData); + } +} + +/** + * Re-render widget + */ +function rerender(container, widgetId, dependencies, config) { + const settings = dependencies.getExtensionSettings(); + const skillsData = settings.userStats.skills; + const state = getWidgetState(widgetId); + + const html = renderSkillsUI(skillsData, state, config, widgetId); + container.innerHTML = html; + + attachSkillsHandlers(container, widgetId, dependencies, config); +} diff --git a/style.css b/style.css index 38eb097..5cafea4 100644 --- a/style.css +++ b/style.css @@ -4370,6 +4370,82 @@ body:has(.rpg-panel.rpg-position-left) #sheld { color: var(--rpg-highlight); } +/* Apply theme colors to skills subtabs */ +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtabs, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtabs, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtabs { + border-bottom-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtab, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtab, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtab { + border-color: var(--rpg-border); + color: var(--rpg-text); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtab:hover, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtab:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtab:hover { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skills-subtab.active, +.rpg-panel[data-theme="fantasy"] .rpg-skills-subtab.active, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-subtab.active { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +/* Apply theme colors to skill cards */ +.rpg-panel[data-theme="sci-fi"] .rpg-skill-card, +.rpg-panel[data-theme="fantasy"] .rpg-skill-card, +.rpg-panel[data-theme="cyberpunk"] .rpg-skill-card { + border-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-skill-card:hover, +.rpg-panel[data-theme="fantasy"] .rpg-skill-card:hover, +.rpg-panel[data-theme="cyberpunk"] .rpg-skill-card:hover { + border-color: var(--rpg-highlight); +} + +/* Apply theme colors to category headers */ +.rpg-panel[data-theme="sci-fi"] .rpg-category-header, +.rpg-panel[data-theme="fantasy"] .rpg-category-header, +.rpg-panel[data-theme="cyberpunk"] .rpg-category-header { + background: var(--rpg-highlight); + border-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-category-name, +.rpg-panel[data-theme="fantasy"] .rpg-category-name, +.rpg-panel[data-theme="cyberpunk"] .rpg-category-name { + color: var(--rpg-text); +} + +/* Apply theme colors to XP bars */ +.rpg-panel[data-theme="sci-fi"] .rpg-xp-bar, +.rpg-panel[data-theme="fantasy"] .rpg-xp-bar, +.rpg-panel[data-theme="cyberpunk"] .rpg-xp-bar { + border-color: var(--rpg-border); +} + +.rpg-panel[data-theme="sci-fi"] .rpg-xp-fill, +.rpg-panel[data-theme="fantasy"] .rpg-xp-fill, +.rpg-panel[data-theme="cyberpunk"] .rpg-xp-fill { + background: linear-gradient(90deg, var(--rpg-highlight), var(--rpg-accent)); +} + +/* Apply theme colors to skills add button */ +.rpg-panel[data-theme="sci-fi"] .rpg-skills-add-btn, +.rpg-panel[data-theme="fantasy"] .rpg-skills-add-btn, +.rpg-panel[data-theme="cyberpunk"] .rpg-skills-add-btn { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + /* Apply theme colors to storage locations */ .rpg-panel[data-theme="sci-fi"] .rpg-storage-location, .rpg-panel[data-theme="fantasy"] .rpg-storage-location, @@ -7723,6 +7799,628 @@ body:has(.rpg-panel.rpg-position-left) #sheld { color: var(--rpg-highlight); } +/* ============================================ + SKILLS WIDGET STYLES + ============================================ */ + +/* Skills Widget - Flex container for proper scrolling */ +.rpg-skills-widget { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; +} + +/* Skills Views - Scrollable content area */ +.rpg-skills-views { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +/* Skills Sub-tabs Navigation */ +.rpg-skills-subtabs { + display: flex; + gap: 0.5rem; + border-bottom: 2px solid var(--SmartThemeBorderColor); + padding-bottom: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--SmartThemeBorderColor) transparent; +} + +.rpg-skills-subtabs::-webkit-scrollbar { + height: 6px; +} + +.rpg-skills-subtabs::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-skills-subtabs::-webkit-scrollbar-thumb { + background: var(--SmartThemeBorderColor); + border-radius: 3px; +} + +.rpg-skills-subtabs::-webkit-scrollbar-thumb:hover { + background: var(--rpg-accent); +} + +.rpg-skills-subtab { + flex: 1; + min-width: fit-content; + white-space: nowrap; + padding: 0.5rem 1rem; + background: transparent; + border: 2px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + cursor: pointer; + transition: all 0.2s ease; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} + +.rpg-skills-subtab i { + font-size: 1rem; +} + +.rpg-skills-subtab:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-skills-subtab.active { + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); + font-weight: 600; +} + +/* Skills Sections */ +.rpg-skills-section { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rpg-skills-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--SmartThemeBorderColor); + gap: 0.5rem; + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; + scrollbar-width: thin; + scrollbar-color: var(--SmartThemeBorderColor) transparent; +} + +.rpg-skills-header::-webkit-scrollbar { + height: 6px; +} + +.rpg-skills-header::-webkit-scrollbar-track { + background: transparent; +} + +.rpg-skills-header::-webkit-scrollbar-thumb { + background: var(--SmartThemeBorderColor); + border-radius: 3px; +} + +.rpg-skills-header::-webkit-scrollbar-thumb:hover { + background: var(--rpg-accent); +} + +.rpg-skills-header h4 { + margin: 0; + font-size: 1.1rem; + color: var(--SmartThemeBodyColor); + white-space: nowrap; + min-width: fit-content; +} + +.rpg-skills-content { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +/* Skills Add Button */ +.rpg-skills-add-btn { + padding: 0.4rem 0.75rem; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + background: transparent; + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); + white-space: nowrap; + min-width: fit-content; + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.rpg-skills-add-btn:hover { + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); + border-color: var(--rpg-highlight); +} + +/* Skills Empty State */ +.rpg-skills-empty { + padding: 2rem; + text-align: center; + color: var(--SmartThemeFastUISliderColColor); + font-style: italic; + font-size: 0.9rem; +} + +/* Skills Filter */ +.rpg-skills-filter { + margin-bottom: 0.75rem; +} + +.rpg-filter-input { + width: 100%; + padding: 0.5rem 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.9rem; + font-family: inherit; +} + +.rpg-filter-input:focus { + outline: none; + border-color: var(--ac-style-color-matchedText); + box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2); +} + +.rpg-filter-input::placeholder { + color: var(--SmartThemeFastUISliderColColor); + font-style: italic; +} + +/* Category Headers (Collapsible) */ +.rpg-category-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + background: var(--SmartThemeQuoteColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + cursor: pointer; + margin-top: 0.5rem; +} + +.rpg-category-toggle { + background: none; + border: none; + color: var(--SmartThemeBodyColor); + cursor: pointer; + padding: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.2s ease; +} + +.rpg-category-toggle:hover { + color: var(--ac-style-color-matchedText); +} + +.rpg-category-toggle i { + transition: transform 0.2s ease; +} + +.rpg-category-header.collapsed .rpg-category-toggle i { + transform: rotate(-90deg); +} + +.rpg-category-name { + flex: 1; + margin: 0; + font-size: 1rem; + font-weight: 600; + color: #000000; +} + +.rpg-category-actions { + display: flex; + gap: 0.5rem; +} + +.rpg-category-content { + margin-top: 0.75rem; +} + +.rpg-category-header.collapsed + .rpg-category-content { + display: none; +} + +/* Skill Cards - List and Grid Views */ +.rpg-skills-list { + display: flex; + flex-direction: column; + gap: 0.5rem; + min-height: 2rem; + padding: 0.5rem 0; +} + +.rpg-skill-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.75rem 1rem; + background: transparent; + border: 2px solid var(--rpg-highlight); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.95rem; + transition: all 0.2s ease; +} + +.rpg-skill-card:hover { + border-color: var(--rpg-highlight); + background: rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.1); +} + +.rpg-skill-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +.rpg-skill-header-row { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.rpg-skill-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; +} + +.rpg-skill-name.rpg-editable { + border-bottom: 1px dashed var(--SmartThemeBorderColor); + transition: all 0.2s ease; +} + +.rpg-skill-name.rpg-editable:hover { + border-bottom-color: var(--ac-style-color-matchedText); +} + +.rpg-skill-name.rpg-editable:focus { + outline: none; + border-bottom-color: var(--ac-style-color-matchedText); + background: var(--SmartThemeQuoteColor); +} + +.rpg-skill-level { + font-size: 0.85rem; + color: var(--rpg-highlight); + font-weight: 600; + white-space: nowrap; +} + +.rpg-skill-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-shrink: 0; +} + +.rpg-skill-action { + padding: 0.3rem 0.6rem; + background: transparent; + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + cursor: pointer; + transition: all 0.2s ease; + font-size: 0.85rem; + white-space: nowrap; +} + +.rpg-skill-action:hover { + background: var(--ac-style-color-matchedText); + border-color: var(--ac-style-color-matchedText); + color: white; +} + +.rpg-skill-action.rpg-level-up-btn { + border-color: var(--rpg-highlight); + color: var(--rpg-highlight); +} + +.rpg-skill-action.rpg-level-up-btn:hover { + background: var(--rpg-highlight); + color: white; +} + +.rpg-skill-action.rpg-level-down-btn { + border-color: var(--rpg-accent); + color: var(--rpg-accent); +} + +.rpg-skill-action.rpg-level-down-btn:hover { + background: var(--rpg-accent); + color: white; +} + +.rpg-skill-action.rpg-delete-btn { + color: var(--SmartThemeFastUISliderColColor); +} + +.rpg-skill-action.rpg-delete-btn:hover { + background: #dc3545; + border-color: #dc3545; + color: white; +} + +/* XP Progress Bar */ +.rpg-xp-bar { + position: relative; + width: 100%; + height: 1.25rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + overflow: hidden; +} + +.rpg-xp-fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: linear-gradient(90deg, + var(--rpg-highlight), + rgba(var(--rpg-highlight-rgb, 233, 69, 96), 0.7)); + transition: width 0.3s ease; + border-radius: 0.25rem 0 0 0.25rem; +} + +.rpg-xp-text { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: var(--SmartThemeBodyColor); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Grid View for Skills */ +.rpg-skills-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.75rem; + padding: 0.5rem 0; +} + +.rpg-skills-grid .rpg-skill-card { + flex-direction: column; + align-items: stretch; + padding: 1rem 0.75rem; + min-height: 100px; +} + +.rpg-skills-grid .rpg-skill-info { + align-items: center; + text-align: center; +} + +.rpg-skills-grid .rpg-skill-header-row { + flex-direction: column; + gap: 0.5rem; +} + +.rpg-skills-grid .rpg-skill-name { + text-align: center; + word-wrap: break-word; + overflow-wrap: break-word; + white-space: normal; + max-width: 100%; +} + +.rpg-skills-grid .rpg-skill-actions { + flex-direction: column; + width: 100%; +} + +.rpg-skills-grid .rpg-skill-action { + width: 100%; +} + +/* Quick View - Compact List */ +.rpg-skills-quick-list { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem 0; +} + +.rpg-skills-quick-list .rpg-skill-card { + padding: 0.5rem 0.75rem; + gap: 0.5rem; +} + +.rpg-skills-quick-list .rpg-skill-info { + gap: 0; +} + +.rpg-skills-quick-list .rpg-xp-bar { + display: none; +} + +.rpg-skills-quick-list .rpg-skill-action { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + +/* Inline Forms for Skills and Categories */ +.rpg-add-skill-form, +.rpg-add-category-form { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + background: var(--SmartThemeQuoteColor); + border: 1px solid var(--ac-style-color-matchedText); + border-radius: 0.25rem; + margin-bottom: 0.75rem; +} + +/* Header Actions (View Toggle + Add Button) */ +.rpg-skills-header-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: nowrap; + min-width: fit-content; +} + +/* Sort and Filter Controls */ +.rpg-skills-controls { + display: flex; + gap: 0.75rem; + align-items: center; + flex-wrap: wrap; + margin-bottom: 0.75rem; +} + +.rpg-sort-dropdown { + padding: 0.4rem 0.75rem; + background: var(--SmartThemeBlurTintColor); + border: 1px solid var(--SmartThemeBorderColor); + border-radius: 0.25rem; + color: var(--SmartThemeBodyColor); + font-size: 0.85rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.rpg-sort-dropdown:hover { + border-color: var(--ac-style-color-matchedText); +} + +.rpg-sort-dropdown:focus { + outline: none; + border-color: var(--ac-style-color-matchedText); + box-shadow: 0 0 0 2px rgba(var(--ac-style-color-matchedText-rgb, 66, 135, 245), 0.2); +} + +/* Responsive Classes - Wide Layout */ +.rpg-skills-wide .rpg-skills-header h4 { + font-size: 1.2rem; +} + +.rpg-skills-wide .rpg-skill-card { + padding: 1rem 1.25rem; +} + +/* Responsive Classes - Compact Layout */ +.rpg-skills-compact .rpg-skills-header h4 { + font-size: 1rem; +} + +.rpg-skills-compact .rpg-skill-card { + padding: 0.5rem 0.75rem; + gap: 0.75rem; +} + +.rpg-skills-compact .rpg-skills-add-btn { + font-size: 0.8rem; + padding: 0.35rem 0.6rem; +} + +.rpg-skills-compact .rpg-skill-action { + padding: 0.25rem 0.5rem; + font-size: 0.8rem; +} + +.rpg-skills-compact .rpg-xp-bar { + height: 1rem; +} + +.rpg-skills-compact .rpg-xp-text { + font-size: 0.7rem; +} + +/* Mobile Responsiveness for Skills */ +@media (max-width: 768px) { + .rpg-skills-subtabs { + gap: 0.35rem; + } + + .rpg-skills-subtab { + padding: 0.4rem 0.75rem; + font-size: 0.85rem; + } + + .rpg-skills-subtab .rpg-subtab-label { + display: none; + } + + .rpg-skills-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 0.5rem; + } + + .rpg-skills-header { + flex-wrap: wrap; + } + + .rpg-skills-header h4 { + font-size: 1rem; + } + + .rpg-skill-card { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .rpg-skill-actions { + width: 100%; + flex-wrap: wrap; + } + + .rpg-skill-action { + flex: 1; + min-width: fit-content; + } +} + /* ============================================ DESKTOP TABS SYSTEM ============================================ */