/**
* UI COMPONENTS - COMPACT LAYOUT WITH FILTERS POPUP
* Fixed: Index tag z-index, time tag position
* Added: Favorites feature, Filters popup, Prompt/Size/Seed filters
*/
// Cache for filter buttons
let filterButtonCache = {};
// Toggle Filters Popup
function toggleFiltersPopup() {
const popup = document.getElementById('filters-popup');
const overlay = document.getElementById('filters-overlay');
if (popup.style.display === 'none' || !popup.style.display) {
popup.style.display = 'block';
overlay.style.display = 'block';
document.body.style.overflow = 'hidden'; // Prevent background scroll
} else {
popup.style.display = 'none';
overlay.style.display = 'none';
document.body.style.overflow = '';
}
}
// Toggle Session Popup
function toggleSessionPopup() {
const popup = document.getElementById('session-popup');
const overlay = document.getElementById('session-overlay');
if (popup.style.display === 'none' || !popup.style.display) {
popup.style.display = 'block';
overlay.style.display = 'block';
document.body.style.overflow = 'hidden';
} else {
popup.style.display = 'none';
overlay.style.display = 'none';
document.body.style.overflow = '';
}
}
// Close popup on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const filtersPopup = document.getElementById('filters-popup');
const sessionPopup = document.getElementById('session-popup');
if (filtersPopup && filtersPopup.style.display !== 'none') {
toggleFiltersPopup();
} else if (sessionPopup && sessionPopup.style.display !== 'none') {
toggleSessionPopup();
}
}
});
// Truncate long text with tooltip
function truncateText(text, maxLength) {
if (!text) return '';
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '...';
}
// Initialize Filter Buttons (with caching)
function initFilters() {
if (!activeData || activeData.length === 0) return;
// Ensure all filter Sets exist (add new ones if missing)
const filterKeys = ['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed'];
filterKeys.forEach(key => {
if (!filters.hasOwnProperty(key) || !(filters[key] instanceof Set)) {
filters[key] = new Set();
}
});
['model', 'sampler', 'scheduler', 'denoise', 'lora', 'positive', 'negative', 'size', 'seed'].forEach(key => {
const unique = [...new Set(activeData.map(d => {
if (key === 'model') return d.model || meta.model || "Default";
if (key === 'positive') return d.positive || meta.positive || "";
if (key === 'negative') return d.negative || meta.negative || "";
if (key === 'size') return `${d.width}x${d.height}`;
return d[key];
}))].sort();
const container = document.getElementById('filter-' + key);
if (!container) return;
const cacheKey = unique.join(',');
if (filterButtonCache[key] === cacheKey) {
return;
}
filterButtonCache[key] = cacheKey;
container.innerHTML = '';
unique.forEach(val => {
const safeVal = String(val).replace(/[^a-zA-Z0-9]/g, '');
const btnId = `btn-${key}-${safeVal}`;
let b = document.createElement('button');
b.id = btnId;
b.className = `filter-btn active ${key}`;
let label = val;
let fullText = val;
// Handle special formatting for different filter types
if (key === 'lora') {
if (val === "None") {
label = "None";
} else if (val.includes(" + ")) {
label = "Stack";
fullText = val.replace(/ \+ /g, '\n');
} else {
let clean = val.replace(/\\/g, '/').split('/').pop().split(':')[0];
label = truncateText(clean, 12);
fullText = val;
}
} else if (key === 'model') {
let clean = val.replace(/\\/g, '/').split('/').pop();
label = truncateText(clean, 12);
fullText = val;
} else if (key === 'positive' || key === 'negative') {
// Truncate prompts to 30 characters for button display
label = truncateText(val, 30);
fullText = val;
} else if (key === 'seed') {
// Display seeds in a more readable format
label = String(val);
fullText = val;
}
b.innerText = label;
b.title = fullText;
b.onclick = (e) => {
// Shift-click: Isolate this filter (deselect all others of this type)
if (e.shiftKey) {
e.preventDefault();
// Check if this is the only active filter
const isOnlyActive = filters[key].size === 1 && filters[key].has(val);
if (isOnlyActive) {
// If it's the only one active, select all instead
unique.forEach(v => filters[key].add(v));
// Update all buttons
const allButtons = container.querySelectorAll('.filter-btn');
allButtons.forEach(btn => btn.classList.add('active'));
} else {
// Clear all filters of this type
filters[key].clear();
// Add only this one
filters[key].add(val);
// Update all buttons visually
const allButtons = container.querySelectorAll('.filter-btn');
allButtons.forEach(btn => btn.classList.remove('active'));
b.classList.add('active');
}
} else {
// Normal click: Toggle this filter
if (filters[key].has(val)) {
filters[key].delete(val);
b.classList.remove('active');
} else {
filters[key].add(val);
b.classList.add('active');
}
}
updateDataPipeline();
};
filters[key].add(val);
container.appendChild(b);
});
});
}
let pendingSaveItems = new Map(); // CHANGED: Set -> Map
let saveTimer = null;
const SAVE_BATCH_DELAY = 2000;
/**
* OPTIMIZED: Schedule a save that only sends changed items
*/
function scheduleBatchedSave() {
if (saveTimer) {
clearTimeout(saveTimer);
}
saveTimer = setTimeout(async () => {
if (pendingSaveItems.size === 0) return;
console.log(`[Save] 💾 Sending ${pendingSaveItems.size} changed items to server`);
try {
const sessionName = document.getElementById('session-input')?.value || "default";
// Convert Map to array of items
const changedItems = Array.from(pendingSaveItems.values());
// CHANGED: Use new endpoint that only accepts changed items
const response = await fetch('/config_tester/save_changes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
session_name: sessionName,
changed_items: changedItems // Only changed items!
})
});
if (!response.ok) {
throw new Error(`Save failed: ${response.status}`);
}
console.log(`[Save] ✅ Successfully saved ${pendingSaveItems.size} changes`);
pendingSaveItems.clear();
} catch (error) {
console.error('[Save] ❌ Batch save failed:', error);
// Don't clear pendingSaveItems - will retry on next change
}
}, SAVE_BATCH_DELAY);
}
function markItemChanged(item) {
if (!item || !item.id) return;
// Store the full item object, not just the ID
pendingSaveItems.set(item.id, item);
scheduleBatchedSave();
}
// Lightweight JSON update (separate debounce from save)
let jsonUpdateTimer = null;
const JSON_UPDATE_DELAY = 500; // 500ms debounce
function scheduleJSONUpdate() {
if (jsonUpdateTimer) {
clearTimeout(jsonUpdateTimer);
}
jsonUpdateTimer = setTimeout(() => {
updateJSONs(processedData);
}, JSON_UPDATE_DELAY);
}
// Toggle Favorite
async function toggleFavorite(element) {
// [OPTIMIZATION] Find parent card and grab data directly (O(1) access)
// No getElementById, no array.find, no iteration.
const card = element.closest('.card');
if (!card || !card._dataItem) return;
const item = card._dataItem;
// Update Data
item.favorited = !item.favorited;
// Update UI - Scope querySelector to just this card for extra speed
const favBtn = card.querySelector('.favorite-btn');
if (favBtn) {
favBtn.classList.toggle('favorited', item.favorited);
favBtn.innerText = item.favorited ? '★' : '☆';
// Simple animation
favBtn.style.transform = 'scale(1.2)';
setTimeout(() => favBtn.style.transform = 'scale(1)', 120);
}
markItemChanged(item);
scheduleJSONUpdate();
}
/**
* Show error alert popup with details
*/
function showSaveErrorAlert(title, message, technicalDetails = '') {
// Remove any existing error alert
const existingAlert = document.getElementById('save-error-alert');
if (existingAlert) {
existingAlert.remove();
}
// Create overlay
const overlay = document.createElement('div');
overlay.id = 'save-error-alert';
overlay.className = 'error-alert-overlay';
// Create popup
const popup = document.createElement('div');
popup.className = 'error-alert-popup';
// Build details section if we have technical info
const detailsHtml = technicalDetails ? `
Technical Details
${technicalDetails}