Add suitbuilder UI improvements for locked slots and suit summary
- Expand locked item spell selection to include weapon skills, magic skills, and defenses - Preserve locked slot configuration when selecting different suits - Add clear button (x) to individual equipment slots for granular control - Add suit summary section below equipment slots with copy-to-clipboard functionality Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
0fd539bedf
commit
8e70f88de1
3 changed files with 1391 additions and 110 deletions
|
|
@ -1,13 +1,54 @@
|
|||
// Suitbuilder JavaScript - Constraint Solver Frontend Logic
|
||||
console.log('Suitbuilder.js loaded - VERSION: SCORE_ORDERING_AND_CANCELLATION_FIX_v3');
|
||||
console.log('Suitbuilder.js loaded - VERSION: CONSISTENT_SUIT_COUNT_v4');
|
||||
|
||||
// Configuration
|
||||
const API_BASE = '/inv/suitbuilder';
|
||||
let currentSuits = [];
|
||||
let lockedSlots = new Set();
|
||||
let lockedSlots = new Map(); // slot -> { set: string|null, setId: number|null, spells: string[] }
|
||||
let selectedSuit = null;
|
||||
let currentSearchController = null; // AbortController for current search
|
||||
|
||||
// Common legendary cantrips for lock form
|
||||
const COMMON_CANTRIPS = [
|
||||
// Attributes
|
||||
'Legendary Strength',
|
||||
'Legendary Endurance',
|
||||
'Legendary Coordination',
|
||||
'Legendary Quickness',
|
||||
'Legendary Focus',
|
||||
'Legendary Willpower',
|
||||
// Weapon Skills
|
||||
'Legendary Finesse Weapon Aptitude',
|
||||
'Legendary Heavy Weapon Aptitude',
|
||||
'Legendary Light Weapon Aptitude',
|
||||
'Legendary Missile Weapon Aptitude',
|
||||
'Legendary Two Handed Combat Aptitude',
|
||||
// Magic Skills
|
||||
'Legendary War Magic Aptitude',
|
||||
'Legendary Void Magic Aptitude',
|
||||
'Legendary Creature Enchantment Aptitude',
|
||||
'Legendary Item Enchantment Aptitude',
|
||||
'Legendary Life Magic Aptitude',
|
||||
// Defense
|
||||
'Legendary Magic Resistance',
|
||||
'Legendary Invulnerability'
|
||||
];
|
||||
|
||||
// Common legendary wards for lock form
|
||||
const COMMON_WARDS = [
|
||||
'Legendary Flame Ward',
|
||||
'Legendary Frost Ward',
|
||||
'Legendary Acid Ward',
|
||||
'Legendary Storm Ward',
|
||||
'Legendary Slashing Ward',
|
||||
'Legendary Piercing Ward',
|
||||
'Legendary Bludgeoning Ward',
|
||||
'Legendary Armor'
|
||||
];
|
||||
|
||||
// Armor slots (have equipment sets)
|
||||
const ARMOR_SLOTS = ['Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands', 'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet'];
|
||||
|
||||
// Initialize when page loads
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initializeSuitbuilder();
|
||||
|
|
@ -134,34 +175,97 @@ function setupSlotInteractions() {
|
|||
/**
|
||||
* Perform suit search with current constraints using streaming results
|
||||
*/
|
||||
// Global timer for search
|
||||
let searchStartTime = null;
|
||||
let searchTimerInterval = null;
|
||||
|
||||
async function performSuitSearch() {
|
||||
const constraints = gatherConstraints();
|
||||
|
||||
|
||||
if (!validateConstraints(constraints)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const resultsDiv = document.getElementById('suitResults');
|
||||
const countSpan = document.getElementById('resultsCount');
|
||||
|
||||
// Reset current suits and UI
|
||||
|
||||
// Start timer IMMEDIATELY
|
||||
searchStartTime = Date.now();
|
||||
if (searchTimerInterval) clearInterval(searchTimerInterval);
|
||||
|
||||
// Reset current suits and UI with new fancy template
|
||||
currentSuits = [];
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="loading">
|
||||
🔍 Searching for optimal suits...
|
||||
<div class="search-progress">
|
||||
<div class="progress-stats">
|
||||
Found: <span id="foundCount">0</span> suits |
|
||||
Checked: <span id="checkedCount">0</span> combinations |
|
||||
Time: <span id="elapsedTime">0.0</span>s
|
||||
</div>
|
||||
<button id="stopSearch" class="stop-search-btn">Stop Search</button>
|
||||
<div class="search-status-container">
|
||||
<!-- Phase indicator with animated icon -->
|
||||
<div class="phase-indicator">
|
||||
<div class="phase-icon" id="phaseIcon">⏳</div>
|
||||
<div class="phase-text" id="searchPhase">Initializing...</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress bars -->
|
||||
<div class="progress-bars">
|
||||
<div class="progress-bar-container">
|
||||
<label>Initialization</label>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill phase-progress" id="phaseProgress" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-container" id="bucketProgressContainer" style="display: none;">
|
||||
<label>Search Progress</label>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill bucket-progress" id="bucketProgress" style="width: 0%"></div>
|
||||
</div>
|
||||
<span class="progress-label" id="currentBucket"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Time</div>
|
||||
<div class="stat-value" id="elapsedTime">0.0</div>
|
||||
<div class="stat-unit">seconds</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Evaluated</div>
|
||||
<div class="stat-value" id="checkedCount">0</div>
|
||||
<div class="stat-unit">combinations</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">Rate</div>
|
||||
<div class="stat-value" id="searchRate">-</div>
|
||||
<div class="stat-unit">per second</div>
|
||||
</div>
|
||||
<div class="stat-card highlight">
|
||||
<div class="stat-label">Found</div>
|
||||
<div class="stat-value" id="foundCount">0</div>
|
||||
<div class="stat-unit">suits</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Verbose log area -->
|
||||
<details class="verbose-log-section" open>
|
||||
<summary>Verbose Output <span class="log-count" id="logCount">0</span></summary>
|
||||
<div class="verbose-log" id="verboseLog"></div>
|
||||
</details>
|
||||
|
||||
<!-- Stop button -->
|
||||
<button id="stopSearch" class="stop-search-btn">
|
||||
<span class="stop-icon">⏹</span> Stop Search
|
||||
</button>
|
||||
</div>
|
||||
<div id="streamingResults"></div>
|
||||
`;
|
||||
countSpan.textContent = '';
|
||||
|
||||
|
||||
// Start client-side timer update (every 100ms for responsive display)
|
||||
searchTimerInterval = setInterval(() => {
|
||||
const elapsed = (Date.now() - searchStartTime) / 1000;
|
||||
const timeEl = document.getElementById('elapsedTime');
|
||||
if (timeEl) timeEl.textContent = elapsed.toFixed(1);
|
||||
}, 100);
|
||||
|
||||
try {
|
||||
await streamOptimalSuits(constraints);
|
||||
} catch (error) {
|
||||
|
|
@ -173,6 +277,12 @@ async function performSuitSearch() {
|
|||
resultsDiv.innerHTML = `<div class="error">❌ Suit search failed: ${error.message}</div>`;
|
||||
countSpan.textContent = '';
|
||||
}
|
||||
} finally {
|
||||
// Stop client-side timer
|
||||
if (searchTimerInterval) {
|
||||
clearInterval(searchTimerInterval);
|
||||
searchTimerInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,10 +322,18 @@ function gatherConstraints() {
|
|||
.filter(cb => cb.checked)
|
||||
.map(cb => cb.value),
|
||||
|
||||
// Locked slots
|
||||
locked_slots: Array.from(lockedSlots)
|
||||
// Locked slots - convert Map to object for API
|
||||
locked_slots: Object.fromEntries(
|
||||
Array.from(lockedSlots.entries()).map(([slot, info]) => [
|
||||
slot,
|
||||
{
|
||||
set_id: info.setId || null,
|
||||
spells: info.spells || []
|
||||
}
|
||||
])
|
||||
)
|
||||
};
|
||||
|
||||
|
||||
return constraints;
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +370,7 @@ async function streamOptimalSuits(constraints) {
|
|||
...constraints.legendary_cantrips,
|
||||
...constraints.protection_spells
|
||||
],
|
||||
locked_items: {}, // TODO: implement locked items
|
||||
locked_slots: constraints.locked_slots || {},
|
||||
include_equipped: constraints.include_equipped,
|
||||
include_inventory: constraints.include_inventory,
|
||||
min_armor: constraints.min_armor ? parseInt(constraints.min_armor) : null,
|
||||
|
|
@ -336,6 +454,10 @@ async function streamOptimalSuits(constraints) {
|
|||
handleSuitEvent(eventData);
|
||||
} else if (currentEventType === 'progress') {
|
||||
handleProgressEvent(eventData);
|
||||
} else if (currentEventType === 'phase') {
|
||||
handlePhaseEvent(eventData);
|
||||
} else if (currentEventType === 'log') {
|
||||
handleLogEvent(eventData);
|
||||
} else if (currentEventType === 'complete') {
|
||||
handleCompleteEvent(eventData);
|
||||
resolve();
|
||||
|
|
@ -372,23 +494,32 @@ async function streamOptimalSuits(constraints) {
|
|||
function handleSuitEvent(data) {
|
||||
try {
|
||||
console.log('NEW handleSuitEvent called with data:', data);
|
||||
|
||||
|
||||
// Transform backend suit format to frontend format
|
||||
const transformedSuit = transformSuitData(data);
|
||||
console.log('Transformed suit:', transformedSuit);
|
||||
|
||||
|
||||
// Insert suit in score-ordered position (highest score first)
|
||||
const insertIndex = insertSuitInScoreOrder(transformedSuit);
|
||||
console.log('Insert index returned:', insertIndex);
|
||||
|
||||
// Insert DOM element at the correct position instead of regenerating everything
|
||||
|
||||
// If suit wasn't inserted (not good enough for top N), skip DOM update
|
||||
if (insertIndex === -1) {
|
||||
console.log('Suit not inserted, skipping DOM update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert DOM element at the correct position
|
||||
insertSuitDOMAtPosition(transformedSuit, insertIndex);
|
||||
console.log('DOM insertion complete');
|
||||
|
||||
|
||||
// Trim excess suits from array and DOM to maintain consistent count
|
||||
trimExcessSuits();
|
||||
|
||||
// Update count
|
||||
document.getElementById('foundCount').textContent = currentSuits.length;
|
||||
document.getElementById('resultsCount').textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing suit data:', error);
|
||||
console.error('Stack trace:', error.stack);
|
||||
|
|
@ -397,28 +528,165 @@ async function streamOptimalSuits(constraints) {
|
|||
|
||||
function handleProgressEvent(data) {
|
||||
try {
|
||||
document.getElementById('foundCount').textContent = data.found || currentSuits.length;
|
||||
document.getElementById('checkedCount').textContent = data.evaluated || 0;
|
||||
document.getElementById('elapsedTime').textContent = data.elapsed || '0.0';
|
||||
// Always show frontend's actual suit count for consistency
|
||||
document.getElementById('foundCount').textContent = currentSuits.length;
|
||||
document.getElementById('checkedCount').textContent = (data.evaluated || 0).toLocaleString();
|
||||
|
||||
// Update rate display
|
||||
const rateEl = document.getElementById('searchRate');
|
||||
if (rateEl && data.rate !== undefined) {
|
||||
rateEl.textContent = data.rate.toLocaleString();
|
||||
}
|
||||
|
||||
// Update bucket progress bar
|
||||
if (data.current_depth !== undefined && data.total_buckets) {
|
||||
const bucketContainer = document.getElementById('bucketProgressContainer');
|
||||
const bucketProgress = document.getElementById('bucketProgress');
|
||||
const currentBucket = document.getElementById('currentBucket');
|
||||
|
||||
if (bucketContainer) bucketContainer.style.display = 'block';
|
||||
if (bucketProgress) {
|
||||
const progress = (data.current_depth / data.total_buckets) * 100;
|
||||
bucketProgress.style.width = `${progress}%`;
|
||||
}
|
||||
if (currentBucket && data.current_bucket) {
|
||||
currentBucket.textContent = `Searching: ${data.current_bucket} (${data.current_depth + 1}/${data.total_buckets})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Timer is now handled client-side for immediate response
|
||||
} catch (error) {
|
||||
console.error('Error processing progress data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePhaseEvent(data) {
|
||||
try {
|
||||
const phaseIcon = document.getElementById('phaseIcon');
|
||||
const phaseText = document.getElementById('searchPhase');
|
||||
const phaseProgress = document.getElementById('phaseProgress');
|
||||
|
||||
// Phase icons
|
||||
const icons = {
|
||||
'loading': '📂',
|
||||
'loaded': '✅',
|
||||
'buckets': '🗂️',
|
||||
'buckets_done': '✅',
|
||||
'reducing': '✂️',
|
||||
'sorting': '📊',
|
||||
'searching': '🔍'
|
||||
};
|
||||
|
||||
if (phaseIcon) {
|
||||
phaseIcon.textContent = icons[data.phase] || '⏳';
|
||||
// Add/remove animation for searching phase
|
||||
if (data.phase === 'searching') {
|
||||
phaseIcon.classList.add('searching');
|
||||
}
|
||||
}
|
||||
|
||||
if (phaseText) {
|
||||
phaseText.textContent = data.message || data.phase;
|
||||
}
|
||||
|
||||
// Update initialization progress bar
|
||||
if (phaseProgress && data.phase_number && data.total_phases) {
|
||||
const progress = (data.phase_number / data.total_phases) * 100;
|
||||
phaseProgress.style.width = `${progress}%`;
|
||||
|
||||
// Make it indeterminate during searching
|
||||
if (data.phase === 'searching') {
|
||||
phaseProgress.classList.add('indeterminate');
|
||||
}
|
||||
}
|
||||
|
||||
// Log the phase change
|
||||
addLogEntry(data.message, 'phase');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing phase data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogEvent(data) {
|
||||
try {
|
||||
addLogEntry(data.message, data.level || 'info', data.timestamp);
|
||||
} catch (error) {
|
||||
console.error('Error processing log data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function addLogEntry(message, level = 'info', timestamp = null) {
|
||||
const logContainer = document.getElementById('verboseLog');
|
||||
const logCount = document.getElementById('logCount');
|
||||
if (!logContainer) return;
|
||||
|
||||
const elapsed = timestamp || ((Date.now() - searchStartTime) / 1000);
|
||||
|
||||
const entry = document.createElement('div');
|
||||
entry.className = `log-entry log-${level}`;
|
||||
entry.innerHTML = `<span class="log-time">[${elapsed.toFixed(1)}s]</span> <span class="log-message">${message}</span>`;
|
||||
|
||||
logContainer.appendChild(entry);
|
||||
|
||||
// Trim old entries (max 100)
|
||||
while (logContainer.children.length > 100) {
|
||||
logContainer.removeChild(logContainer.firstChild);
|
||||
}
|
||||
|
||||
// Update log count
|
||||
if (logCount) {
|
||||
logCount.textContent = logContainer.children.length;
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom
|
||||
logContainer.scrollTop = logContainer.scrollHeight;
|
||||
}
|
||||
|
||||
function handleCompleteEvent(data) {
|
||||
try {
|
||||
// Hide loading indicator
|
||||
const loadingDiv = document.querySelector('.loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.innerHTML = `✅ Search complete! Found ${data.suits_found} suits in ${data.duration}s.`;
|
||||
const duration = ((Date.now() - searchStartTime) / 1000).toFixed(1);
|
||||
|
||||
// Update phase indicator to complete
|
||||
const phaseIcon = document.getElementById('phaseIcon');
|
||||
const phaseText = document.getElementById('searchPhase');
|
||||
const phaseProgress = document.getElementById('phaseProgress');
|
||||
|
||||
if (phaseIcon) {
|
||||
phaseIcon.textContent = '✅';
|
||||
phaseIcon.classList.remove('searching');
|
||||
phaseIcon.classList.add('complete');
|
||||
}
|
||||
|
||||
if (phaseText) {
|
||||
phaseText.textContent = `Search complete! Found ${currentSuits.length} suits`;
|
||||
phaseText.classList.add('complete');
|
||||
}
|
||||
if (phaseProgress) {
|
||||
phaseProgress.classList.remove('indeterminate');
|
||||
phaseProgress.style.width = '100%';
|
||||
}
|
||||
|
||||
// Fill bucket progress bar
|
||||
const bucketProgress = document.getElementById('bucketProgress');
|
||||
if (bucketProgress) {
|
||||
bucketProgress.style.width = '100%';
|
||||
}
|
||||
|
||||
// Hide stop button
|
||||
const stopBtn = document.getElementById('stopSearch');
|
||||
if (stopBtn) {
|
||||
stopBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
// Add completion log entry
|
||||
addLogEntry(`Search complete: ${currentSuits.length} suits found in ${duration}s`, 'success');
|
||||
|
||||
// Update final results count
|
||||
const countSpan = document.getElementById('resultsCount');
|
||||
if (countSpan) {
|
||||
countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`;
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing completion data:', error);
|
||||
}
|
||||
|
|
@ -439,19 +707,36 @@ async function streamOptimalSuits(constraints) {
|
|||
const stopButton = document.getElementById('stopSearch');
|
||||
stopButton.addEventListener('click', () => {
|
||||
searchStopped = true;
|
||||
|
||||
|
||||
// Actually abort the HTTP request
|
||||
if (currentSearchController) {
|
||||
currentSearchController.abort();
|
||||
currentSearchController = null;
|
||||
}
|
||||
|
||||
// Update UI to show search was stopped
|
||||
const loadingDiv = document.querySelector('.loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.innerHTML = '⏹️ Search stopped by user.';
|
||||
|
||||
// Update phase indicator to show stopped
|
||||
const phaseIcon = document.getElementById('phaseIcon');
|
||||
const phaseText = document.getElementById('searchPhase');
|
||||
const phaseProgress = document.getElementById('phaseProgress');
|
||||
|
||||
if (phaseIcon) {
|
||||
phaseIcon.textContent = '⏹️';
|
||||
phaseIcon.classList.remove('searching');
|
||||
}
|
||||
|
||||
if (phaseText) {
|
||||
phaseText.textContent = `Search stopped - Found ${currentSuits.length} suits`;
|
||||
phaseText.style.color = '#ffa726';
|
||||
}
|
||||
if (phaseProgress) {
|
||||
phaseProgress.classList.remove('indeterminate');
|
||||
}
|
||||
|
||||
// Hide stop button
|
||||
stopButton.style.display = 'none';
|
||||
|
||||
// Add log entry
|
||||
addLogEntry(`Search stopped by user with ${currentSuits.length} suits found`, 'warning');
|
||||
|
||||
// Update results count
|
||||
const countSpan = document.getElementById('resultsCount');
|
||||
if (countSpan) {
|
||||
|
|
@ -499,10 +784,13 @@ function transformSuitData(suit) {
|
|||
|
||||
/**
|
||||
* Insert a suit into the currentSuits array in score-ordered position (highest first)
|
||||
* Keeps only top MAX_SUITS to match backend behavior and ensure consistent counts
|
||||
*/
|
||||
const MAX_SUITS = 10; // Must match max_results sent to backend
|
||||
|
||||
function insertSuitInScoreOrder(suit) {
|
||||
console.log(`Inserting suit with score ${suit.score}. Current suits:`, currentSuits.map(s => s.score));
|
||||
|
||||
|
||||
// Find the correct position to insert the suit (highest score first)
|
||||
let insertIndex = 0;
|
||||
for (let i = 0; i < currentSuits.length; i++) {
|
||||
|
|
@ -512,14 +800,47 @@ function insertSuitInScoreOrder(suit) {
|
|||
}
|
||||
insertIndex = i + 1;
|
||||
}
|
||||
|
||||
|
||||
// If we already have MAX_SUITS and this suit would be at the end (worst), skip it
|
||||
if (currentSuits.length >= MAX_SUITS && insertIndex >= MAX_SUITS) {
|
||||
console.log(`Suit with score ${suit.score} not good enough for top ${MAX_SUITS}, skipping`);
|
||||
return -1; // Signal that suit wasn't inserted
|
||||
}
|
||||
|
||||
// Insert the suit at the correct position
|
||||
currentSuits.splice(insertIndex, 0, suit);
|
||||
|
||||
|
||||
console.log(`Inserted at index ${insertIndex}. New order:`, currentSuits.map(s => s.score));
|
||||
return insertIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim excess suits from both the array and DOM to maintain MAX_SUITS limit
|
||||
* This ensures consistent suit counts between runs
|
||||
*/
|
||||
function trimExcessSuits() {
|
||||
const streamingResults = document.getElementById('streamingResults');
|
||||
if (!streamingResults) return;
|
||||
|
||||
// Remove excess suits from array (keep only top MAX_SUITS)
|
||||
while (currentSuits.length > MAX_SUITS) {
|
||||
const removed = currentSuits.pop();
|
||||
console.log(`Trimmed suit with score ${removed.score} (exceeds MAX_SUITS=${MAX_SUITS})`);
|
||||
}
|
||||
|
||||
// Remove excess DOM elements (keep only top MAX_SUITS)
|
||||
while (streamingResults.children.length > MAX_SUITS) {
|
||||
const lastChild = streamingResults.lastElementChild;
|
||||
if (lastChild) {
|
||||
console.log(`Removing DOM element for suit at position ${streamingResults.children.length}`);
|
||||
lastChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update medals after trimming
|
||||
updateAllMedals();
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate the entire results display to maintain proper score ordering
|
||||
*/
|
||||
|
|
@ -1137,30 +1458,107 @@ function selectSuit(suitId) {
|
|||
* Populate the visual equipment slots with suit items
|
||||
*/
|
||||
function populateVisualSlots(items) {
|
||||
// Clear all slots first
|
||||
// Clear non-locked slots first
|
||||
document.querySelectorAll('.slot-content').forEach(slot => {
|
||||
const slotName = slot.id.replace('slot_', '').replace('_', ' ');
|
||||
slot.innerHTML = '<span class="empty-slot">Empty</span>';
|
||||
slot.parentElement.classList.remove('populated');
|
||||
const slotName = slot.id.replace('slot_', '').replace(/_/g, ' ');
|
||||
// Skip locked slots - preserve their summary
|
||||
if (!lockedSlots.has(slotName)) {
|
||||
slot.innerHTML = '<span class="empty-slot">Empty</span>';
|
||||
slot.parentElement.classList.remove('populated');
|
||||
}
|
||||
});
|
||||
|
||||
// Populate with items
|
||||
|
||||
// Populate non-locked slots with items
|
||||
Object.entries(items).forEach(([slotName, item]) => {
|
||||
const slotId = `slot_${slotName.replace(' ', '_')}`;
|
||||
// Skip locked slots - they keep their configured info
|
||||
if (lockedSlots.has(slotName)) return;
|
||||
|
||||
const slotId = `slot_${slotName.replace(/ /g, '_')}`;
|
||||
const slotElement = document.getElementById(slotId);
|
||||
|
||||
|
||||
if (slotElement) {
|
||||
const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">Need Reducing</span>' : '';
|
||||
|
||||
|
||||
slotElement.innerHTML = `
|
||||
<div class="slot-item-name">${item.name}</div>
|
||||
<div class="slot-item-character">${item.source_character || item.character_name || 'Unknown'}</div>
|
||||
<div class="slot-item-properties">${formatItemProperties(item)}</div>
|
||||
${needsReducing}
|
||||
<button class="clear-slot-btn" onclick="clearSlot('${slotName}')" title="Clear slot">x</button>
|
||||
`;
|
||||
slotElement.parentElement.classList.add('populated');
|
||||
}
|
||||
});
|
||||
|
||||
// Update suit summary
|
||||
updateSuitSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a single slot
|
||||
*/
|
||||
function clearSlot(slotName) {
|
||||
const slotElement = document.querySelector(`[data-slot="${slotName}"]`);
|
||||
const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`);
|
||||
|
||||
slotContent.innerHTML = '<span class="empty-slot">Empty</span>';
|
||||
slotElement.classList.remove('populated');
|
||||
|
||||
// Also remove from selectedSuit if present
|
||||
if (selectedSuit && selectedSuit.items[slotName]) {
|
||||
delete selectedSuit.items[slotName];
|
||||
}
|
||||
|
||||
updateSuitSummary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the suit summary display
|
||||
*/
|
||||
function updateSuitSummary() {
|
||||
const summaryDiv = document.getElementById('suitSummary');
|
||||
if (!summaryDiv) return;
|
||||
|
||||
if (!selectedSuit || Object.keys(selectedSuit.items).length === 0) {
|
||||
summaryDiv.innerHTML = '<div class="no-summary">Select a suit to see summary</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = Object.entries(selectedSuit.items).map(([slot, item]) => {
|
||||
const spells = (item.spell_names || []).map(s => s.replace('Legendary ', 'L.')).join(', ') || 'No spells';
|
||||
const char = item.source_character || item.character_name || 'Unknown';
|
||||
return `<div class="summary-line">
|
||||
<span class="summary-slot">${slot}:</span>
|
||||
<span class="summary-item">${item.name}</span>
|
||||
<span class="summary-spells">[${spells}]</span>
|
||||
<span class="summary-char">- ${char}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
summaryDiv.innerHTML = lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy suit summary to clipboard
|
||||
*/
|
||||
function copySuitSummary() {
|
||||
if (!selectedSuit) return;
|
||||
|
||||
const text = Object.entries(selectedSuit.items).map(([slot, item]) => {
|
||||
const spells = (item.spell_names || []).join(', ') || 'No spells';
|
||||
const char = item.source_character || item.character_name || 'Unknown';
|
||||
return `${slot}: ${item.name} [${spells}] - ${char}`;
|
||||
}).join('\n');
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Brief visual feedback
|
||||
const btn = document.querySelector('.copy-summary-btn');
|
||||
if (btn) {
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = original, 1500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1169,29 +1567,236 @@ function populateVisualSlots(items) {
|
|||
function toggleSlotLock(slotName) {
|
||||
const slotElement = document.querySelector(`[data-slot="${slotName}"]`);
|
||||
const lockBtn = slotElement.querySelector('.lock-btn');
|
||||
|
||||
|
||||
if (lockedSlots.has(slotName)) {
|
||||
lockedSlots.delete(slotName);
|
||||
slotElement.classList.remove('locked');
|
||||
lockBtn.classList.remove('locked');
|
||||
// Unlocking - remove from map and clear visual
|
||||
unlockSlot(slotName);
|
||||
} else {
|
||||
lockedSlots.add(slotName);
|
||||
slotElement.classList.add('locked');
|
||||
lockBtn.classList.add('locked');
|
||||
// Locking - check if slot is populated
|
||||
const isPopulated = slotElement.classList.contains('populated');
|
||||
|
||||
if (isPopulated && selectedSuit && selectedSuit.items[slotName]) {
|
||||
// Auto-extract set/spells from the populated item
|
||||
const item = selectedSuit.items[slotName];
|
||||
const lockInfo = {
|
||||
set: item.set_name || null,
|
||||
setId: item.set_id || null,
|
||||
spells: item.spell_names || item.spells || []
|
||||
};
|
||||
lockedSlots.set(slotName, lockInfo);
|
||||
slotElement.classList.add('locked');
|
||||
lockBtn.classList.add('locked');
|
||||
renderLockedSlotSummary(slotName, lockInfo);
|
||||
} else {
|
||||
// Empty slot - show lock form for manual entry
|
||||
showLockSlotForm(slotName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlock a slot and restore its content
|
||||
*/
|
||||
function unlockSlot(slotName) {
|
||||
const slotElement = document.querySelector(`[data-slot="${slotName}"]`);
|
||||
const lockBtn = slotElement.querySelector('.lock-btn');
|
||||
const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`);
|
||||
|
||||
lockedSlots.delete(slotName);
|
||||
slotElement.classList.remove('locked');
|
||||
lockBtn.classList.remove('locked');
|
||||
|
||||
// Restore slot content (either from selected suit or empty)
|
||||
if (selectedSuit && selectedSuit.items[slotName]) {
|
||||
const item = selectedSuit.items[slotName];
|
||||
const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">Need Reducing</span>' : '';
|
||||
slotContent.innerHTML = `
|
||||
<div class="slot-item-name">${item.name}</div>
|
||||
<div class="slot-item-character">${item.source_character || item.character_name || 'Unknown'}</div>
|
||||
<div class="slot-item-properties">${formatItemProperties(item)}</div>
|
||||
${needsReducing}
|
||||
`;
|
||||
} else {
|
||||
slotContent.innerHTML = '<span class="empty-slot">Empty</span>';
|
||||
slotElement.classList.remove('populated');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show lock form for manual slot configuration
|
||||
*/
|
||||
function showLockSlotForm(slotName) {
|
||||
const slotElement = document.querySelector(`[data-slot="${slotName}"]`);
|
||||
const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`);
|
||||
const isArmorSlot = ARMOR_SLOTS.includes(slotName);
|
||||
|
||||
// Build spell checkboxes
|
||||
const spellCheckboxes = [...COMMON_CANTRIPS, ...COMMON_WARDS].map(spell => `
|
||||
<label class="lock-spell-checkbox">
|
||||
<input type="checkbox" value="${spell}">
|
||||
<span>${spell.replace('Legendary ', 'L. ')}</span>
|
||||
</label>
|
||||
`).join('');
|
||||
|
||||
// Build set dropdown (armor only)
|
||||
const setDropdown = isArmorSlot ? `
|
||||
<div class="lock-form-set">
|
||||
<label>Equipment Set:</label>
|
||||
<select class="lock-set-select" id="lockSetSelect_${slotName.replace(/ /g, '_')}">
|
||||
<option value="">None</option>
|
||||
${getEquipmentSetOptions()}
|
||||
</select>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
slotContent.innerHTML = `
|
||||
<div class="lock-form">
|
||||
<div class="lock-form-header">Configure Locked Slot</div>
|
||||
${setDropdown}
|
||||
<div class="lock-form-spells">
|
||||
<label>Spells this item has:</label>
|
||||
<div class="lock-spell-checkboxes">
|
||||
${spellCheckboxes}
|
||||
</div>
|
||||
</div>
|
||||
<div class="lock-form-actions">
|
||||
<button class="lock-save-btn" onclick="saveLockSlotForm('${slotName}')">Lock</button>
|
||||
<button class="lock-cancel-btn" onclick="cancelLockSlotForm('${slotName}')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
slotElement.classList.add('configuring');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get equipment set options for dropdown
|
||||
*/
|
||||
function getEquipmentSetOptions() {
|
||||
// Equipment sets - IDs must match getSetNameById() and backend
|
||||
const commonSets = [
|
||||
{ id: 14, name: "Adept's" },
|
||||
{ id: 13, name: "Soldier's" },
|
||||
{ id: 15, name: "Archer's" },
|
||||
{ id: 16, name: "Defender's" },
|
||||
{ id: 19, name: 'Hearty' },
|
||||
{ id: 20, name: 'Dexterous' },
|
||||
{ id: 21, name: 'Wise' },
|
||||
{ id: 22, name: 'Swift' },
|
||||
{ id: 24, name: 'Reinforced' },
|
||||
{ id: 26, name: 'Flame Proof' },
|
||||
{ id: 29, name: 'Lightning Proof' },
|
||||
{ id: 40, name: 'Heroic Protector' },
|
||||
{ id: 41, name: 'Heroic Destroyer' },
|
||||
{ id: 46, name: 'Relic Alduressa' },
|
||||
{ id: 47, name: 'Ancient Relic' },
|
||||
{ id: 48, name: 'Noble Relic' }
|
||||
];
|
||||
|
||||
return commonSets.map(set =>
|
||||
`<option value="${set.id}">${set.name}</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Save lock slot form and apply lock
|
||||
*/
|
||||
function saveLockSlotForm(slotName) {
|
||||
const slotElement = document.querySelector(`[data-slot="${slotName}"]`);
|
||||
const lockBtn = slotElement.querySelector('.lock-btn');
|
||||
const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`);
|
||||
const isArmorSlot = ARMOR_SLOTS.includes(slotName);
|
||||
|
||||
// Get selected set (armor only)
|
||||
let setId = null;
|
||||
let setName = null;
|
||||
if (isArmorSlot) {
|
||||
const setSelect = document.getElementById(`lockSetSelect_${slotName.replace(/ /g, '_')}`);
|
||||
if (setSelect && setSelect.value) {
|
||||
setId = parseInt(setSelect.value);
|
||||
setName = setSelect.options[setSelect.selectedIndex].text;
|
||||
}
|
||||
}
|
||||
|
||||
// Get selected spells
|
||||
const selectedSpells = [];
|
||||
slotContent.querySelectorAll('.lock-spell-checkbox input:checked').forEach(cb => {
|
||||
selectedSpells.push(cb.value);
|
||||
});
|
||||
|
||||
// Create lock info
|
||||
const lockInfo = {
|
||||
set: setName,
|
||||
setId: setId,
|
||||
spells: selectedSpells
|
||||
};
|
||||
|
||||
// Store and render
|
||||
lockedSlots.set(slotName, lockInfo);
|
||||
slotElement.classList.remove('configuring');
|
||||
slotElement.classList.add('locked');
|
||||
lockBtn.classList.add('locked');
|
||||
renderLockedSlotSummary(slotName, lockInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel lock slot form
|
||||
*/
|
||||
function cancelLockSlotForm(slotName) {
|
||||
const slotElement = document.querySelector(`[data-slot="${slotName}"]`);
|
||||
const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`);
|
||||
|
||||
slotElement.classList.remove('configuring');
|
||||
|
||||
// Restore original content
|
||||
if (selectedSuit && selectedSuit.items[slotName]) {
|
||||
const item = selectedSuit.items[slotName];
|
||||
const needsReducing = isMultiSlotItem(item) ? '<span class="need-reducing">Need Reducing</span>' : '';
|
||||
slotContent.innerHTML = `
|
||||
<div class="slot-item-name">${item.name}</div>
|
||||
<div class="slot-item-character">${item.source_character || item.character_name || 'Unknown'}</div>
|
||||
<div class="slot-item-properties">${formatItemProperties(item)}</div>
|
||||
${needsReducing}
|
||||
`;
|
||||
} else {
|
||||
slotContent.innerHTML = '<span class="empty-slot">Empty</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render locked slot summary display
|
||||
*/
|
||||
function renderLockedSlotSummary(slotName, lockInfo) {
|
||||
const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`);
|
||||
|
||||
const setBadge = lockInfo.set ?
|
||||
`<div class="locked-set-badge">${lockInfo.set}</div>` : '';
|
||||
|
||||
const spellPills = lockInfo.spells.length > 0 ?
|
||||
`<div class="locked-spells">
|
||||
${lockInfo.spells.map(s => `<span class="locked-spell">${s.replace('Legendary ', 'L. ')}</span>`).join('')}
|
||||
</div>` : '<div class="locked-no-spells">No spells specified</div>';
|
||||
|
||||
slotContent.innerHTML = `
|
||||
<div class="locked-slot-info">
|
||||
<div class="locked-header">🔒 Locked</div>
|
||||
${setBadge}
|
||||
${spellPills}
|
||||
<button class="unlock-btn" onclick="unlockSlot('${slotName}')">Unlock</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle slot click events
|
||||
*/
|
||||
function handleSlotClick(slotName) {
|
||||
// For now, just toggle lock state
|
||||
// Later this could open item selection dialog
|
||||
// Could open item selection dialog in the future
|
||||
console.log(`Clicked slot: ${slotName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lock currently selected slots
|
||||
* Lock currently selected/populated slots (auto-extract from items)
|
||||
*/
|
||||
function lockSelectedSlots() {
|
||||
document.querySelectorAll('.slot-item.populated').forEach(slot => {
|
||||
|
|
@ -1206,10 +1811,22 @@ function lockSelectedSlots() {
|
|||
* Clear all slot locks
|
||||
*/
|
||||
function clearAllLocks() {
|
||||
// Get all locked slot names before clearing
|
||||
const lockedSlotNames = Array.from(lockedSlots.keys());
|
||||
|
||||
lockedSlots.clear();
|
||||
|
||||
// Restore each slot
|
||||
lockedSlotNames.forEach(slotName => {
|
||||
unlockSlot(slotName);
|
||||
});
|
||||
|
||||
// Also clear any remaining visual lock states
|
||||
document.querySelectorAll('.slot-item').forEach(slot => {
|
||||
slot.classList.remove('locked');
|
||||
slot.querySelector('.lock-btn').classList.remove('locked');
|
||||
slot.classList.remove('configuring');
|
||||
const lockBtn = slot.querySelector('.lock-btn');
|
||||
if (lockBtn) lockBtn.classList.remove('locked');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue