class GalleryManager { constructor() { this.images = []; this.currentPage = 1; this.limit = 100; this.total = 0; this.viewMode = 'grid'; // 'grid' or 'list' this.viewer = null; this.currentSubfolder = ''; this.expandedFolders = new Set(); this.thumbSize = 'md'; // sm, md, lg this.initializeEventListeners(); this.loadFolderTree(); this.loadImages(); // Check thumbnails at startup if enabled this.checkThumbnailsAtStartup(); } initializeEventListeners() { document.getElementById('refreshBtn').addEventListener('click', () => this.loadImages()); document.getElementById('limitSelector').addEventListener('change', (e) => this.changeLimit(parseInt(e.target.value))); document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid')); document.getElementById('listViewBtn').addEventListener('click', () => this.setViewMode('list')); document.getElementById('settingsBtn').addEventListener('click', () => this.showSettings()); document.getElementById('autoTagBtn').addEventListener('click', () => this.showAutoTagModal()); document.getElementById('addPromptBtn').addEventListener('click', () => this.showAddPromptModal()); document.getElementById('retryBtn').addEventListener('click', () => this.loadImages()); document.getElementById('prevPageBtn').addEventListener('click', () => this.previousPage()); document.getElementById('nextPageBtn').addEventListener('click', () => this.nextPage()); document.getElementById('pageInput').addEventListener('change', (e) => this.goToPage(parseInt(e.target.value))); document.getElementById('pageInput').addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.goToPage(parseInt(e.target.value)); } }); // Settings modal event listeners document.getElementById('closeSettingsBtn').addEventListener('click', () => this.hideSettings()); document.getElementById('saveSettingsBtn').addEventListener('click', () => this.saveSettings()); document.getElementById('resetSettingsBtn').addEventListener('click', () => this.resetSettings()); document.getElementById('generateThumbnailsBtn').addEventListener('click', () => this.generateThumbnails()); document.getElementById('clearCacheBtn').addEventListener('click', () => this.clearCache()); document.getElementById('rescanFolderBtn').addEventListener('click', () => this.rescanFolder()); document.getElementById('scanDuplicatesBtn').addEventListener('click', () => this.scanDuplicates()); // Duplicates modal event listeners document.getElementById('closeDuplicatesBtn').addEventListener('click', () => this.hideDuplicatesModal()); document.getElementById('selectAllBtn').addEventListener('click', () => this.selectAllDuplicates()); document.getElementById('removeDuplicatesBtn').addEventListener('click', () => this.removeDuplicates()); // Close modal on outside click document.getElementById('settingsModal').addEventListener('click', (e) => { if (e.target.id === 'settingsModal') { this.hideSettings(); } }); document.getElementById('duplicatesModal').addEventListener('click', (e) => { if (e.target.id === 'duplicatesModal') { this.hideDuplicatesModal(); } }); // Auto Tag modal event listeners document.getElementById('startAutoTagBtn').addEventListener('click', () => this.startAutoTag()); document.getElementById('startReviewBtn').addEventListener('click', () => this.startReview()); document.getElementById('skipReviewBtn').addEventListener('click', () => this.skipReviewImage()); document.getElementById('applyReviewBtn').addEventListener('click', () => this.applyReviewTags()); document.getElementById('cancelAutoTagBtn').addEventListener('click', () => this.cancelAutoTag()); document.getElementById('cancelDownloadBtn').addEventListener('click', () => this.cancelDownload()); document.getElementById('unloadModelBtn').addEventListener('click', () => this.unloadModel()); // WD14 model selection toggle (prompt vs thresholds) document.querySelectorAll('input[name="autoTagModel"]').forEach(radio => { radio.addEventListener('change', (e) => { const isWd14 = e.target.value.startsWith('wd14'); document.getElementById('promptSection').classList.toggle('hidden', isWd14); document.getElementById('wd14ThresholdSection').classList.toggle('hidden', !isWd14); }); }); // WD14 threshold slider value display document.getElementById('wd14GeneralThreshold').addEventListener('input', (e) => { document.getElementById('wd14GeneralThresholdValue').textContent = parseFloat(e.target.value).toFixed(2); }); document.getElementById('wd14CharacterThreshold').addEventListener('input', (e) => { document.getElementById('wd14CharacterThresholdValue').textContent = parseFloat(e.target.value).toFixed(2); }); // Add Prompt modal event listeners document.getElementById('saveNewPromptBtn').addEventListener('click', () => this.saveNewPrompt()); document.getElementById('addPromptTagInput').addEventListener('input', (e) => this.handleTagInput(e)); document.getElementById('addPromptTagInput').addEventListener('keydown', (e) => this.handleTagKeydown(e)); document.getElementById('clearRatingBtn').addEventListener('click', () => this.clearRating()); // Rating stars click handlers document.querySelectorAll('#addPromptRating .rating-star').forEach(star => { star.addEventListener('click', (e) => this.setRating(parseInt(e.target.dataset.rating))); }); // Close AutoTag modals on outside click ['autoTagModal', 'autoTagReviewModal', 'autoTagProgressModal', 'autoTagDownloadModal', 'addPromptModal'].forEach(modalId => { document.getElementById(modalId).addEventListener('click', (e) => { if (e.target.id === modalId) { this.hideModal(modalId); } }); }); // Thumbnail size buttons ['Sm', 'Md', 'Lg'].forEach(size => { const btn = document.getElementById('thumb' + size + 'Btn'); if (btn) btn.addEventListener('click', () => this.setThumbSize(size.toLowerCase())); }); // Folder tree events document.getElementById('clearFolderFilterBtn').addEventListener('click', () => { this.currentSubfolder = ''; this.currentPage = 1; this.renderFolderTree(); this.loadImages(); }); // Resize handle for folder panel const resizeHandle = document.getElementById('folderResizeHandle'); if (resizeHandle) { let isResizing = false; resizeHandle.addEventListener('mousedown', (e) => { isResizing = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const panel = document.getElementById('folderTreePanel'); const newWidth = Math.max(160, Math.min(500, e.clientX - panel.getBoundingClientRect().left)); panel.style.width = newWidth + 'px'; }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; document.body.style.cursor = ''; document.body.style.userSelect = ''; } }); } } async loadFolderTree() { try { const response = await fetch('/prompt_manager/gallery/subfolders?include_ancestors=true'); if (!response.ok) return; const data = await response.json(); if (data.success && data.subfolders.length > 0) { this.folderList = data.subfolders; document.getElementById('folderTreePanel').classList.remove('hidden'); const rh = document.getElementById('folderResizeHandle'); if (rh) rh.classList.remove('hidden'); this.renderFolderTree(); } } catch (error) { console.error('Folder tree error:', error); } } renderFolderTree() { const container = document.getElementById('folderTree'); container.innerHTML = ''; // Build tree structure from flat sorted paths const tree = {}; for (const path of this.folderList) { const parts = path.replace(/\\/g, '/').split('/'); let node = tree; for (const part of parts) { if (!node[part]) node[part] = {}; node = node[part]; } } const renderNode = (obj, parentPath, depth) => { const entries = Object.keys(obj).sort(); for (const name of entries) { const fullPath = parentPath ? parentPath + '/' + name : name; const hasChildren = Object.keys(obj[name]).length > 0; const isExpanded = this.expandedFolders.has(fullPath); const isActive = this.currentSubfolder === fullPath; const row = document.createElement('div'); row.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors' + (isActive ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm'); row.style.paddingLeft = (8 + depth * 16) + 'px'; // Toggle arrow const arrow = document.createElement('span'); arrow.className = 'inline-block w-4 text-center text-pm-secondary mr-1 select-none'; if (hasChildren) { arrow.textContent = isExpanded ? '\u25BE' : '\u25B8'; arrow.addEventListener('click', (e) => { e.stopPropagation(); if (this.expandedFolders.has(fullPath)) { this.expandedFolders.delete(fullPath); } else { this.expandedFolders.add(fullPath); } this.renderFolderTree(); }); } else { arrow.textContent = ''; } row.appendChild(arrow); const label = document.createElement('span'); label.textContent = name; label.className = 'truncate'; row.appendChild(label); // Click to filter row.addEventListener('click', () => { this.currentSubfolder = fullPath; this.currentPage = 1; this.renderFolderTree(); this.loadImages(); }); container.appendChild(row); if (hasChildren && isExpanded) { renderNode(obj[name], fullPath, depth + 1); } } }; // "All" option at top const allRow = document.createElement('div'); allRow.className = 'flex items-center px-2 py-1 cursor-pointer hover:bg-pm-hover transition-colors' + (!this.currentSubfolder ? ' bg-pm-accent/10 text-pm-accent font-medium' : ' text-pm'); allRow.style.paddingLeft = '8px'; allRow.innerHTML = 'All Images'; allRow.addEventListener('click', () => { this.currentSubfolder = ''; this.currentPage = 1; this.renderFolderTree(); this.loadImages(); }); container.appendChild(allRow); renderNode(tree, '', 0); } setThumbSize(size) { this.thumbSize = size; const grid = document.getElementById('galleryGrid'); const sizes = { sm: { cols: 'repeat(auto-fill, minmax(100px, 1fr))', gap: '4px' }, md: { cols: 'repeat(auto-fill, minmax(180px, 1fr))', gap: '8px' }, lg: { cols: 'repeat(auto-fill, minmax(300px, 1fr))', gap: '12px' }, }; const s = sizes[size] || sizes.md; grid.style.gridTemplateColumns = s.cols; grid.style.gap = s.gap; // Update button states ['Sm', 'Md', 'Lg'].forEach(s => { const btn = document.getElementById('thumb' + s + 'Btn'); if (btn) { if (s.toLowerCase() === size) { btn.className = btn.className.replace('bg-pm-input', 'bg-pm-accent').replace('text-pm ', 'text-pm-accent-fg '); } else { btn.className = btn.className.replace('bg-pm-accent', 'bg-pm-input').replace('text-pm-accent-fg', 'text-pm'); } } }); } async loadImages() { this.showLoading(); try { const offset = (this.currentPage - 1) * this.limit; let url = `/prompt_manager/images/output?limit=${this.limit}&offset=${offset}`; if (this.currentSubfolder) { url += `&subfolder=${encodeURIComponent(this.currentSubfolder)}`; } const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); if (data.success) { this.images = data.images; this.total = data.total; this.updateStats(); this.renderGallery(); this.updatePagination(); document.getElementById('loadingStatus').textContent = 'Loaded'; } else { throw new Error(data.error || 'Failed to load images'); } } catch (error) { console.error('Error loading images:', error); this.showError(error.message); } } showLoading() { document.getElementById('loadingState').classList.remove('hidden'); document.getElementById('errorState').classList.add('hidden'); document.getElementById('galleryGrid').classList.add('hidden'); document.getElementById('galleryList').classList.add('hidden'); document.getElementById('paginationControls').classList.add('hidden'); document.getElementById('loadingStatus').textContent = 'Loading...'; } showError(message) { document.getElementById('loadingState').classList.add('hidden'); document.getElementById('errorState').classList.remove('hidden'); document.getElementById('galleryGrid').classList.add('hidden'); document.getElementById('galleryList').classList.add('hidden'); document.getElementById('paginationControls').classList.add('hidden'); document.getElementById('errorMessage').textContent = message; document.getElementById('loadingStatus').textContent = 'Error'; } updateStats() { const start = (this.currentPage - 1) * this.limit + 1; const end = Math.min(this.currentPage * this.limit, this.total); document.getElementById('showingStart').textContent = this.images.length > 0 ? start : 0; document.getElementById('showingEnd').textContent = end; document.getElementById('totalImages').textContent = this.total; } setViewMode(mode) { this.viewMode = mode; // Update button states const gridBtn = document.getElementById('gridViewBtn'); const listBtn = document.getElementById('listViewBtn'); if (mode === 'grid') { gridBtn.classList.remove('bg-pm-input'); gridBtn.classList.add('bg-pm-accent'); listBtn.classList.remove('bg-pm-accent'); listBtn.classList.add('bg-pm-input'); } else { listBtn.classList.remove('bg-pm-input'); listBtn.classList.add('bg-pm-accent'); gridBtn.classList.remove('bg-pm-accent'); gridBtn.classList.add('bg-pm-input'); } this.renderGallery(); } renderGallery() { if (this.images.length === 0) { this.showError('No images found in the output folder'); return; } document.getElementById('loadingState').classList.add('hidden'); document.getElementById('errorState').classList.add('hidden'); if (this.viewMode === 'grid') { this.renderGridView(); } else { this.renderListView(); } // Initialize ViewerJS after rendering setTimeout(() => this.initializeViewer(), 100); // Add simple fallback click handlers this.addFallbackClickHandlers(); } renderGridView() { const grid = document.getElementById('galleryGrid'); const list = document.getElementById('galleryList'); list.classList.add('hidden'); grid.classList.remove('hidden'); grid.innerHTML = this.images.map((image, index) => { // Use thumbnail for display if available, fallback to original const displayUrl = image.thumbnail_url || image.url; const hasThumb = !!image.thumbnail_url; const isVideo = image.is_video || false; const mediaType = image.media_type || 'image'; return `
Loading metadata...
Videos don't contain the same metadata as PNG images. ComfyUI workflow data is only embedded in PNG outputs.
${error.message}
${error.message}
No metadata found in this image
Loading metadata from original image...
${JSON.stringify(this.currentMetadata.workflow, null, 2)}
`);
}
}
async copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
this.showNotification('Copied to clipboard!', 'success');
} catch (err) {
console.error('Copy failed:', err);
this.showNotification('Copy failed', 'error');
}
}
copyAllMetadata() {
if (!this.currentMetadata) return;
const allData = `Checkpoint: ${this.currentMetadata.checkpoint || 'Unknown'}
Positive Prompt: ${this.currentMetadata.positivePrompt}
Negative Prompt: ${this.currentMetadata.negativePrompt}
Steps: ${this.currentMetadata.steps || 'Unknown'}
CFG Scale: ${this.currentMetadata.cfgScale || 'Unknown'}
Sampler: ${this.currentMetadata.sampler || 'Unknown'}
Seed: ${this.currentMetadata.seed || 'Unknown'}`;
this.copyToClipboard(allData);
}
downloadWorkflowJSON() {
if (this.currentMetadata && this.currentMetadata.workflow) {
const dataStr = JSON.stringify(this.currentMetadata.workflow, null, 2);
const dataBlob = new Blob([dataStr], {type: 'application/json'});
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = 'comfyui_workflow.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
this.showNotification('Workflow downloaded!', 'success');
}
}
formatPromptText(text) {
if (!text || text === 'No prompt found' || text === 'No negative prompt found') {
return `${text || 'No prompt found'}`;
}
return this.escapeHtml(text);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
unescapeHtml(text) {
const div = document.createElement('div');
div.innerHTML = text;
return div.textContent;
}
showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 px-4 py-2 rounded-pm-md shadow-pm text-sm z-[20000] transition-all duration-300 transform translate-x-full`;
const colors = {
success: "bg-pm-success text-pm",
error: "bg-pm-error text-pm",
warning: "bg-pm-warning text-pm",
info: "bg-pm-accent text-pm"
};
notification.className += ` ${colors[type] || colors.info}`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => notification.classList.remove("translate-x-full"), 100);
setTimeout(() => {
notification.classList.add("translate-x-full");
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}, 3000);
}
formatImageCaption(image) {
const date = new Date(image.modified_time * 1000);
return `${image.filename} | ${this.formatFileSize(image.size)} | ${date.toLocaleString()}`;
}
async extractImageMetadata(imageUrl) {
try {
// Fetch the image as an array buffer
const response = await fetch(imageUrl);
const arrayBuffer = await response.arrayBuffer();
// Parse PNG metadata
const metadata = await this.parsePNGMetadata(arrayBuffer);
const comfyData = this.extractComfyUIData(metadata);
return this.parseWorkflowData(comfyData, imageUrl);
} catch (error) {
console.error('Error extracting metadata:', error);
return null;
}
}
async parsePNGMetadata(arrayBuffer) {
const dataView = new DataView(arrayBuffer);
let offset = 8; // Skip PNG signature
const metadata = {};
while (offset < arrayBuffer.byteLength - 8) {
const length = dataView.getUint32(offset);
const type = new TextDecoder().decode(arrayBuffer.slice(offset + 4, offset + 8));
if (type === 'tEXt' || type === 'iTXt' || type === 'zTXt') {
const chunkData = arrayBuffer.slice(offset + 8, offset + 8 + length);
let text;
if (type === 'tEXt') {
text = new TextDecoder().decode(chunkData);
} else if (type === 'iTXt') {
// iTXt format: keyword\0compression\0language\0translated_keyword\0text
const textData = new TextDecoder().decode(chunkData);
const parts = textData.split('\0');
if (parts.length >= 5) {
metadata[parts[0]] = parts[4];
}
text = textData;
} else if (type === 'zTXt') {
// zTXt is compressed - basic parsing (might need proper decompression)
text = new TextDecoder().decode(chunkData);
}
// Parse the text chunk for key-value pairs
const nullIndex = text.indexOf('\0');
if (nullIndex !== -1) {
const key = text.substring(0, nullIndex);
const value = text.substring(nullIndex + 1);
metadata[key] = value;
}
}
offset += 8 + length + 4; // Move to next chunk (8 = length + type, 4 = CRC)
}
return metadata;
}
extractComfyUIData(metadata) {
let workflowData = null;
let promptData = null;
// Common ComfyUI metadata field names
const workflowFields = ['workflow', 'Workflow', 'comfy', 'ComfyUI'];
const promptFields = ['prompt', 'Prompt', 'parameters', 'Parameters'];
for (const field of workflowFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
workflowData = JSON.parse(cleanedJson);
break;
} catch (e) {
console.log('Failed to parse workflow field:', field);
}
}
}
for (const field of promptFields) {
if (metadata[field]) {
try {
// Clean NaN values from JSON string before parsing
let cleanedJson = metadata[field];
cleanedJson = cleanedJson.replace(/:\s*NaN\b/g, ': null');
cleanedJson = cleanedJson.replace(/\bNaN\b/g, 'null');
promptData = JSON.parse(cleanedJson);
break;
} catch (e) {
console.log('Failed to parse prompt field:', field);
}
}
}
return { workflow: workflowData, prompt: promptData };
}
parseWorkflowData(comfyData, imagePath) {
// Trace prompts, model and sampler settings through the node graph (#75)
const {
checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed,
} = window.ComfyMetadata.extractGenerationParams(comfyData);
return {
checkpoint,
positivePrompt,
negativePrompt,
steps,
cfgScale,
sampler,
seed,
workflow: comfyData.workflow,
imagePath
};
}
// Settings functionality
showSettings() {
this.loadSettingsFromStorage();
document.getElementById('settingsModal').classList.remove('hidden');
document.getElementById('settingsModal').classList.add('flex');
document.body.style.overflow = 'hidden';
}
hideSettings() {
document.getElementById('settingsModal').classList.add('hidden');
document.getElementById('settingsModal').classList.remove('flex');
document.body.style.overflow = '';
}
loadSettingsFromStorage() {
const settings = this.getSettings();
// Load checkbox settings
document.getElementById('lazyLoadingToggle').checked = settings.lazyLoading;
document.getElementById('autoLoadMetadataToggle').checked = settings.autoLoadMetadata;
document.getElementById('cacheMetadataToggle').checked = settings.cacheMetadata;
document.getElementById('showFilePathsToggle').checked = settings.showFilePaths;
document.getElementById('sidebarCollapsedByDefaultToggle').checked = settings.sidebarCollapsedByDefault;
document.getElementById('showImageInfoToggle').checked = settings.showImageInfo;
document.getElementById('debugModeToggle').checked = settings.debugMode;
document.getElementById('checkThumbnailsAtStartup').checked = settings.checkThumbnailsAtStartup;
// Load video settings
document.getElementById('videoAutoplayToggle').checked = settings.videoAutoplay;
document.getElementById('videoMuteToggle').checked = settings.videoMute;
document.getElementById('videoLoopToggle').checked = settings.videoLoop;
// Load select settings
document.getElementById('imageQualitySelect').value = settings.imageQuality;
document.getElementById('defaultViewModeSelect').value = settings.defaultViewMode;
document.getElementById('defaultLimitSelect').value = settings.defaultLimit;
document.getElementById('gridColumnsSelect').value = settings.gridColumns;
// Load input settings
document.getElementById('apiTimeoutInput').value = settings.apiTimeout;
// Update thumbnail status
document.getElementById('thumbnailStatus').textContent = settings.thumbnailsGenerated ? 'Generated' : 'Not generated';
}
getSettings() {
const defaultSettings = {
lazyLoading: true,
imageQuality: 'medium',
defaultViewMode: 'grid',
defaultLimit: 100,
gridColumns: 8,
showImageInfo: true,
autoLoadMetadata: true,
cacheMetadata: true,
showFilePaths: true,
sidebarCollapsedByDefault: false,
debugMode: false,
apiTimeout: 30,
thumbnailsGenerated: false,
checkThumbnailsAtStartup: true,
// Video settings
videoAutoplay: false,
videoMute: true,
videoLoop: true
};
try {
const stored = localStorage.getItem('gallerySettings');
if (stored) {
return { ...defaultSettings, ...JSON.parse(stored) };
}
} catch (e) {
console.warn('Failed to load settings from localStorage:', e);
}
return defaultSettings;
}
saveSettings() {
const settings = {
lazyLoading: document.getElementById('lazyLoadingToggle').checked,
imageQuality: document.getElementById('imageQualitySelect').value,
defaultViewMode: document.getElementById('defaultViewModeSelect').value,
defaultLimit: parseInt(document.getElementById('defaultLimitSelect').value),
gridColumns: parseInt(document.getElementById('gridColumnsSelect').value),
showImageInfo: document.getElementById('showImageInfoToggle').checked,
autoLoadMetadata: document.getElementById('autoLoadMetadataToggle').checked,
cacheMetadata: document.getElementById('cacheMetadataToggle').checked,
showFilePaths: document.getElementById('showFilePathsToggle').checked,
sidebarCollapsedByDefault: document.getElementById('sidebarCollapsedByDefaultToggle').checked,
debugMode: document.getElementById('debugModeToggle').checked,
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
thumbnailsGenerated: this.getSettings().thumbnailsGenerated, // Preserve this
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
// Video settings
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
videoMute: document.getElementById('videoMuteToggle').checked,
videoLoop: document.getElementById('videoLoopToggle').checked
};
try {
localStorage.setItem('gallerySettings', JSON.stringify(settings));
this.showNotification('Settings saved successfully!', 'success');
this.hideSettings();
// Apply some settings immediately
this.applySettings(settings);
} catch (e) {
console.error('Failed to save settings:', e);
this.showNotification('Failed to save settings', 'error');
}
}
resetSettings() {
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
localStorage.removeItem('gallerySettings');
this.loadSettingsFromStorage();
this.showNotification('Settings reset to defaults', 'success');
}
}
applySettings(settings) {
// Apply grid columns
this.updateGridColumns(settings.gridColumns);
// Apply default view mode if different
if (this.viewMode !== settings.defaultViewMode) {
this.setViewMode(settings.defaultViewMode);
}
// Apply default limit if different
if (this.limit !== settings.defaultLimit) {
this.limit = settings.defaultLimit;
document.getElementById('limitSelector').value = settings.defaultLimit;
}
}
updateGridColumns(columns) {
const gridContainer = document.getElementById('galleryGrid');
if (gridContainer) {
// Remove existing column classes
gridContainer.className = gridContainer.className.replace(/xl:grid-cols-\d+/g, '');
// Add new column class
gridContainer.classList.add(`xl:grid-cols-${columns}`);
}
}
async generateThumbnails() {
const btn = document.getElementById('generateThumbnailsBtn');
const status = document.getElementById('thumbnailStatus');
const progressContainer = document.getElementById('thumbnailProgress');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressPercent = document.getElementById('progressPercent');
const progressDetails = document.getElementById('progressDetails');
const progressETA = document.getElementById('progressETA');
btn.disabled = true;
btn.textContent = 'Generating...';
status.textContent = 'Initializing thumbnail generation...';
progressContainer.classList.remove('hidden');
// Initialize progress display
progressBar.style.width = '0%';
progressPercent.textContent = 'Starting...';
progressText.textContent = 'Connecting to server...';
progressDetails.textContent = 'Preparing to scan output directory...';
progressETA.textContent = '';
try {
// Start thumbnail generation with progress updates
const result = await this.generateThumbnailsWithProgress({
quality: document.getElementById('imageQualitySelect').value
});
// Update settings on success
const settings = this.getSettings();
settings.thumbnailsGenerated = true;
localStorage.setItem('gallerySettings', JSON.stringify(settings));
status.textContent = 'Generation completed';
progressText.textContent = 'Completed!';
progressDetails.textContent = `Generated ${result.count} new, skipped ${result.skipped || 0} existing (${result.total_images} total) in ${result.elapsed_time}s`;
this.showNotification('Thumbnails generated successfully!', 'success');
// Hide progress after a delay
setTimeout(() => {
progressContainer.classList.add('hidden');
}, 3000);
} catch (error) {
console.error('Thumbnail generation error:', error);
console.error('Error details:', error.stack);
status.textContent = 'Generation failed';
progressText.textContent = 'Failed';
progressDetails.textContent = error.message;
progressBar.style.width = '0%';
progressBar.classList.add('bg-pm-error');
progressPercent.textContent = 'Error';
this.showNotification('Failed to generate thumbnails: ' + error.message, 'error');
// Hide progress after a delay
setTimeout(() => {
progressContainer.classList.add('hidden');
progressBar.classList.remove('bg-pm-error');
}, 5000);
} finally {
btn.disabled = false;
btn.textContent = 'Generate';
}
}
async generateThumbnailsWithProgress(options) {
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressPercent = document.getElementById('progressPercent');
const progressDetails = document.getElementById('progressDetails');
const progressETA = document.getElementById('progressETA');
const cancelBtn = document.getElementById('cancelThumbnailsBtn');
return new Promise((resolve, reject) => {
// Show cancel button
cancelBtn.classList.remove('hidden');
console.log(`Starting thumbnail generation with quality: ${options.quality}`);
// Set up Server-Sent Events
const eventSource = new EventSource(`/prompt_manager/images/generate-thumbnails/progress?quality=${options.quality}`);
// Log connection
console.log('EventSource connected for thumbnail generation progress');
let cancelled = false;
let resultData = null;
// Handle cancellation
const cancelHandler = () => {
cancelled = true;
eventSource.close();
cancelBtn.classList.add('hidden');
reject(new Error('Thumbnail generation cancelled by user'));
};
cancelBtn.addEventListener('click', cancelHandler);
// Handle scanning/status events
eventSource.addEventListener('status', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
progressText.textContent = data.message || 'Processing...';
if (data.phase === 'scanning') {
progressDetails.textContent = 'Scanning output folder for images and videos...';
progressBar.style.width = '0%';
progressPercent.textContent = 'Scanning...';
} else {
progressDetails.textContent = 'Preparing thumbnail generation...';
}
// Log status updates
if (this.getSettings().debugMode) {
console.log(`Thumbnail generation status: ${data.phase} - ${data.message}`);
}
});
// Handle start event
eventSource.addEventListener('start', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
// Show detailed file type breakdown if available
const fileBreakdown = (data.image_count !== undefined && data.video_count !== undefined)
? ` (${data.image_count} images, ${data.video_count} videos)`
: '';
progressText.textContent = `Starting to process ${data.total_images} media files${fileBreakdown}...`;
progressDetails.textContent = data.message || `Found ${data.total_images} images and videos to process`;
progressBar.style.width = '0%';
progressPercent.textContent = '0%';
progressETA.textContent = 'Calculating...';
// Log start event
if (this.getSettings().debugMode) {
console.log(`Thumbnail generation started: ${data.total_images} files to process`);
}
});
// Handle progress updates
eventSource.addEventListener('progress', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
// Update progress bar
progressBar.style.width = `${data.percentage}%`;
progressPercent.textContent = `${data.percentage}%`;
// Update text with current file being processed and action
const fileInfo = data.current_file ? ` - ${data.current_file}` : '';
const actionText = data.action === 'skipped' ? ' [Skipping]' : data.action === 'generating' ? ' [Generating]' : '';
progressText.textContent = `Processing ${data.file_type || 'file'}... (${data.processed}/${data.total_images})${fileInfo}${actionText}`;
// More detailed progress information
const elapsedText = data.elapsed ? ` | Time: ${Math.floor(data.elapsed)}s` : '';
progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped} | Rate: ${data.rate} img/s${elapsedText}`;
// Update ETA
if (data.eta > 0 && data.eta < 3600) {
const etaText = data.eta > 60 ? `${Math.floor(data.eta / 60)}m ${Math.floor(data.eta % 60)}s` : `${Math.floor(data.eta)}s`;
progressETA.textContent = `ETA: ${etaText}`;
} else {
progressETA.textContent = '';
}
// Log to console for debugging if debug mode is enabled
if (this.getSettings().debugMode) {
console.log(`Thumbnail generation progress: ${data.processed}/${data.total_images} - ${data.current_file}`);
}
});
// Handle completion
eventSource.addEventListener('complete', (event) => {
if (cancelled) return;
resultData = JSON.parse(event.data);
// Update final progress
progressBar.style.width = '100%';
progressPercent.textContent = '100%';
progressText.textContent = 'Completed!';
// Show detailed completion message
const errorCount = resultData.error_count || (resultData.errors ? resultData.errors.length : 0);
const errors = errorCount > 0 ? ` (${errorCount} errors)` : '';
const rateText = resultData.processing_rate ? ` at ${resultData.processing_rate} img/s` : '';
progressDetails.textContent = `Generated: ${resultData.count} new, Skipped: ${resultData.skipped} existing | Total: ${resultData.total_images} files | Time: ${resultData.elapsed_time}s${rateText}${errors}`;
// Show first error if any occurred
if (resultData.errors && resultData.errors.length > 0 && this.getSettings().debugMode) {
console.error('First thumbnail error:', resultData.errors[0]);
}
progressETA.textContent = 'Done';
// Log completion details
if (this.getSettings().debugMode) {
console.log('Thumbnail generation completed:', resultData);
if (resultData.errors && resultData.errors.length > 0) {
console.warn('Thumbnail generation errors:', resultData.errors);
}
}
// Clean up
eventSource.close();
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
resolve(resultData);
});
// Handle errors
eventSource.addEventListener('error', (event) => {
if (cancelled) return;
const data = JSON.parse(event.data);
// Clean up
eventSource.close();
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
reject(new Error(data.error || 'Thumbnail generation failed'));
});
// Handle EventSource errors
eventSource.onerror = (error) => {
if (cancelled) return;
console.error('EventSource error:', error);
console.error('EventSource readyState:', eventSource.readyState);
// Check if connection is closing normally
if (eventSource.readyState === EventSource.CLOSED) {
console.log('EventSource connection closed');
} else {
console.error('EventSource connection failed');
}
// Clean up
eventSource.close();
cancelBtn.removeEventListener('click', cancelHandler);
cancelBtn.classList.add('hidden');
// Only reject if we don't have result data
if (!resultData) {
reject(new Error('Connection to server lost during thumbnail generation'));
}
};
});
}
async clearCache() {
if (confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) {
try {
// Clear only PromptManager-related browser caches (never touch user images)
if ('caches' in window) {
const cacheNames = await caches.keys();
// Only clear caches that are specifically ours
const ourCaches = cacheNames.filter(name =>
name.includes('prompt-manager') ||
name.includes('gallery') ||
name.includes('thumbnail')
);
await Promise.all(ourCaches.map(name => caches.delete(name)));
}
// Clear only our localStorage entries (never touch user data)
Object.keys(localStorage).forEach(key => {
if (key.startsWith('gallery_cache_') ||
key.startsWith('metadata_cache_') ||
key.startsWith('promptManager_') ||
key.startsWith('pm_')) {
localStorage.removeItem(key);
}
});
// Call backend to clear server-side thumbnails (only our generated thumbnails)
try {
const response = await fetch('/prompt_manager/images/clear-thumbnails', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
const data = await response.json();
console.log('Server thumbnails cleared:', data);
}
} catch (e) {
console.warn('Could not clear server thumbnails:', e);
// Not a critical error - continue with local cleanup
}
// Update thumbnail status
const settings = this.getSettings();
settings.thumbnailsGenerated = false;
localStorage.setItem('gallerySettings', JSON.stringify(settings));
document.getElementById('thumbnailStatus').textContent = 'Not generated';
this.showNotification('PromptManager cache cleared successfully! Original images untouched.', 'success');
} catch (error) {
console.error('Clear cache error:', error);
this.showNotification('Failed to clear cache', 'error');
}
}
}
async rescanFolder() {
const btn = document.getElementById('rescanFolderBtn');
btn.disabled = true;
btn.textContent = 'Scanning...';
try {
// Force reload images
await this.loadImages();
this.showNotification('Folder rescanned successfully!', 'success');
} catch (error) {
console.error('Rescan error:', error);
this.showNotification('Failed to rescan folder', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Rescan';
}
}
async scanDuplicates() {
this.showDuplicatesModal();
const btn = document.getElementById('scanDuplicatesBtn');
btn.disabled = true;
btn.textContent = 'Scanning...';
try {
const response = await fetch('/prompt_manager/scan_duplicates');
const data = await response.json();
if (data.success) {
this.displayDuplicates(data.duplicates || []);
this.showNotification(`Found ${data.duplicates?.length || 0} duplicate image groups`, 'info');
} else {
throw new Error(data.error || 'Failed to scan for duplicate images');
}
} catch (error) {
console.error('Duplicate scan error:', error);
this.showNotification('Failed to scan for duplicate images', 'error');
this.hideDuplicatesModal();
} finally {
btn.disabled = false;
btn.textContent = 'Scan';
}
}
showDuplicatesModal() {
document.getElementById('duplicatesModal').classList.remove('hidden');
document.getElementById('duplicatesModal').classList.add('flex');
document.body.style.overflow = 'hidden';
// Reset modal state
document.getElementById('duplicatesScanStatus').classList.remove('hidden');
document.getElementById('duplicatesContent').classList.add('hidden');
document.getElementById('duplicatesFooter').classList.add('hidden');
}
hideDuplicatesModal() {
document.getElementById('duplicatesModal').classList.add('hidden');
document.getElementById('duplicatesModal').classList.remove('flex');
document.body.style.overflow = '';
}
displayDuplicates(duplicates) {
const scanStatus = document.getElementById('duplicatesScanStatus');
const content = document.getElementById('duplicatesContent');
const footer = document.getElementById('duplicatesFooter');
const list = document.getElementById('duplicatesList');
const count = document.getElementById('duplicatesCount');
scanStatus.classList.add('hidden');
if (duplicates.length === 0) {
list.innerHTML = 'Found ${missingCount} images without thumbnails (${imageCount} total images). Would you like to generate thumbnails for faster loading?
đĄ Thumbnails speed up gallery loading significantly. You can change this setting in Gallery Settings.
Processing ${missingCount} images out of ${imageCount} total...