/** * ShowcaseView.js * Shared showcase component for displaying examples in model modals (Lora/Checkpoint) */ import { showToast } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.js'; import { NSFW_LEVELS } from '../../../utils/constants.js'; import { initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers, initMediaControlHandlers } from './MediaUtils.js'; import { generateMetadataPanel } from './MetadataPanel.js'; import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; /** * Load example images asynchronously * @param {Array} images - Array of image objects (both regular and custom) * @param {string} modelHash - Model hash for fetching local files */ export async function loadExampleImages(images, modelHash) { try { const showcaseTab = document.getElementById('showcase-tab'); if (!showcaseTab) return; // First fetch local example files let localFiles = []; try { const endpoint = '/api/example-image-files'; const params = `model_hash=${modelHash}`; const response = await fetch(`${endpoint}?${params}`); const result = await response.json(); if (result.success) { localFiles = result.files; } } catch (error) { console.error("Failed to get example files:", error); } // Then render with both remote images and local files showcaseTab.innerHTML = renderShowcaseContent(images, localFiles); // Re-initialize the showcase event listeners initShowcaseContent(showcaseTab); // Initialize the example import functionality // initExampleImport(modelHash, showcaseTab); } catch (error) { console.error('Error loading example images:', error); const showcaseTab = document.getElementById('showcase-tab'); if (showcaseTab) { showcaseTab.innerHTML = `
Error loading example images
`; } } } /** * Render showcase content * @param {Array} images - Array of images/videos to show * @param {Array} exampleFiles - Local example files * @param {boolean} startExpanded - Whether to start in expanded state (unused in new design) * @returns {string} HTML content */ export function renderShowcaseContent(images, exampleFiles = [], startExpanded = false) { if (!images?.length) { // Show empty state with import interface return renderEmptyShowcase(); } // Filter images based on SFW setting const showOnlySFW = state.settings.show_only_sfw; let filteredImages = images; let hiddenCount = 0; if (showOnlySFW) { filteredImages = images.filter(img => { const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const isSfw = nsfwLevel < NSFW_LEVELS.R; if (!isSfw) hiddenCount++; return isSfw; }); } // Show message if no images are available after filtering if (filteredImages.length === 0) { return `

All example images are filtered due to NSFW content settings

Your settings are currently set to show only safe-for-work content

You can change this in Settings

