Add client-side spell column sorting and improve inventory search

- Implement client-side sorting for all columns including spell_names
- Add computed_spell_names CTE for server-side sort fallback
- Add resizable columns with localStorage persistence
- Add Cloak slot detection by name pattern
- Increase items limit to 50000 for full inventory loading
- Increase proxy timeout to 60s for large queries
- Remove pagination (all items loaded at once for sorting)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
erik 2026-01-28 15:32:54 +00:00
parent 25980edf99
commit 8cae94d87d
6 changed files with 294 additions and 111 deletions

View file

@ -2188,7 +2188,7 @@
"dictionaries": { "dictionaries": {
"AttributeSetInfo": { "AttributeSetInfo": {
"name": "AttributeSetInfo", "name": "AttributeSetInfo",
"description": "Equipment set names", "description": "Equipment set names (complete from Dictionaries.cs)",
"values": { "values": {
"2": "Test", "2": "Test",
"4": "Carraida's Benediction", "4": "Carraida's Benediction",
@ -2210,7 +2210,7 @@
"20": "Dexterous Set", "20": "Dexterous Set",
"21": "Wise Set", "21": "Wise Set",
"22": "Swift Set", "22": "Swift Set",
"23": "Hardenend Set", "23": "Hardened Set",
"24": "Reinforced Set", "24": "Reinforced Set",
"25": "Interlocking Set", "25": "Interlocking Set",
"26": "Flame Proof Set", "26": "Flame Proof Set",
@ -2232,15 +2232,102 @@
"42": "Olthoi Armor D Red", "42": "Olthoi Armor D Red",
"43": "Olthoi Armor C Rat", "43": "Olthoi Armor C Rat",
"44": "Olthoi Armor C Red", "44": "Olthoi Armor C Red",
"45": "Olthoi Armor F Red", "45": "Olthoi Armor D Rat",
"46": "Olthoi Armor K Red", "46": "Upgraded Relic Alduressa Set",
"47": "Olthoi Armor M Red", "47": "Upgraded Ancient Relic Set",
"48": "Olthoi Armor B Red", "48": "Upgraded Noble Relic Set",
"49": "Olthoi Armor B Rat", "49": "Weave of Alchemy",
"50": "Olthoi Armor K Rat", "50": "Weave of Arcane Lore",
"51": "Olthoi Armor M Rat", "51": "Weave of Armor Tinkering",
"52": "Olthoi Armor F Rat", "52": "Weave of Assess Person",
"53": "Olthoi Armor D Rat" "53": "Weave of Light Weapons",
"54": "Weave of Missile Weapons",
"55": "Weave of Cooking",
"56": "Weave of Creature Enchantment",
"57": "Weave of Missile Weapons",
"58": "Weave of Finesse",
"59": "Weave of Deception",
"60": "Weave of Fletching",
"61": "Weave of Healing",
"62": "Weave of Item Enchantment",
"63": "Weave of Item Tinkering",
"64": "Weave of Leadership",
"65": "Weave of Life Magic",
"66": "Weave of Loyalty",
"67": "Weave of Light Weapons",
"68": "Weave of Magic Defense",
"69": "Weave of Magic Item Tinkering",
"70": "Weave of Mana Conversion",
"71": "Weave of Melee Defense",
"72": "Weave of Missile Defense",
"73": "Weave of Salvaging",
"74": "Weave of Light Weapons",
"75": "Weave of Light Weapons",
"76": "Weave of Heavy Weapons",
"77": "Weave of Missile Weapons",
"78": "Weave of Two Handed Combat",
"79": "Weave of Light Weapons",
"80": "Weave of Void Magic",
"81": "Weave of War Magic",
"82": "Weave of Weapon Tinkering",
"83": "Weave of Assess Creature",
"84": "Weave of Dirty Fighting",
"85": "Weave of Dual Wield",
"86": "Weave of Recklessness",
"87": "Weave of Shield",
"88": "Weave of Sneak Attack",
"89": "Ninja_New",
"90": "Weave of Summoning",
"91": "Shrouded Soul",
"92": "Darkened Mind",
"93": "Clouded Spirit",
"94": "Minor Stinging Shrouded Soul",
"95": "Minor Sparking Shrouded Soul",
"96": "Minor Smoldering Shrouded Soul",
"97": "Minor Shivering Shrouded Soul",
"98": "Minor Stinging Darkened Mind",
"99": "Minor Sparking Darkened Mind",
"100": "Minor Smoldering Darkened Mind",
"101": "Minor Shivering Darkened Mind",
"102": "Minor Stinging Clouded Spirit",
"103": "Minor Sparking Clouded Spirit",
"104": "Minor Smoldering Clouded Spirit",
"105": "Minor Shivering Clouded Spirit",
"106": "Major Stinging Shrouded Soul",
"107": "Major Sparking Shrouded Soul",
"108": "Major Smoldering Shrouded Soul",
"109": "Major Shivering Shrouded Soul",
"110": "Major Stinging Darkened Mind",
"111": "Major Sparking Darkened Mind",
"112": "Major Smoldering Darkened Mind",
"113": "Major Shivering Darkened Mind",
"114": "Major Stinging Clouded Spirit",
"115": "Major Sparking Clouded Spirit",
"116": "Major Smoldering Clouded Spirit",
"117": "Major Shivering Clouded Spirit",
"118": "Blackfire Stinging Shrouded Soul",
"119": "Blackfire Sparking Shrouded Soul",
"120": "Blackfire Smoldering Shrouded Soul",
"121": "Blackfire Shivering Shrouded Soul",
"122": "Blackfire Stinging Darkened Mind",
"123": "Blackfire Sparking Darkened Mind",
"124": "Blackfire Smoldering Darkened Mind",
"125": "Blackfire Shivering Darkened Mind",
"126": "Blackfire Stinging Clouded Spirit",
"127": "Blackfire Sparking Clouded Spirit",
"128": "Blackfire Smoldering Clouded Spirit",
"129": "Blackfire Shivering Clouded Spirit",
"130": "Shimmering Shadows",
"131": "Brown Society Locket",
"132": "Yellow Society Locket",
"133": "Red Society Band",
"134": "Green Society Band",
"135": "Purple Society Band",
"136": "Blue Society Band",
"137": "Gauntlet Garb",
"138": "Paragon Missile Weapons",
"139": "Paragon Casters",
"140": "Paragon Melee Weapons"
} }
} }
}, },

