/** * ShowcaseView.js * Handles showcase content (images, videos) display for checkpoint modal */ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { state } from '../../state/index.js'; import { NSFW_LEVELS } from '../../utils/constants.js'; /** * Get the local URL for an example image if available * @param {Object} img - Image object * @param {number} index - Image index * @param {string} modelHash - Model hash * @returns {string|null} - Local URL or null if not available */ function getLocalExampleImageUrl(img, index, modelHash) { if (!modelHash) return null; // Get remote extension const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase(); // If it's a video (mp4), use that extension if (remoteExt === 'mp4') { return `/example_images_static/${modelHash}/image_${index + 1}.mp4`; } // For images, check if optimization is enabled (defaults to true) const optimizeImages = state.settings.optimizeExampleImages !== false; // Use .webp for images if optimization enabled, otherwise use original extension const extension = optimizeImages ? 'webp' : remoteExt; return `/example_images_static/${modelHash}/image_${index + 1}.${extension}`; } /** * Render showcase content * @param {Array} images - Array of images/videos to show * @param {string} modelHash - Model hash for identifying local files * @returns {string} HTML content */ export function renderShowcaseContent(images, modelHash) { if (!images?.length) return '
No example images available
'; // 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 `
Scroll or click to show ${filteredImages.length} examples
`; } /** * Generate media wrapper HTML for an image or video * @param {Object} media - Media object with image or video data * @returns {string} HTML content */ function generateMediaWrapper(media, localUrl = null) { // Calculate appropriate aspect ratio: // 1. Keep original aspect ratio // 2. Limit maximum height to 60% of viewport height // 3. Ensure minimum height is 40% of container width const aspectRatio = (media.height / media.width) * 100; const containerWidth = 800; // modal content maximum width const minHeightPercent = 40; const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100; const heightPercent = Math.max( minHeightPercent, Math.min(maxHeightPercent, aspectRatio) ); // Check if media should be blurred const nsfwLevel = media.nsfwLevel !== undefined ? media.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 media const meta = media.meta || {}; const prompt = meta.prompt || ''; const negativePrompt = meta.negative_prompt || meta.negativePrompt || ''; const size = meta.Size || `${media.width}x${media.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 ); // Check if this is a video or image if (media.type === 'video') { return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl); } return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl); } /** * Generate metadata panel HTML */ function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) { // Create unique IDs for prompt copying const promptIndex = Math.random().toString(36).substring(2, 15); const negPromptIndex = Math.random().toString(36).substring(2, 15); let content = '
'; if (hasParams) { content += `
${size ? `
Size:${size}
` : ''} ${seed ? `
Seed:${seed}
` : ''} ${model ? `
Model:${model}
` : ''} ${steps ? `
Steps:${steps}
` : ''} ${sampler ? `
Sampler:${sampler}
` : ''} ${cfgScale ? `
CFG:${cfgScale}
` : ''} ${clipSkip ? `
Clip Skip:${clipSkip}
` : ''}
`; } if (!hasParams && !hasPrompts) { content += ` `; } if (prompt) { content += ` `; } if (negativePrompt) { content += ` `; } content += '
'; return content; } /** * Generate video wrapper HTML */ function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) { return `
${shouldBlur ? ` ` : ''} ${shouldBlur ? `

${nsfwText}

` : ''} ${metadataPanel}
`; } /** * Generate image wrapper HTML */ function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl = null) { return `
${shouldBlur ? ` ` : ''} Preview ${shouldBlur ? `

${nsfwText}

` : ''} ${metadataPanel}
`; } /** * Toggle showcase expansion */ export function toggleShowcase(element) { const carousel = element.nextElementSibling; const isCollapsed = carousel.classList.contains('collapsed'); const indicator = element.querySelector('span'); const icon = element.querySelector('i'); carousel.classList.toggle('collapsed'); if (isCollapsed) { const count = carousel.querySelectorAll('.media-wrapper').length; indicator.textContent = `Scroll or click to hide examples`; icon.classList.replace('fa-chevron-down', 'fa-chevron-up'); initLazyLoading(carousel); // Initialize NSFW content blur toggle handlers initNsfwBlurHandlers(carousel); // Initialize metadata panel interaction handlers initMetadataPanelHandlers(carousel); } else { const count = carousel.querySelectorAll('.media-wrapper').length; indicator.textContent = `Scroll or click to show ${count} examples`; icon.classList.replace('fa-chevron-up', 'fa-chevron-down'); } } /** * Initialize metadata panel interaction handlers */ function initMetadataPanelHandlers(container) { const mediaWrappers = container.querySelectorAll('.media-wrapper'); mediaWrappers.forEach(wrapper => { const metadataPanel = wrapper.querySelector('.image-metadata-panel'); if (!metadataPanel) return; // Prevent events from bubbling metadataPanel.addEventListener('click', (e) => { e.stopPropagation(); }); // Handle copy prompt buttons const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); copyBtns.forEach(copyBtn => { const promptIndex = copyBtn.dataset.promptIndex; const promptElement = wrapper.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) => { e.stopPropagation(); }); }); } /** * Initialize blur toggle handlers */ function initNsfwBlurHandlers(container) { // Handle toggle blur buttons const toggleButtons = container.querySelectorAll('.toggle-blur-btn'); toggleButtons.forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const wrapper = btn.closest('.media-wrapper'); const media = wrapper.querySelector('img, video'); const isBlurred = media.classList.toggle('blurred'); const icon = btn.querySelector('i'); // Update the icon based on blur state if (isBlurred) { icon.className = 'fas fa-eye'; } else { icon.className = 'fas fa-eye-slash'; } // Toggle the overlay visibility const overlay = wrapper.querySelector('.nsfw-overlay'); if (overlay) { overlay.style.display = isBlurred ? 'flex' : 'none'; } }); }); // Handle "Show" buttons in overlays const showButtons = container.querySelectorAll('.show-content-btn'); showButtons.forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const wrapper = btn.closest('.media-wrapper'); const media = wrapper.querySelector('img, video'); media.classList.remove('blurred'); // Update the toggle button icon const toggleBtn = wrapper.querySelector('.toggle-blur-btn'); if (toggleBtn) { toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; } // Hide the overlay const overlay = wrapper.querySelector('.nsfw-overlay'); if (overlay) { overlay.style.display = 'none'; } }); }); } /** * Initialize lazy loading for images and videos */ function initLazyLoading(container) { const lazyElements = container.querySelectorAll('.lazy'); const lazyLoad = (element) => { const localSrc = element.dataset.localSrc; const remoteSrc = element.dataset.remoteSrc; // Check if element is an image or video if (element.tagName.toLowerCase() === 'video') { // Try local first, then remote tryLocalOrFallbackToRemote(element, localSrc, remoteSrc); } else { // For images, we'll use an Image object to test if local file exists tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc); } element.classList.remove('lazy'); }; // Try to load local image first, fall back to remote if local fails const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => { // Only try local if we have a local path if (localSrc) { const testImg = new Image(); testImg.onload = () => { // Local image loaded successfully imgElement.src = localSrc; }; testImg.onerror = () => { // Local image failed, use remote imgElement.src = remoteSrc; }; // Start loading test image testImg.src = localSrc; } else { // No local path, use remote directly imgElement.src = remoteSrc; } }; // Try to load local video first, fall back to remote if local fails const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => { // Only try local if we have a local path if (localSrc) { // Try to fetch local file headers to see if it exists fetch(localSrc, { method: 'HEAD' }) .then(response => { if (response.ok) { // Local video exists, use it videoElement.src = localSrc; videoElement.querySelector('source').src = localSrc; } else { // Local video doesn't exist, use remote videoElement.src = remoteSrc; videoElement.querySelector('source').src = remoteSrc; } videoElement.load(); }) .catch(() => { // Error fetching, use remote videoElement.src = remoteSrc; videoElement.querySelector('source').src = remoteSrc; videoElement.load(); }); } else { // No local path, use remote directly videoElement.src = remoteSrc; videoElement.querySelector('source').src = remoteSrc; videoElement.load(); } }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { lazyLoad(entry.target); observer.unobserve(entry.target); } }); }); lazyElements.forEach(element => observer.observe(element)); } /** * Set up showcase scroll functionality */ export function setupShowcaseScroll() { // Listen for wheel events document.addEventListener('wheel', (event) => { const modalContent = document.querySelector('#checkpointModal .modal-content'); if (!modalContent) return; const showcase = modalContent.querySelector('.showcase-section'); if (!showcase) return; const carousel = showcase.querySelector('.carousel'); const scrollIndicator = showcase.querySelector('.scroll-indicator'); if (carousel?.classList.contains('collapsed') && event.deltaY > 0) { const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100; if (isNearBottom) { toggleShowcase(scrollIndicator); event.preventDefault(); } } }, { passive: false }); // Use MutationObserver to set up back-to-top button when modal content is added const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { if (mutation.type === 'childList' && mutation.addedNodes.length) { const checkpointModal = document.getElementById('checkpointModal'); if (checkpointModal && checkpointModal.querySelector('.modal-content')) { setupBackToTopButton(checkpointModal.querySelector('.modal-content')); } } } }); // Start observing the document body for changes observer.observe(document.body, { childList: true, subtree: true }); // Also try to set up the button immediately in case the modal is already open const modalContent = document.querySelector('#checkpointModal .modal-content'); if (modalContent) { setupBackToTopButton(modalContent); } } /** * Set up back-to-top button */ function setupBackToTopButton(modalContent) { // Remove any existing scroll listeners to avoid duplicates modalContent.onscroll = null; // Add new scroll listener modalContent.addEventListener('scroll', () => { const backToTopBtn = modalContent.querySelector('.back-to-top'); if (backToTopBtn) { if (modalContent.scrollTop > 300) { backToTopBtn.classList.add('visible'); } else { backToTopBtn.classList.remove('visible'); } } }); // Trigger a scroll event to check initial position modalContent.dispatchEvent(new Event('scroll')); } /** * Scroll to top of modal content */ export function scrollToTop(button) { const modalContent = button.closest('.modal-content'); if (modalContent) { modalContent.scrollTo({ top: 0, behavior: 'smooth' }); } }