`; } // Show hidden content notification if applicable const hiddenNotification = hiddenCount > 0 ? `
${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting
` : ''; return ` ${hiddenNotification}
${filteredImages.map((img, index) => renderThumbnail(img, index, exampleFiles)).join('')}
${renderImportInterface()}
${filteredImages.length > 0 ? renderMainMediaItem(filteredImages[0], 0, exampleFiles) : ''}
`; } /** * Find the matching local file for an image * @param {Object} img - Image metadata * @param {number} index - Image index * @param {Array} exampleFiles - Array of local files * @returns {Object|null} Matching local file or null */ function findLocalFile(img, index, exampleFiles) { if (!exampleFiles || exampleFiles.length === 0) return null; let localFile = null; if (img.id) { // This is a custom image, find by custom_ const customPrefix = `custom_${img.id}`; localFile = exampleFiles.find(file => file.name.startsWith(customPrefix)); } else { // This is a regular image from civitai, find by index localFile = exampleFiles.find(file => { const match = file.name.match(/image_(\d+)\./); return match && parseInt(match[1]) === index; }); } return localFile; } /** * Render a thumbnail for the sidebar * @param {Object} img - Image/video metadata * @param {number} index - Index in the array * @param {Array} exampleFiles - Local files * @returns {string} HTML for the thumbnail */ function renderThumbnail(img, index, exampleFiles) { // Find matching file in our list of actual files let localFile = findLocalFile(img, index, exampleFiles); const remoteUrl = img.url || ''; const localUrl = localFile ? localFile.path : ''; const isVideo = localFile ? localFile.is_video : remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm'); // Check if media should be blurred const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; return `
${isVideo ? `
` : ` Thumbnail `} ${shouldBlur ? `
` : ''}
`; } /** * Render the main media item in the display area * @param {Object} img - Image/video metadata * @param {number} index - Index in the array * @param {Array} exampleFiles - Local files * @returns {string} HTML for the main media item */ function renderMainMediaItem(img, index, exampleFiles) { // Find matching file in our list of actual files let localFile = findLocalFile(img, index, exampleFiles); const remoteUrl = img.url || ''; const localUrl = localFile ? localFile.path : ''; const isVideo = localFile ? localFile.is_video : remoteUrl.endsWith('.mp4') || remoteUrl.endsWith('.webm'); // Check if media should be blurred const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; // Determine NSFW warning text based on level let nsfwText = "Mature Content"; if (nsfwLevel >= NSFW_LEVELS.XXX) { nsfwText = "XXX-rated Content"; } else if (nsfwLevel >= NSFW_LEVELS.X) { nsfwText = "X-rated Content"; } else if (nsfwLevel >= NSFW_LEVELS.R) { nsfwText = "R-rated Content"; } // Extract metadata from the image const meta = img.meta || {}; const prompt = meta.prompt || ''; const negativePrompt = meta.negative_prompt || meta.negativePrompt || ''; const size = meta.Size || `${img.width}x${img.height}`; const seed = meta.seed || ''; const model = meta.Model || ''; const steps = meta.steps || ''; const sampler = meta.sampler || ''; const cfgScale = meta.cfgScale || ''; const clipSkip = meta.clipSkip || ''; // Check if we have any meaningful generation parameters const hasParams = seed || model || steps || sampler || cfgScale || clipSkip; const hasPrompts = prompt || negativePrompt; // Create metadata panel content const metadataPanel = generateMetadataPanel( hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip ); // Determine if this is a custom image (has id property) const isCustomImage = Boolean(img.id); // Create the media control buttons HTML const mediaControlsHtml = `
`; // Generate the appropriate wrapper based on media type if (isVideo) { return generateVideoWrapper( img, 100, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml ); } return generateImageWrapper( img, 100, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml ); } /** * Render empty showcase with import interface * @returns {string} HTML content for empty showcase */ function renderEmptyShowcase() { return `
${renderImportInterface()}

No example images available

Import images or videos using the sidebar

`; } /** * Render the import interface for example images * @returns {string} HTML content for import interface */ function renderImportInterface() { return `
Drop here
`; } /** * Initialize all showcase content interactions * @param {HTMLElement} showcase - The showcase element */ export function initShowcaseContent(showcase) { if (!showcase) return; const container = showcase.querySelector('.showcase-container'); if (!container) return; initLazyLoading(container); initNsfwBlurHandlers(container); initThumbnailNavigation(container); initMainDisplayHandlers(container); initMediaControlHandlers(container); // Initialize keyboard navigation initKeyboardNavigation(container); } /** * Initialize thumbnail navigation * @param {HTMLElement} container - The showcase container */ function initThumbnailNavigation(container) { const thumbnails = container.querySelectorAll('.thumbnail-item'); const mainContainer = container.querySelector('#mainMediaContainer'); if (!mainContainer) return; thumbnails.forEach((thumbnail, index) => { thumbnail.addEventListener('click', () => { // Update active thumbnail thumbnails.forEach(t => t.classList.remove('active')); thumbnail.classList.add('active'); // Get the corresponding image data and render main media const showcaseSection = document.querySelector('.showcase-section'); const modelHash = showcaseSection?.dataset.modelHash; // This would need access to the filtered images array // For now, we'll trigger a re-render of the main display updateMainDisplay(index, container); }); }); } /** * Initialize main display handlers including navigation and info toggle * @param {HTMLElement} container - The showcase container */ function initMainDisplayHandlers(container) { const prevBtn = container.querySelector('#prevBtn'); const nextBtn = container.querySelector('#nextBtn'); const infoBtn = container.querySelector('#infoBtn'); if (prevBtn) { prevBtn.addEventListener('click', () => navigateMedia(container, -1)); } if (nextBtn) { nextBtn.addEventListener('click', () => navigateMedia(container, 1)); } if (infoBtn) { infoBtn.addEventListener('click', () => toggleMetadataPanel(container)); } // Initialize metadata panel toggle behavior initMetadataPanelToggle(container); } /** * Initialize keyboard navigation * @param {HTMLElement} container - The showcase container */ function initKeyboardNavigation(container) { document.addEventListener('keydown', (e) => { // Only handle if showcase is visible and focused if (!container.closest('.modal').classList.contains('show')) return; switch(e.key) { case 'ArrowLeft': e.preventDefault(); navigateMedia(container, -1); break; case 'ArrowRight': e.preventDefault(); navigateMedia(container, 1); break; case 'i': case 'I': e.preventDefault(); toggleMetadataPanel(container); break; } }); } /** * Navigate to previous/next media item * @param {HTMLElement} container - The showcase container * @param {number} direction - -1 for previous, 1 for next */ function navigateMedia(container, direction) { const thumbnails = container.querySelectorAll('.thumbnail-item'); const activeThumbnail = container.querySelector('.thumbnail-item.active'); if (!activeThumbnail || thumbnails.length === 0) return; const currentIndex = Array.from(thumbnails).indexOf(activeThumbnail); let newIndex = currentIndex + direction; // Wrap around if (newIndex < 0) newIndex = thumbnails.length - 1; if (newIndex >= thumbnails.length) newIndex = 0; // Click the new thumbnail to trigger the display update thumbnails[newIndex].click(); } /** * Toggle metadata panel visibility * @param {HTMLElement} container - The showcase container */ function toggleMetadataPanel(container) { const metadataPanel = container.querySelector('.image-metadata-panel'); const infoBtn = container.querySelector('#infoBtn'); if (!metadataPanel || !infoBtn) return; const isVisible = metadataPanel.classList.contains('visible'); if (isVisible) { metadataPanel.classList.remove('visible'); infoBtn.classList.remove('active'); } else { metadataPanel.classList.add('visible'); infoBtn.classList.add('active'); } } /** * Initialize metadata panel toggle behavior * @param {HTMLElement} container - The showcase container */ function initMetadataPanelToggle(container) { const metadataPanel = container.querySelector('.image-metadata-panel'); if (!metadataPanel) return; // Handle copy prompt buttons const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); copyBtns.forEach(copyBtn => { const promptIndex = copyBtn.dataset.promptIndex; const promptElement = container.querySelector(`#prompt-${promptIndex}`); copyBtn.addEventListener('click', async (e) => { e.stopPropagation(); if (!promptElement) return; try { await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard'); } catch (err) { console.error('Copy failed:', err); showToast('Copy failed', 'error'); } }); }); // Prevent panel scroll from causing modal scroll metadataPanel.addEventListener('wheel', (e) => { const isAtTop = metadataPanel.scrollTop === 0; const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight; if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) { e.stopPropagation(); } }, { passive: true }); } /** * Update main display with new media item * @param {number} index - Index of the media to display * @param {HTMLElement} container - The showcase container */ function updateMainDisplay(index, container) { // This function would need to re-render the main display area // Implementation depends on how the image data is stored and accessed console.log('Update main display to index:', index); }