View file

@ -2085,7 +2085,7 @@ async def search_items(
sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level"), sort_by: str = Query("name", description="Sort field: name, value, damage, armor, workmanship, damage_rating, crit_damage_rating, level"),
sort_dir: str = Query("asc", description="Sort direction: asc or desc"), sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
page: int = Query(1, ge=1, description="Page number"), page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(200, ge=1, le=10000, description="Items per page") limit: int = Query(200, ge=1, le=50000, description="Items per page")
): ):
""" """
Search items across characters with comprehensive filtering options. Search items across characters with comprehensive filtering options.
@ -2236,9 +2236,19 @@ async def search_items(
-- Check wielded location for two-handed weapons -- Check wielded location for two-handed weapons
WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed' WHEN i.current_wielded_location = 67108864 THEN 'Two-Handed'
-- CLOAKS: Identify by name pattern
WHEN i.name ILIKE '%cloak%' THEN 'Cloak'
-- DEFAULT -- DEFAULT
ELSE '-' ELSE '-'
END as computed_slot_name END as computed_slot_name,
-- Compute spell_names (spell IDs for client-side name lookup)
COALESCE(
(SELECT STRING_AGG(CAST(sp_inner.spell_id AS VARCHAR), ',' ORDER BY sp_inner.spell_id)
FROM item_spells sp_inner WHERE sp_inner.item_id = i.id),
''
) as computed_spell_names
FROM items i FROM items i
LEFT JOIN item_combat_stats cs ON i.id = cs.item_id LEFT JOIN item_combat_stats cs ON i.id = cs.item_id
@ -2495,6 +2505,10 @@ async def search_items(
slot_approaches.append("(object_class = 11 AND name ILIKE '%trinket%')") slot_approaches.append("(object_class = 11 AND name ILIKE '%trinket%')")
# 4. Jewelry fallback: items that don't match other jewelry patterns # 4. Jewelry fallback: items that don't match other jewelry patterns
slot_approaches.append("(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')") slot_approaches.append("(object_class = 4 AND name NOT ILIKE '%ring%' AND name NOT ILIKE '%bracelet%' AND name NOT ILIKE '%amulet%' AND name NOT ILIKE '%necklace%' AND name NOT ILIKE '%gorget%')")
elif slot_name.lower() == 'cloak':
# For cloaks: identify by name pattern
slot_approaches.append("(name ILIKE '%cloak%')")
slot_approaches.append("(computed_slot_name = 'Cloak')")
# Combine approaches with OR (any approach can match) # Combine approaches with OR (any approach can match)
if slot_approaches: if slot_approaches:
@ -2663,9 +2677,11 @@ async def search_items(
# Add ORDER BY # Add ORDER BY
sort_mapping = { sort_mapping = {
"name": "name", "name": "name",
"character_name": "character_name",
"value": "value", "value": "value",
"damage": "max_damage", "damage": "max_damage",
"armor": "armor_level", "armor": "armor_level",
"armor_level": "armor_level",
"workmanship": "workmanship", "workmanship": "workmanship",
"level": "wield_level", "level": "wield_level",
"damage_rating": "damage_rating", "damage_rating": "damage_rating",
@ -2673,11 +2689,20 @@ async def search_items(
"heal_boost_rating": "heal_boost_rating", "heal_boost_rating": "heal_boost_rating",
"vitality_rating": "vitality_rating", "vitality_rating": "vitality_rating",
"damage_resist_rating": "damage_resist_rating", "damage_resist_rating": "damage_resist_rating",
"crit_damage_resist_rating": "crit_damage_resist_rating" "crit_damage_resist_rating": "crit_damage_resist_rating",
"item_set": "item_set",
"slot_name": "computed_slot_name",
"coverage": "coverage_mask",
"item_type_name": "object_class",
"last_updated": "timestamp",
"spell_names": "computed_spell_names"
} }
sort_field = sort_mapping.get(sort_by, "name") sort_field = sort_mapping.get(sort_by, "name")
sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC" sort_direction = "DESC" if sort_dir.lower() == "desc" else "ASC"
query_parts.append(f"ORDER BY {sort_field} {sort_direction}")
# Handle NULLS for optional fields
nulls_clause = "NULLS LAST" if sort_direction == "ASC" else "NULLS FIRST"
query_parts.append(f"ORDER BY {sort_field} {sort_direction} {nulls_clause}")
# Add pagination # Add pagination
offset = (page - 1) * limit offset = (page - 1) * limit

View file

@ -2275,8 +2275,8 @@ async def proxy_inventory_service(path: str, request: Request):
inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000') inventory_service_url = os.getenv('INVENTORY_SERVICE_URL', 'http://inventory-service:8000')
logger.info(f"Proxying to inventory service: {inventory_service_url}/{path}") logger.info(f"Proxying to inventory service: {inventory_service_url}/{path}")
# Forward the request to inventory service # Forward the request to inventory service (60s timeout for large queries)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.request( response = await client.request(
method=request.method, method=request.method,
url=f"{inventory_service_url}/{path}", url=f"{inventory_service_url}/{path}",

View file

@ -265,6 +265,7 @@
border-collapse: collapse; border-collapse: collapse;
font-size: 10px; font-size: 10px;
color: #000; color: #000;
table-layout: fixed;
} }
.results-table th { .results-table th {
@ -276,11 +277,36 @@
position: sticky; position: sticky;
top: 0; top: 0;
font-size: 9px; font-size: 9px;
position: relative;
user-select: none;
}
/* Resizable column handle */
.results-table th .resize-handle {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 5px;
cursor: col-resize;
background: transparent;
}
.results-table th .resize-handle:hover,
.results-table th .resize-handle.resizing {
background: #666;
}
.results-table th.resizing {
background: #b0b0b0;
} }
.results-table td { .results-table td {
padding: 1px 3px; padding: 1px 3px;
border: 1px solid #ddd; border: 1px solid #ddd;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.results-table tbody tr:hover { .results-table tbody tr:hover {
@ -542,6 +568,9 @@
<option value="Right Wrist">Right Wrist</option> <option value="Right Wrist">Right Wrist</option>
<option value="Trinket">Trinket</option> <option value="Trinket">Trinket</option>
</optgroup> </optgroup>
<optgroup label="Other Slots">
<option value="Cloak">Cloak</option>
</optgroup>
</select> </select>
</div> </div>
</div> </div>
@ -944,6 +973,10 @@
<input type="checkbox" id="slot_trinket" value="Trinket"> <input type="checkbox" id="slot_trinket" value="Trinket">
<label for="slot_trinket">Trinket</label> <label for="slot_trinket">Trinket</label>
</div> </div>
<div class="checkbox-item">
<input type="checkbox" id="slot_cloak" value="Cloak">
<label for="slot_cloak">Cloak</label>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -20,10 +20,9 @@ let currentSort = {
// Store current search results for client-side sorting // Store current search results for client-side sorting
let currentResultsData = null; let currentResultsData = null;
// Pagination state // Items limit - load all items for client-side sorting (backend max is 50000)
let currentPage = 1; let currentPage = 1;
let itemsPerPage = 5000; // 5k items per page for good performance let itemsPerPage = 50000;
let totalPages = 1;
// Initialize the application // Initialize the application
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
@ -44,7 +43,7 @@ function initializeEventListeners() {
// Form submission // Form submission
searchForm.addEventListener('submit', async (e) => { searchForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
await performSearch(true); // Reset to page 1 on new search await performSearch();
}); });
// Clear button // Clear button
@ -172,9 +171,8 @@ function clearAllFields() {
// Reset slot filter // Reset slot filter
document.getElementById('slotFilter').value = ''; document.getElementById('slotFilter').value = '';
// Reset pagination // Reset page
currentPage = 1; currentPage = 1;
totalPages = 1;
// Reset results and clear stored data // Reset results and clear stored data
currentResultsData = null; currentResultsData = null;
@ -205,12 +203,8 @@ function handleSlotFilterChange() {
/** /**
* Perform the search based on form inputs * Perform the search based on form inputs
*/ */
async function performSearch(resetPage = false) { async function performSearch() {
// Reset to page 1 if this is a new search (not pagination)
if (resetPage) {
currentPage = 1; currentPage = 1;
}
searchResults.innerHTML = '<div class="loading">🔍 Searching inventory...</div>'; searchResults.innerHTML = '<div class="loading">🔍 Searching inventory...</div>';
try { try {
@ -228,9 +222,6 @@ async function performSearch(resetPage = false) {
// Store results for client-side re-sorting // Store results for client-side re-sorting
currentResultsData = data; currentResultsData = data;
// Update pagination state
updatePaginationState(data);
// Apply client-side slot filtering // Apply client-side slot filtering
applySlotFilter(data); applySlotFilter(data);
@ -396,12 +387,8 @@ function getSelectedProtections() {
*/ */
function getSelectedSlots() { function getSelectedSlots() {
const selectedSlots = []; const selectedSlots = [];
// Get armor slots // Get all selected slot checkboxes from the all-slots container
document.querySelectorAll('#armor-slots input[type="checkbox"]:checked').forEach(cb => { document.querySelectorAll('#all-slots input[type="checkbox"]:checked').forEach(cb => {
selectedSlots.push(cb.value);
});
// Get jewelry slots
document.querySelectorAll('#jewelry-slots input[type="checkbox"]:checked').forEach(cb => {
selectedSlots.push(cb.value); selectedSlots.push(cb.value);
}); });
return selectedSlots; return selectedSlots;
@ -505,12 +492,16 @@ function displayResults(data) {
// Get item type for display // Get item type for display
const itemType = item.item_type_name || '-'; const itemType = item.item_type_name || '-';
// Format equipment set name // Format equipment set name - prefer translated name
let setDisplay = '-'; let setDisplay = '-';
if (item.item_set) { if (item.item_set_name) {
// Remove redundant "Set" prefix if present // Use the translated set name from backend
setDisplay = item.item_set_name;
// Remove redundant "Set" suffix if present for cleaner display
setDisplay = setDisplay.replace(/\s+Set$/i, '');
} else if (item.item_set) {
// Fallback to raw set ID if name not available
setDisplay = item.item_set.replace(/^Set\s+/i, ''); setDisplay = item.item_set.replace(/^Set\s+/i, '');
// Also handle if it ends with " Set"
setDisplay = setDisplay.replace(/\s+Set$/i, ''); setDisplay = setDisplay.replace(/\s+Set$/i, '');
} }
@ -540,22 +531,6 @@ function displayResults(data) {
</table> </table>
`; `;
// Add pagination controls if needed
if (totalPages > 1) {
const isFirstPage = currentPage === 1;
const isLastPage = currentPage === totalPages;
html += `
<div class="pagination-controls">
<button onclick="goToPage(1)" ${isFirstPage ? 'disabled' : ''}>First</button>
<button onclick="previousPage()" ${isFirstPage ? 'disabled' : ''}> Previous</button>
<span class="pagination-info">Page ${currentPage} of ${totalPages} (${data.total_count} total items)</span>
<button onclick="nextPage()" ${isLastPage ? 'disabled' : ''}>Next </button>
<button onclick="goToPage(${totalPages})" ${isLastPage ? 'disabled' : ''}>Last</button>
</div>
`;
}
searchResults.innerHTML = html; searchResults.innerHTML = html;
// Add click event listeners to sortable headers // Add click event listeners to sortable headers
@ -565,6 +540,98 @@ function displayResults(data) {
handleSort(sortField); 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 * Apply client-side slot filtering
@ -636,7 +703,7 @@ function sortResults(data) {
} }
/** /**
* Handle column sorting * Handle column sorting - client-side sorting for all columns
*/ */
function handleSort(field) { function handleSort(field) {
// If clicking the same field, toggle direction // If clicking the same field, toggle direction
@ -648,9 +715,16 @@ function handleSort(field) {
currentSort.direction = 'asc'; currentSort.direction = 'asc';
} }
// Reset to page 1 and perform new search with updated sort // If we have cached results, sort them client-side and re-display
currentPage = 1; 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(); performSearch();
}
} }
/** /**
@ -794,40 +868,4 @@ function displaySetAnalysisResults(data) {
setAnalysisResults.innerHTML = html; setAnalysisResults.innerHTML = html;
} }
/** // Pagination functions removed - using client-side sorting with all results loaded
* Update pagination state from API response
*/
function updatePaginationState(data) {
totalPages = data.total_pages || 1;
// Current page is already tracked in currentPage
}
/**
* Go to a specific page
*/
function goToPage(page) {
if (page < 1 || page > totalPages || page === currentPage) {
return;
}
currentPage = page;
performSearch();
}
/**
* Go to next page
*/
function nextPage() {
if (currentPage < totalPages) {
goToPage(currentPage + 1);
}
}
/**
* Go to previous page
*/
function previousPage() {
if (currentPage > 1) {
goToPage(currentPage - 1);
}
}