/** * Showcase - Left panel for displaying example images * Features: * - Main image display with navigation * - Thumbnail rail for quick switching * - Params panel for image metadata * - Upload area for custom examples * - Keyboard navigation support (← →) */ import { escapeHtml } from '../shared/utils.js'; import { translate } from '../../utils/i18nHelpers.js'; import { showToast } from '../../utils/uiHelpers.js'; import { getModelApiClient } from '../../api/modelApiFactory.js'; export class Showcase { constructor(container) { this.element = container; this.images = []; this.currentIndex = 0; this.modelHash = ''; this.filePath = ''; this.paramsVisible = false; this.uploadAreaVisible = false; this.isUploading = false; } /** * Render the showcase */ render({ images, modelHash, filePath }) { this.images = images || []; this.modelHash = modelHash || ''; this.filePath = filePath || ''; this.currentIndex = 0; this.paramsVisible = false; this.uploadAreaVisible = false; this.element.innerHTML = this.getTemplate(); this.bindEvents(); if (this.images.length > 0) { this.loadImage(0); } } /** * Get the HTML template */ getTemplate() { const hasImages = this.images.length > 0; return `
${hasImages ? `
${translate('modals.model.examples.title', {}, 'Example')}
${translate('modals.model.params.title', {}, 'Generation Parameters')}
` : `

${translate('modals.model.examples.empty', {}, 'No example images available')}

`}
${this.renderThumbnailRail()} ${this.renderUploadArea()} `; } /** * Render the thumbnail rail */ renderThumbnailRail() { const thumbnails = this.images.map((img, index) => { const url = img.url || img; const isNsfw = img.nsfw || false; return `
${isNsfw ? 'NSFW' : ''}
`; }).join(''); return `
${thumbnails}
`; } /** * Render the upload area */ renderUploadArea() { return `

${translate('modals.model.examples.dropFiles', {}, 'Drop files here or click to browse')}

${translate('modals.model.examples.supportedFormats', {}, 'Supports: JPG, PNG, WEBP, MP4, WEBM')}

`; } /** * Bind event listeners */ bindEvents() { this.element.addEventListener('click', (e) => { const target = e.target.closest('[data-action]'); if (!target) return; const action = target.dataset.action; switch (action) { case 'prev-image': this.prevImage(); break; case 'next-image': this.nextImage(); break; case 'select-image': const index = parseInt(target.dataset.index, 10); if (!isNaN(index)) { this.loadImage(index); } break; case 'toggle-params': this.toggleParams(); break; case 'close-params': this.hideParams(); break; case 'set-preview': this.setAsPreview(); break; case 'delete-example': this.deleteExample(); break; case 'add-example': case 'toggle-upload': this.toggleUploadArea(); break; case 'cancel-upload': this.hideUploadArea(); break; case 'copy-prompt': this.copyPrompt(); break; } }); // File input change const fileInput = this.element.querySelector('.upload-area__input'); if (fileInput) { fileInput.addEventListener('change', (e) => { this.handleFileSelect(e.target.files); }); } // Drag and drop const dropzone = this.element.querySelector('.upload-area__dropzone'); if (dropzone) { dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('dragover'); }); dropzone.addEventListener('dragleave', () => { dropzone.classList.remove('dragover'); }); dropzone.addEventListener('drop', (e) => { e.preventDefault(); dropzone.classList.remove('dragover'); this.handleFileSelect(e.dataTransfer.files); }); dropzone.addEventListener('click', () => { fileInput?.click(); }); } } /** * Toggle upload area visibility */ toggleUploadArea() { this.uploadAreaVisible = !this.uploadAreaVisible; const uploadArea = this.element.querySelector('.upload-area'); if (uploadArea) { uploadArea.classList.toggle('visible', this.uploadAreaVisible); } } /** * Hide upload area */ hideUploadArea() { this.uploadAreaVisible = false; const uploadArea = this.element.querySelector('.upload-area'); if (uploadArea) { uploadArea.classList.remove('visible'); } } /** * Handle file selection */ async handleFileSelect(files) { if (!files || files.length === 0) return; if (this.isUploading) return; this.isUploading = true; const uploadArea = this.element.querySelector('.upload-area'); const dropzone = this.element.querySelector('.upload-area__dropzone'); // Show loading state if (dropzone) { dropzone.innerHTML = `

${translate('modals.model.examples.uploading', {}, 'Uploading...')}

`; } try { for (const file of files) { await this.uploadFile(file); } showToast('modals.model.examples.uploadSuccess', {}, 'success'); this.hideUploadArea(); // Refresh the showcase by reloading model data this.refreshShowcase(); } catch (error) { console.error('Failed to upload file:', error); showToast('modals.model.examples.uploadFailed', {}, 'error'); // Reset dropzone if (dropzone) { dropzone.innerHTML = `

${translate('modals.model.examples.dropFiles', {}, 'Drop files here or click to browse')}

${translate('modals.model.examples.supportedFormats', {}, 'Supports: JPG, PNG, WEBP, MP4, WEBM')}

`; } } finally { this.isUploading = false; } } /** * Upload a single file */ async uploadFile(file) { if (!this.filePath) { throw new Error('No file path available'); } // Check file type const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/jpg', 'video/mp4', 'video/webm']; if (!allowedTypes.includes(file.type)) { throw new Error(`Unsupported file type: ${file.type}`); } // Check file size (100MB limit) const maxSize = 100 * 1024 * 1024; if (file.size > maxSize) { throw new Error('File too large (max 100MB)'); } const formData = new FormData(); formData.append('file', file); formData.append('model_path', this.filePath); const response = await fetch('/api/lm/upload-example', { method: 'POST', body: formData }); if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || 'Upload failed'); } return response.json(); } /** * Refresh showcase after upload */ async refreshShowcase() { if (!this.filePath) return; try { const client = getModelApiClient(); const metadata = await client.fetchModelMetadata(this.filePath); if (metadata) { const regularImages = metadata.images || []; const customImages = metadata.customImages || []; const allImages = [...regularImages, ...customImages]; this.images = allImages; this.currentIndex = allImages.length - 1; // Re-render this.render({ images: allImages, modelHash: this.modelHash, filePath: this.filePath }); // Load the newly uploaded image if (this.currentIndex >= 0) { this.loadImage(this.currentIndex); } } } catch (error) { console.error('Failed to refresh showcase:', error); } } /** * Load and display an image by index */ loadImage(index) { if (index < 0 || index >= this.images.length) return; this.currentIndex = index; const image = this.images[index]; const url = image.url || image; // Update main image const imgElement = this.element.querySelector('.showcase__image'); if (imgElement) { imgElement.classList.add('loading'); imgElement.src = url; imgElement.onload = () => { imgElement.classList.remove('loading'); }; } // Update thumbnail rail active state this.element.querySelectorAll('.thumbnail-rail__item').forEach((item, i) => { item.classList.toggle('active', i === index); }); // Update params this.updateParams(image); } /** * Navigate to previous image */ prevImage() { if (this.images.length === 0) return; const newIndex = this.currentIndex > 0 ? this.currentIndex - 1 : this.images.length - 1; this.loadImage(newIndex); } /** * Navigate to next image */ nextImage() { if (this.images.length === 0) return; const newIndex = this.currentIndex < this.images.length - 1 ? this.currentIndex + 1 : 0; this.loadImage(newIndex); } /** * Toggle params panel visibility */ toggleParams() { this.paramsVisible = !this.paramsVisible; const panel = this.element.querySelector('.showcase__params'); if (panel) { panel.classList.toggle('visible', this.paramsVisible); } } /** * Hide params panel */ hideParams() { this.paramsVisible = false; const panel = this.element.querySelector('.showcase__params'); if (panel) { panel.classList.remove('visible'); } } /** * Update params panel content */ updateParams(image) { const content = this.element.querySelector('.showcase__params-content'); if (!content) return; const meta = image.meta || {}; const prompt = meta.prompt || ''; const negativePrompt = meta.negativePrompt || ''; // Build params display let html = ''; if (prompt) { html += this.renderPromptSection( translate('modals.model.params.prompt', {}, 'Prompt'), prompt, 'prompt' ); } if (negativePrompt) { html += this.renderPromptSection( translate('modals.model.params.negativePrompt', {}, 'Negative Prompt'), negativePrompt, 'negative' ); } // Add parameter tags const params = []; if (meta.sampler) params.push({ name: 'Sampler', value: meta.sampler }); if (meta.steps) params.push({ name: 'Steps', value: meta.steps }); if (meta.cfgScale) params.push({ name: 'CFG', value: meta.cfgScale }); if (meta.seed) params.push({ name: 'Seed', value: meta.seed }); if (meta.size) params.push({ name: 'Size', value: meta.size }); if (params.length > 0) { html += '
'; params.forEach(param => { html += ` ${escapeHtml(param.name)}: ${escapeHtml(String(param.value))} `; }); html += '
'; } if (!prompt && !negativePrompt && params.length === 0) { html = `
${translate('modals.model.params.noData', {}, 'No generation data available')}
`; } content.innerHTML = html; } /** * Render a prompt section */ renderPromptSection(label, text, type) { return `
${escapeHtml(label)}
${escapeHtml(text)}
`; } /** * Copy current prompt to clipboard */ async copyPrompt() { const image = this.images[this.currentIndex]; if (!image) return; const meta = image.meta || {}; const prompt = meta.prompt || ''; if (!prompt) return; try { await navigator.clipboard.writeText(prompt); showToast('modals.model.params.promptCopied', {}, 'success'); } catch (err) { console.error('Failed to copy prompt:', err); } } /** * Set current image as model preview */ async setAsPreview() { const image = this.images[this.currentIndex]; if (!image || !this.filePath) return; const url = image.url || image; try { const client = getModelApiClient(); await client.setModelPreview(this.filePath, url); showToast('modals.model.actions.previewSet', {}, 'success'); } catch (err) { console.error('Failed to set preview:', err); showToast('modals.model.actions.previewFailed', {}, 'error'); } } /** * Delete current example */ async deleteExample() { const image = this.images[this.currentIndex]; if (!image || !this.filePath) return; const url = image.url || image; const isCustom = image.isCustom || false; // Confirm deletion const confirmed = confirm(translate('modals.model.examples.confirmDelete', {}, 'Delete this example image?')); if (!confirmed) return; try { const response = await fetch('/api/lm/delete-example', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model_path: this.filePath, image_url: url, is_custom: isCustom }) }); if (!response.ok) { throw new Error('Failed to delete example'); } showToast('modals.model.examples.deleted', {}, 'success'); // Remove from local array and refresh this.images.splice(this.currentIndex, 1); if (this.currentIndex >= this.images.length) { this.currentIndex = Math.max(0, this.images.length - 1); } // Re-render this.render({ images: this.images, modelHash: this.modelHash, filePath: this.filePath }); if (this.images.length > 0) { this.loadImage(this.currentIndex); } } catch (err) { console.error('Failed to delete example:', err); showToast('modals.model.examples.deleteFailed', {}, 'error'); } } }