Backend: add weapon_time and melee_defense_bonus to search CTE. Frontend: show Speed, Attack Bonus (+%), Melee Def (+%) columns visible by default. Material/Workmanship hidden by default. Attack bonus and melee defense shown as percentage offset from 1.0. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
916 lines
35 KiB
JavaScript
916 lines
35 KiB
JavaScript
/**
|
|
* Inventory Search Application
|
|
* Dedicated JavaScript for the inventory search page
|
|
*/
|
|
|
|
// Configuration - use main app proxy for inventory service
|
|
const API_BASE = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
|
|
? 'http://localhost:8766' // Local development - direct to inventory service
|
|
: `${window.location.origin}/inv`; // Production - through main app proxy
|
|
|
|
// DOM Elements - will be set after DOM loads
|
|
let searchForm, clearBtn, searchResults;
|
|
|
|
// Sorting state
|
|
let currentSort = {
|
|
field: 'name',
|
|
direction: 'asc'
|
|
};
|
|
|
|
// Store current search results for client-side sorting
|
|
let currentResultsData = null;
|
|
|
|
// Items limit - load all items for client-side sorting (backend max is 50000)
|
|
let currentPage = 1;
|
|
let itemsPerPage = 50000;
|
|
|
|
// Initialize the application
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Get DOM elements after DOM is loaded
|
|
searchForm = document.getElementById('inventorySearchForm');
|
|
clearBtn = document.getElementById('clearBtn');
|
|
searchResults = document.getElementById('searchResults');
|
|
|
|
|
|
initializeEventListeners();
|
|
loadCharacterOptions();
|
|
renderColumnToggles();
|
|
});
|
|
|
|
/**
|
|
* Initialize all event listeners
|
|
*/
|
|
function initializeEventListeners() {
|
|
// Form submission
|
|
searchForm.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await performSearch();
|
|
});
|
|
|
|
// Clear button
|
|
clearBtn.addEventListener('click', clearAllFields);
|
|
|
|
// Slot filter change
|
|
document.getElementById('slotFilter').addEventListener('change', handleSlotFilterChange);
|
|
|
|
// Set analysis buttons
|
|
document.getElementById('setAnalysisBtn').addEventListener('click', showSetAnalysis);
|
|
document.getElementById('backToSearch').addEventListener('click', showSearchSection);
|
|
document.getElementById('runSetAnalysis').addEventListener('click', performSetAnalysis);
|
|
|
|
// Show/hide weapon type dropdown when equipment type changes
|
|
document.querySelectorAll('input[name="equipmentType"]').forEach(radio => {
|
|
radio.addEventListener('change', (e) => {
|
|
const weaponGroup = document.getElementById('weaponTypeGroup');
|
|
weaponGroup.style.display = e.target.value === 'weapon' ? '' : 'none';
|
|
if (e.target.value !== 'weapon') {
|
|
document.getElementById('weaponTypeFilter').value = '';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Checkbox visual feedback for cantrips and equipment sets
|
|
document.querySelectorAll('.checkbox-item input[type="checkbox"]').forEach(checkbox => {
|
|
checkbox.addEventListener('change', handleCheckboxChange);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load available characters for the checkbox list
|
|
*/
|
|
async function loadCharacterOptions() {
|
|
try {
|
|
// Use inventory service proxy endpoint for character list
|
|
const response = await fetch(`${window.location.origin}/inventory-characters`);
|
|
const data = await response.json();
|
|
|
|
if (data.characters && data.characters.length > 0) {
|
|
const characterList = document.getElementById('characterList');
|
|
|
|
// Sort characters by name
|
|
data.characters.sort((a, b) => a.character_name.localeCompare(b.character_name));
|
|
|
|
// Add character checkboxes
|
|
data.characters.forEach(char => {
|
|
const div = document.createElement('div');
|
|
div.className = 'character-item';
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.id = `char_${char.character_name}`;
|
|
checkbox.value = char.character_name;
|
|
checkbox.className = 'character-checkbox';
|
|
checkbox.checked = true; // Check all by default
|
|
|
|
const label = document.createElement('label');
|
|
label.htmlFor = checkbox.id;
|
|
label.textContent = char.character_name;
|
|
|
|
div.appendChild(checkbox);
|
|
div.appendChild(label);
|
|
characterList.appendChild(div);
|
|
});
|
|
|
|
// Set up event listeners for character selection
|
|
setupCharacterCheckboxes();
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not load character list:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Setup character checkbox functionality
|
|
*/
|
|
function setupCharacterCheckboxes() {
|
|
const allCheckbox = document.getElementById('char_all');
|
|
const characterCheckboxes = document.querySelectorAll('.character-checkbox');
|
|
|
|
// Handle "All Characters" checkbox
|
|
allCheckbox.addEventListener('change', function() {
|
|
characterCheckboxes.forEach(cb => {
|
|
cb.checked = this.checked;
|
|
});
|
|
});
|
|
|
|
// Handle individual character checkboxes
|
|
characterCheckboxes.forEach(cb => {
|
|
cb.addEventListener('change', function() {
|
|
// If any individual checkbox is unchecked, uncheck "All"
|
|
if (!this.checked) {
|
|
allCheckbox.checked = false;
|
|
} else {
|
|
// If all individual checkboxes are checked, check "All"
|
|
const allChecked = Array.from(characterCheckboxes).every(checkbox => checkbox.checked);
|
|
allCheckbox.checked = allChecked;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle checkbox change events for visual feedback
|
|
*/
|
|
function handleCheckboxChange(e) {
|
|
const item = e.target.closest('.checkbox-item');
|
|
if (e.target.checked) {
|
|
item.classList.add('checked');
|
|
} else {
|
|
item.classList.remove('checked');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all form fields and checkboxes
|
|
*/
|
|
function clearAllFields() {
|
|
searchForm.reset();
|
|
|
|
// Reset character selection to "All"
|
|
document.getElementById('char_all').checked = true;
|
|
document.querySelectorAll('.character-checkbox').forEach(cb => {
|
|
cb.checked = true;
|
|
});
|
|
|
|
// Clear checkbox visual states
|
|
document.querySelectorAll('.checkbox-item').forEach(item => {
|
|
item.classList.remove('checked');
|
|
});
|
|
|
|
// Reset equipment type to armor
|
|
document.getElementById('armorOnly').checked = true;
|
|
|
|
// Reset slot filter and weapon type
|
|
document.getElementById('slotFilter').value = '';
|
|
document.getElementById('weaponTypeFilter').value = '';
|
|
document.getElementById('weaponTypeGroup').style.display = 'none';
|
|
|
|
// Clear item state checkboxes (not covered by form.reset for standalone checkboxes)
|
|
['searchBonded', 'searchAttuned', 'searchIsRare'].forEach(id => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.checked = false;
|
|
});
|
|
|
|
// Reset page
|
|
currentPage = 1;
|
|
|
|
// Reset results and clear stored data
|
|
currentResultsData = null;
|
|
searchResults.innerHTML = '<div class="no-results">Enter search criteria above and click "Search Items" to find inventory items.</div>';
|
|
}
|
|
|
|
/**
|
|
* Handle slot filter changes
|
|
*/
|
|
function handleSlotFilterChange() {
|
|
// If we have current results, reapply filtering and sorting
|
|
if (currentResultsData) {
|
|
// Reset items to original unfiltered data
|
|
const originalData = JSON.parse(JSON.stringify(currentResultsData));
|
|
|
|
// Apply slot filtering
|
|
applySlotFilter(originalData);
|
|
|
|
// Apply sorting
|
|
sortResults(originalData);
|
|
|
|
// Display results
|
|
displayResults(originalData);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Perform the search based on form inputs
|
|
*/
|
|
async function performSearch() {
|
|
currentPage = 1;
|
|
searchResults.innerHTML = '<div class="loading">🔍 Searching inventory...</div>';
|
|
|
|
try {
|
|
const params = buildSearchParameters();
|
|
const searchUrl = `${API_BASE}/search/items?${params.toString()}`;
|
|
console.log('Search URL:', searchUrl);
|
|
|
|
const response = await fetch(searchUrl);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.detail || 'Search failed');
|
|
}
|
|
|
|
// Store results for client-side re-sorting
|
|
currentResultsData = data;
|
|
|
|
// Apply client-side slot filtering
|
|
applySlotFilter(data);
|
|
|
|
// Display results (already sorted by server)
|
|
displayResults(data);
|
|
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
searchResults.innerHTML = `<div class="error">❌ Search failed: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build search parameters from form inputs
|
|
*/
|
|
function buildSearchParameters() {
|
|
const params = new URLSearchParams();
|
|
|
|
// Equipment type selection
|
|
const equipmentType = document.querySelector('input[name="equipmentType"]:checked').value;
|
|
if (equipmentType === 'armor') {
|
|
params.append('armor_only', 'true');
|
|
} else if (equipmentType === 'jewelry') {
|
|
params.append('jewelry_only', 'true');
|
|
} else if (equipmentType === 'shirt') {
|
|
params.append('shirt_only', 'true');
|
|
} else if (equipmentType === 'pants') {
|
|
params.append('pants_only', 'true');
|
|
} else if (equipmentType === 'weapon') {
|
|
params.append('weapon_only', 'true');
|
|
const weaponType = document.getElementById('weaponTypeFilter')?.value;
|
|
if (weaponType) {
|
|
params.append('weapon_type', weaponType);
|
|
}
|
|
} else if (equipmentType === 'clothing') {
|
|
params.append('clothing_only', 'true');
|
|
}
|
|
// If 'all' is selected, don't add any type filter
|
|
|
|
// Basic search parameters - handle character selection
|
|
const allCharactersChecked = document.getElementById('char_all').checked;
|
|
if (!allCharactersChecked) {
|
|
// Get selected characters
|
|
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
|
|
.map(cb => cb.value);
|
|
|
|
if (selectedCharacters.length === 1) {
|
|
// Single character selected
|
|
params.append('character', selectedCharacters[0]);
|
|
} else if (selectedCharacters.length > 1) {
|
|
// Multiple characters - use comma-separated list
|
|
params.append('characters', selectedCharacters.join(','));
|
|
} else {
|
|
// No characters selected - search nothing
|
|
return { items: [], total_count: 0, page: 1, total_pages: 0 };
|
|
}
|
|
} else {
|
|
// All characters selected
|
|
params.append('include_all_characters', 'true');
|
|
}
|
|
addParam(params, 'text', 'searchText');
|
|
addParam(params, 'material', 'searchMaterial');
|
|
|
|
const equipStatus = document.getElementById('searchEquipStatus').value;
|
|
if (equipStatus && equipStatus !== 'all') {
|
|
params.append('equipment_status', equipStatus);
|
|
}
|
|
|
|
// Armor statistics parameters
|
|
addParam(params, 'min_armor', 'searchMinArmor');
|
|
addParam(params, 'max_armor', 'searchMaxArmor');
|
|
addParam(params, 'min_crit_damage_rating', 'searchMinCritDamage');
|
|
addParam(params, 'max_crit_damage_rating', 'searchMaxCritDamage');
|
|
addParam(params, 'min_damage_rating', 'searchMinDamageRating');
|
|
addParam(params, 'max_damage_rating', 'searchMaxDamageRating');
|
|
addParam(params, 'min_heal_boost_rating', 'searchMinHealBoost');
|
|
addParam(params, 'max_heal_boost_rating', 'searchMaxHealBoost');
|
|
addParam(params, 'min_vitality_rating', 'searchMinVitalityRating');
|
|
addParam(params, 'min_damage_resist_rating', 'searchMinDamageResistRating');
|
|
addParam(params, 'min_crit_damage_resist_rating', 'searchMinCritDamageResistRating');
|
|
|
|
// Weapon / combat stats
|
|
addParam(params, 'min_damage', 'searchMinDamage');
|
|
addParam(params, 'max_damage', 'searchMaxDamage');
|
|
addParam(params, 'min_attack_bonus', 'searchMinAttackBonus');
|
|
addParam(params, 'min_crit_resist_rating', 'searchMinCritResistRating');
|
|
addParam(params, 'min_tinks', 'searchMinTinks');
|
|
|
|
// Requirements parameters
|
|
addParam(params, 'min_level', 'searchMinLevel');
|
|
addParam(params, 'max_level', 'searchMaxLevel');
|
|
addParam(params, 'min_workmanship', 'searchMinWorkmanship');
|
|
addParam(params, 'max_workmanship', 'searchMaxWorkmanship');
|
|
|
|
// Value parameters
|
|
addParam(params, 'min_value', 'searchMinValue');
|
|
addParam(params, 'max_value', 'searchMaxValue');
|
|
addParam(params, 'max_burden', 'searchMaxBurden');
|
|
|
|
// Spell text search
|
|
addParam(params, 'spell_contains', 'searchSpellContains');
|
|
|
|
// Item state filters (only send when checked)
|
|
if (document.getElementById('searchBonded')?.checked) {
|
|
params.append('bonded', 'true');
|
|
}
|
|
if (document.getElementById('searchAttuned')?.checked) {
|
|
params.append('attuned', 'true');
|
|
}
|
|
if (document.getElementById('searchIsRare')?.checked) {
|
|
params.append('is_rare', 'true');
|
|
}
|
|
|
|
// Equipment set filters
|
|
const selectedEquipmentSets = getSelectedEquipmentSets();
|
|
if (selectedEquipmentSets.length === 1) {
|
|
params.append('item_set', selectedEquipmentSets[0]);
|
|
} else if (selectedEquipmentSets.length > 1) {
|
|
params.append('item_sets', selectedEquipmentSets.join(','));
|
|
}
|
|
|
|
// Cantrip filters
|
|
const selectedCantrips = getSelectedCantrips();
|
|
const selectedProtections = getSelectedProtections();
|
|
const allSpells = [...selectedCantrips, ...selectedProtections];
|
|
if (allSpells.length > 0) {
|
|
params.append('legendary_cantrips', allSpells.join(','));
|
|
}
|
|
|
|
// Equipment slot filters
|
|
const selectedSlots = getSelectedSlots();
|
|
if (selectedSlots.length > 0) {
|
|
params.append('slot_names', selectedSlots.join(','));
|
|
}
|
|
|
|
// Pagination parameters
|
|
params.append('page', currentPage);
|
|
params.append('limit', itemsPerPage);
|
|
|
|
// Sorting parameters
|
|
params.append('sort_by', currentSort.field);
|
|
params.append('sort_dir', currentSort.direction);
|
|
|
|
return params;
|
|
}
|
|
|
|
/**
|
|
* Helper function to add parameter if value exists
|
|
*/
|
|
function addParam(params, paramName, elementId) {
|
|
const value = document.getElementById(elementId)?.value?.trim();
|
|
if (value) {
|
|
params.append(paramName, value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get selected equipment sets
|
|
*/
|
|
function getSelectedEquipmentSets() {
|
|
const selectedSets = [];
|
|
document.querySelectorAll('#equipmentSets input[type="checkbox"]:checked').forEach(cb => {
|
|
selectedSets.push(cb.value);
|
|
});
|
|
return selectedSets;
|
|
}
|
|
|
|
/**
|
|
* Get selected legendary cantrips
|
|
*/
|
|
function getSelectedCantrips() {
|
|
const selectedCantrips = [];
|
|
document.querySelectorAll('#cantrips input[type="checkbox"]:checked').forEach(cb => {
|
|
selectedCantrips.push(cb.value);
|
|
});
|
|
return selectedCantrips;
|
|
}
|
|
|
|
/**
|
|
* Get selected protection spells
|
|
*/
|
|
function getSelectedProtections() {
|
|
const selectedProtections = [];
|
|
document.querySelectorAll('#protections input[type="checkbox"]:checked').forEach(cb => {
|
|
selectedProtections.push(cb.value);
|
|
});
|
|
return selectedProtections;
|
|
}
|
|
|
|
/**
|
|
* Get selected equipment slots from checkboxes
|
|
*/
|
|
function getSelectedSlots() {
|
|
const selectedSlots = [];
|
|
// Get all selected slot checkboxes from the all-slots container
|
|
document.querySelectorAll('#all-slots input[type="checkbox"]:checked').forEach(cb => {
|
|
selectedSlots.push(cb.value);
|
|
});
|
|
return selectedSlots;
|
|
}
|
|
|
|
// Column definitions — single source of truth for headers, data, and visibility
|
|
const RESULT_COLUMNS = [
|
|
{ key: 'character_name', label: 'Character', sort: 'character_name', defaultVisible: true },
|
|
{ key: 'status', label: 'Status', sort: 'is_equipped', defaultVisible: true,
|
|
render: item => { const s = item.is_equipped; return `<td class="${s ? 'status-equipped' : 'status-inventory'}">${s ? '⚔️ Equipped' : '📦 Inventory'}</td>`; } },
|
|
{ key: 'name', label: 'Item Name', sort: 'name', defaultVisible: true, cls: 'item-name',
|
|
render: item => { let n = item.name; if (item.material_name && item.material_name !== '' && !n.toLowerCase().includes(item.material_name.toLowerCase())) n = `${item.material_name} ${n}`; return `<td class="item-name">${n}</td>`; } },
|
|
{ key: 'item_type_name', label: 'Type', sort: 'item_type_name', defaultVisible: true },
|
|
{ key: 'slot_name', label: 'Slot', sort: 'slot_name', defaultVisible: true, cls: 'text-right narrow-col',
|
|
render: item => `<td class="text-right narrow-col">${item.slot_name ? item.slot_name.replace(/,\s*/g, '<br>') : '-'}</td>` },
|
|
{ key: 'coverage', label: 'Coverage', sort: 'coverage', defaultVisible: false, cls: 'text-right narrow-col',
|
|
render: item => `<td class="text-right narrow-col">${item.coverage ? item.coverage.replace(/,\s*/g, '<br>') : '-'}</td>` },
|
|
{ key: 'armor_level', label: 'Armor', sort: 'armor', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.armor_level > 0 ? item.armor_level : '-'}</td>` },
|
|
{ key: 'max_damage', label: 'Max Dmg', sort: 'max_damage', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.max_damage > 0 ? item.max_damage : '-'}</td>` },
|
|
{ key: 'weapon_time', label: 'Speed', sort: 'weapon_time', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.weapon_time > 0 ? item.weapon_time : '-'}</td>` },
|
|
{ key: 'attack_bonus', label: 'Attack Bonus', sort: 'attack_bonus', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.attack_bonus > 0 ? '+' + ((item.attack_bonus - 1) * 100).toFixed(0) + '%' : '-'}</td>` },
|
|
{ key: 'melee_defense_bonus', label: 'Melee Def', sort: 'melee_defense_bonus', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.melee_defense_bonus > 0 ? '+' + ((item.melee_defense_bonus - 1) * 100).toFixed(0) + '%' : '-'}</td>` },
|
|
{ key: 'material_name', label: 'Material', sort: 'material_name', defaultVisible: false },
|
|
{ key: 'workmanship', label: 'Wkm', sort: 'workmanship', defaultVisible: false, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.workmanship > 0 ? item.workmanship : '-'}</td>` },
|
|
{ key: 'item_set', label: 'Set', sort: 'item_set', defaultVisible: true, cls: 'set-col',
|
|
render: item => { let s = '-'; if (item.item_set_name) { s = item.item_set_name.replace(/\s+Set$/i, ''); } else if (item.item_set) { s = item.item_set.replace(/^Set\s+/i, '').replace(/\s+Set$/i, ''); } return `<td class="set-col" title="${s}">${s}</td>`; } },
|
|
{ key: 'spell_names', label: 'Spells/Cantrips', sort: 'spell_names', defaultVisible: true, cls: 'spells-cell medium-col',
|
|
render: item => { if (!item.spell_names || item.spell_names.length === 0) return '<td class="spells-cell medium-col">-</td>'; const f = item.spell_names.map(sp => sp.toLowerCase().includes('legendary') ? `<span class="legendary-cantrip">${sp}</span>` : `<span class="regular-spell">${sp}</span>`); return `<td class="spells-cell medium-col">${f.join('<br>')}</td>`; } },
|
|
{ key: 'crit_damage_rating', label: 'Crit Dmg', sort: 'crit_damage_rating', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.crit_damage_rating > 0 ? item.crit_damage_rating : '-'}</td>` },
|
|
{ key: 'damage_rating', label: 'Dmg Rating', sort: 'damage_rating', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.damage_rating > 0 ? item.damage_rating : '-'}</td>` },
|
|
{ key: 'heal_boost_rating', label: 'Heal Boost', sort: 'heal_boost_rating', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.heal_boost_rating > 0 ? item.heal_boost_rating : '-'}</td>` },
|
|
{ key: 'vitality_rating', label: 'Vitality', sort: 'vitality_rating', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.vitality_rating > 0 ? item.vitality_rating : '-'}</td>` },
|
|
{ key: 'damage_resist_rating', label: 'Dmg Resist', sort: 'damage_resist_rating', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.damage_resist_rating > 0 ? item.damage_resist_rating : '-'}</td>` },
|
|
{ key: 'crit_damage_resist_rating', label: 'Crit Dmg Resist', sort: 'crit_damage_resist_rating', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.crit_damage_resist_rating > 0 ? item.crit_damage_resist_rating : '-'}</td>` },
|
|
{ key: 'last_updated', label: 'Last Updated', sort: 'last_updated', defaultVisible: true, cls: 'text-right',
|
|
render: item => `<td class="text-right">${item.last_updated ? new Date(item.last_updated).toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }) : '-'}</td>` },
|
|
];
|
|
|
|
// Load column visibility from localStorage or use defaults
|
|
function getColumnVisibility() {
|
|
try {
|
|
const saved = localStorage.getItem('inventoryColumnVisibility');
|
|
if (saved) return JSON.parse(saved);
|
|
} catch (e) {}
|
|
const vis = {};
|
|
RESULT_COLUMNS.forEach(c => vis[c.key] = c.defaultVisible);
|
|
return vis;
|
|
}
|
|
let columnVisibility = getColumnVisibility();
|
|
|
|
function saveColumnVisibility() {
|
|
localStorage.setItem('inventoryColumnVisibility', JSON.stringify(columnVisibility));
|
|
}
|
|
|
|
function renderColumnToggles() {
|
|
const container = document.getElementById('columnToggles');
|
|
if (!container) return;
|
|
container.innerHTML = '';
|
|
RESULT_COLUMNS.forEach(col => {
|
|
const label = document.createElement('label');
|
|
label.style.cssText = 'display:inline-flex;align-items:center;gap:2px;font-size:9px;margin-right:6px;cursor:pointer;';
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.checked = columnVisibility[col.key] !== false;
|
|
cb.style.cssText = 'width:10px;height:10px;margin:0;';
|
|
cb.addEventListener('change', () => {
|
|
columnVisibility[col.key] = cb.checked;
|
|
saveColumnVisibility();
|
|
if (currentResultsData) displayResults(currentResultsData);
|
|
});
|
|
label.appendChild(cb);
|
|
label.appendChild(document.createTextNode(col.label));
|
|
container.appendChild(label);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Display search results in the UI
|
|
*/
|
|
function displayResults(data) {
|
|
if (!data.items || data.items.length === 0) {
|
|
searchResults.innerHTML = '<div class="no-results">No items found matching your search criteria.</div>';
|
|
return;
|
|
}
|
|
|
|
const visibleCols = RESULT_COLUMNS.filter(c => columnVisibility[c.key] !== false);
|
|
|
|
const getSortIcon = (field) => {
|
|
if (currentSort.field === field) {
|
|
return currentSort.direction === 'asc' ? ' ▲' : ' ▼';
|
|
}
|
|
return '';
|
|
};
|
|
|
|
let html = `
|
|
<div class="results-info">
|
|
Found <strong>${data.total_count}</strong> items - Showing all results
|
|
</div>
|
|
<table class="results-table">
|
|
<thead>
|
|
<tr>
|
|
${visibleCols.map(c => `<th class="${c.cls || ''} sortable" data-sort="${c.sort}">${c.label}${getSortIcon(c.sort)}</th>`).join('\n ')}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
data.items.forEach((item) => {
|
|
html += '<tr>';
|
|
visibleCols.forEach(col => {
|
|
if (col.render) {
|
|
html += col.render(item);
|
|
} else {
|
|
const val = item[col.key];
|
|
html += `<td class="${col.cls || ''}">${val != null && val !== '' && val !== -1 ? val : '-'}</td>`;
|
|
}
|
|
});
|
|
html += '</tr>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
searchResults.innerHTML = html;
|
|
|
|
// Add click event listeners to sortable headers
|
|
document.querySelectorAll('.sortable').forEach(header => {
|
|
header.addEventListener('click', () => {
|
|
const sortField = header.getAttribute('data-sort');
|
|
handleSort(sortField);
|
|
});
|
|
});
|
|
|
|
// Initialize resizable columns
|
|
initResizableColumns();
|
|
}
|
|
|
|
/**
|
|
* Initialize resizable columns for the results table
|
|
*/
|
|
function initResizableColumns() {
|
|
const table = document.querySelector('.results-table');
|
|
if (!table) return;
|
|
|
|
const headers = table.querySelectorAll('th');
|
|
|
|
headers.forEach((header, index) => {
|
|
// Skip last column (no need to resize)
|
|
if (index === headers.length - 1) return;
|
|
|
|
// Create resize handle
|
|
const resizeHandle = document.createElement('div');
|
|
resizeHandle.className = 'resize-handle';
|
|
header.appendChild(resizeHandle);
|
|
|
|
let startX, startWidth;
|
|
|
|
resizeHandle.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation(); // Prevent sorting when resizing
|
|
|
|
startX = e.pageX;
|
|
startWidth = header.offsetWidth;
|
|
|
|
header.classList.add('resizing');
|
|
resizeHandle.classList.add('resizing');
|
|
|
|
const onMouseMove = (e) => {
|
|
const diff = e.pageX - startX;
|
|
const newWidth = Math.max(30, startWidth + diff); // Minimum 30px width
|
|
header.style.width = newWidth + 'px';
|
|
header.style.minWidth = newWidth + 'px';
|
|
};
|
|
|
|
const onMouseUp = () => {
|
|
header.classList.remove('resizing');
|
|
resizeHandle.classList.remove('resizing');
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
|
|
// Save column widths to localStorage
|
|
saveColumnWidths();
|
|
};
|
|
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
});
|
|
});
|
|
|
|
// Restore saved column widths
|
|
restoreColumnWidths();
|
|
}
|
|
|
|
/**
|
|
* Save column widths to localStorage
|
|
*/
|
|
function saveColumnWidths() {
|
|
const headers = document.querySelectorAll('.results-table th');
|
|
const widths = [];
|
|
headers.forEach(header => {
|
|
widths.push(header.style.width || '');
|
|
});
|
|
localStorage.setItem('inventoryColumnWidths', JSON.stringify(widths));
|
|
}
|
|
|
|
/**
|
|
* Restore column widths from localStorage
|
|
*/
|
|
function restoreColumnWidths() {
|
|
const saved = localStorage.getItem('inventoryColumnWidths');
|
|
if (!saved) return;
|
|
|
|
try {
|
|
const widths = JSON.parse(saved);
|
|
const headers = document.querySelectorAll('.results-table th');
|
|
headers.forEach((header, index) => {
|
|
if (widths[index]) {
|
|
header.style.width = widths[index];
|
|
header.style.minWidth = widths[index];
|
|
}
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to restore column widths:', e);
|
|
}
|
|
}
|
|
/**
|
|
* Apply client-side slot filtering
|
|
*/
|
|
function applySlotFilter(data) {
|
|
const selectedSlot = document.getElementById('slotFilter').value;
|
|
|
|
if (!selectedSlot || !data.items) {
|
|
return; // No filter or no data
|
|
}
|
|
|
|
// Filter items that can be equipped in the selected slot
|
|
data.items = data.items.filter(item => {
|
|
const slotName = item.slot_name || '';
|
|
|
|
// Check if the item's slot_name contains the selected slot
|
|
// This handles multi-slot items like "Left Ring, Right Ring"
|
|
return slotName.includes(selectedSlot);
|
|
});
|
|
|
|
// Update total count
|
|
data.total_count = data.items.length;
|
|
}
|
|
|
|
/**
|
|
* Sort results client-side based on current sort settings
|
|
*/
|
|
function sortResults(data) {
|
|
if (!data.items || data.items.length === 0) return;
|
|
|
|
const field = currentSort.field;
|
|
const direction = currentSort.direction;
|
|
|
|
data.items.sort((a, b) => {
|
|
let aVal = a[field];
|
|
let bVal = b[field];
|
|
|
|
// Handle null/undefined values
|
|
if (aVal == null && bVal == null) return 0;
|
|
if (aVal == null) return 1;
|
|
if (bVal == null) return -1;
|
|
|
|
// Special handling for spell_names array
|
|
if (field === 'spell_names') {
|
|
// Convert arrays to strings for sorting
|
|
aVal = Array.isArray(aVal) ? aVal.join(', ').toLowerCase() : '';
|
|
bVal = Array.isArray(bVal) ? bVal.join(', ').toLowerCase() : '';
|
|
const result = aVal.localeCompare(bVal);
|
|
return direction === 'asc' ? result : -result;
|
|
}
|
|
|
|
// Determine if we're sorting numbers or strings
|
|
const isNumeric = typeof aVal === 'number' || (!isNaN(aVal) && !isNaN(parseFloat(aVal)));
|
|
|
|
if (isNumeric) {
|
|
// Numeric sorting
|
|
aVal = parseFloat(aVal) || 0;
|
|
bVal = parseFloat(bVal) || 0;
|
|
const result = aVal - bVal;
|
|
return direction === 'asc' ? result : -result;
|
|
} else {
|
|
// String sorting
|
|
aVal = String(aVal).toLowerCase();
|
|
bVal = String(bVal).toLowerCase();
|
|
const result = aVal.localeCompare(bVal);
|
|
return direction === 'asc' ? result : -result;
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle column sorting - client-side sorting for all columns
|
|
*/
|
|
function handleSort(field) {
|
|
// If clicking the same field, toggle direction
|
|
if (currentSort.field === field) {
|
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
|
} else {
|
|
// New field, default to ascending
|
|
currentSort.field = field;
|
|
currentSort.direction = 'asc';
|
|
}
|
|
|
|
// If we have cached results, sort them client-side and re-display
|
|
if (currentResultsData && currentResultsData.items) {
|
|
// Make a copy to preserve original order
|
|
const sortedData = JSON.parse(JSON.stringify(currentResultsData));
|
|
sortResults(sortedData);
|
|
displayResults(sortedData);
|
|
} else {
|
|
// No cached results, need to fetch first
|
|
performSearch();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show set analysis section
|
|
*/
|
|
function showSetAnalysis() {
|
|
document.getElementById('setAnalysisSection').style.display = 'block';
|
|
document.getElementById('searchResults').style.display = 'none';
|
|
searchForm.style.display = 'none';
|
|
}
|
|
|
|
/**
|
|
* Show search section
|
|
*/
|
|
function showSearchSection() {
|
|
document.getElementById('setAnalysisSection').style.display = 'none';
|
|
document.getElementById('searchResults').style.display = 'block';
|
|
searchForm.style.display = 'block';
|
|
}
|
|
|
|
/**
|
|
* Perform set combination analysis
|
|
*/
|
|
async function performSetAnalysis() {
|
|
const primarySet = document.getElementById('primarySetSelect').value;
|
|
const secondarySet = document.getElementById('secondarySetSelect').value;
|
|
const setAnalysisResults = document.getElementById('setAnalysisResults');
|
|
|
|
if (primarySet === secondarySet) {
|
|
setAnalysisResults.innerHTML = '<div class="error">❌ Primary and secondary sets must be different.</div>';
|
|
return;
|
|
}
|
|
|
|
setAnalysisResults.innerHTML = '<div class="loading">🔍 Analyzing set combinations...</div>';
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
params.append('primary_set', primarySet);
|
|
params.append('secondary_set', secondarySet);
|
|
params.append('primary_count', '5');
|
|
params.append('secondary_count', '4');
|
|
|
|
// Use selected characters or all characters
|
|
const allCharactersChecked = document.getElementById('char_all').checked;
|
|
if (allCharactersChecked) {
|
|
params.append('include_all_characters', 'true');
|
|
} else {
|
|
const selectedCharacters = Array.from(document.querySelectorAll('.character-checkbox:checked'))
|
|
.map(cb => cb.value);
|
|
if (selectedCharacters.length > 0) {
|
|
params.append('characters', selectedCharacters.join(','));
|
|
} else {
|
|
setAnalysisResults.innerHTML = '<div class="error">❌ Please select at least one character or check "All Characters".</div>';
|
|
return;
|
|
}
|
|
}
|
|
|
|
const analysisUrl = `${API_BASE}/analyze/sets?${params.toString()}`;
|
|
console.log('Set Analysis URL:', analysisUrl);
|
|
|
|
const response = await fetch(analysisUrl);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok) {
|
|
throw new Error(data.detail || 'Set analysis failed');
|
|
}
|
|
|
|
displaySetAnalysisResults(data);
|
|
|
|
} catch (error) {
|
|
console.error('Set analysis error:', error);
|
|
setAnalysisResults.innerHTML = `<div class="error">❌ Set analysis failed: ${error.message}</div>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display set analysis results
|
|
*/
|
|
function displaySetAnalysisResults(data) {
|
|
const setAnalysisResults = document.getElementById('setAnalysisResults');
|
|
|
|
if (!data.character_analysis || data.character_analysis.length === 0) {
|
|
setAnalysisResults.innerHTML = '<div class="no-results">No characters found with the selected sets.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = `
|
|
<div class="results-info">
|
|
<strong>${data.primary_set.name}</strong> (${data.primary_set.pieces_needed} pieces) +
|
|
<strong>${data.secondary_set.name}</strong> (${data.secondary_set.pieces_needed} pieces)<br>
|
|
Found <strong>${data.characters_can_build}</strong> of <strong>${data.total_characters}</strong> characters who can build this combination
|
|
</div>
|
|
<table class="results-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Character</th>
|
|
<th>Can Build?</th>
|
|
<th>${data.primary_set.name}</th>
|
|
<th>${data.secondary_set.name}</th>
|
|
<th>Primary Items</th>
|
|
<th>Secondary Items</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
data.character_analysis.forEach((char) => {
|
|
const canBuild = char.can_build_combination;
|
|
const canBuildText = canBuild ? '✅ Yes' : '❌ No';
|
|
const canBuildClass = canBuild ? 'status-equipped' : 'status-inventory';
|
|
|
|
const primaryStatus = `${char.primary_set_available}/${char.primary_set_needed}`;
|
|
const secondaryStatus = `${char.secondary_set_available}/${char.secondary_set_needed}`;
|
|
|
|
// Format item lists
|
|
const primaryItems = char.primary_items.map(item =>
|
|
`${item.name}${item.equipped ? ' ⚔️' : ''}`
|
|
).join('<br>') || '-';
|
|
|
|
const secondaryItems = char.secondary_items.map(item =>
|
|
`${item.name}${item.equipped ? ' ⚔️' : ''}`
|
|
).join('<br>') || '-';
|
|
|
|
html += `
|
|
<tr>
|
|
<td><strong>${char.character_name}</strong></td>
|
|
<td class="${canBuildClass}">${canBuildText}</td>
|
|
<td class="text-right">${primaryStatus}</td>
|
|
<td class="text-right">${secondaryStatus}</td>
|
|
<td class="spells-cell">${primaryItems}</td>
|
|
<td class="spells-cell">${secondaryItems}</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
|
|
setAnalysisResults.innerHTML = html;
|
|
}
|
|
|
|
// Pagination functions removed - using client-side sorting with all results loaded
|