+ `;
+}
+
+/**
+ * Render a single media item (image or video)
+ * @param {Object} img - Image/video metadata
+ * @param {number} index - Index in the array
+ * @param {Array} exampleFiles - Local files
+ * @returns {string} HTML for the media item
+ */
+function renderMediaItem(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');
+
+ // Calculate appropriate aspect ratio
+ const aspectRatio = (img.height / img.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 = 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 = `
+
+
+ ${isCustomImage ? `
+
+ ` : ''}
+
+ `;
+
+ // Generate the appropriate wrapper based on media type
+ if (isVideo) {
+ return generateVideoWrapper(
+ img, heightPercent, shouldBlur, nsfwText, metadataPanel,
+ localUrl, remoteUrl, mediaControlsHtml
+ );
+ }
+
+ return generateImageWrapper(
+ img, heightPercent, shouldBlur, nsfwText, metadataPanel,
+ localUrl, remoteUrl, mediaControlsHtml
+ );
+}
+
+/**
+ * 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;
+ });
+
+ // If not found by index, just use the same position in the array if available
+ if (!localFile && index < exampleFiles.length) {
+ localFile = exampleFiles[index];
+ }
+ }
+
+ return localFile;
+}
+
+/**
+ * Render the import interface for example images
+ * @param {boolean} isEmpty - Whether there are no existing examples
+ * @returns {string} HTML content for import interface
+ */
+function renderImportInterface(isEmpty) {
+ return `
+
+
+
+
+
${isEmpty ? 'No example images available' : 'Add more examples'}
+
Drag & drop images or videos here
+
or
+
+
Supported formats: jpg, png, gif, webp, mp4, webm
+
+
+
+
+
+
+ Importing files...
+
+
+
+ `;
+}
+
+/**
+ * Initialize the example import functionality
+ * @param {string} modelHash - The SHA256 hash of the model
+ * @param {Element} container - The container element for the import area
+ */
+export function initExampleImport(modelHash, container) {
+ if (!container) return;
+
+ const importContainer = container.querySelector('#exampleImportContainer');
+ const fileInput = container.querySelector('#exampleFilesInput');
+ const selectFilesBtn = container.querySelector('#selectExampleFilesBtn');
+
+ // Set up file selection button
+ if (selectFilesBtn) {
+ selectFilesBtn.addEventListener('click', () => {
+ fileInput.click();
+ });
+ }
+
+ // Handle file selection
+ if (fileInput) {
+ fileInput.addEventListener('change', (e) => {
+ if (e.target.files.length > 0) {
+ handleImportFiles(Array.from(e.target.files), modelHash, importContainer);
+ }
+ });
+ }
+
+ // Set up drag and drop
+ if (importContainer) {
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
+ importContainer.addEventListener(eventName, preventDefaults, false);
+ });
+
+ function preventDefaults(e) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+
+ // Highlight drop area on drag over
+ ['dragenter', 'dragover'].forEach(eventName => {
+ importContainer.addEventListener(eventName, () => {
+ importContainer.classList.add('highlight');
+ }, false);
+ });
+
+ // Remove highlight on drag leave
+ ['dragleave', 'drop'].forEach(eventName => {
+ importContainer.addEventListener(eventName, () => {
+ importContainer.classList.remove('highlight');
+ }, false);
+ });
+
+ // Handle dropped files
+ importContainer.addEventListener('drop', (e) => {
+ const files = Array.from(e.dataTransfer.files);
+ handleImportFiles(files, modelHash, importContainer);
+ }, false);
+ }
+}
+
+/**
+ * Handle the file import process
+ * @param {File[]} files - Array of files to import
+ * @param {string} modelHash - The SHA256 hash of the model
+ * @param {Element} importContainer - The container element for import UI
+ */
+async function handleImportFiles(files, modelHash, importContainer) {
+ // Filter for supported file types
+ const supportedImages = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
+ const supportedVideos = ['.mp4', '.webm'];
+ const supportedExtensions = [...supportedImages, ...supportedVideos];
+
+ const validFiles = files.filter(file => {
+ const ext = '.' + file.name.split('.').pop().toLowerCase();
+ return supportedExtensions.includes(ext);
+ });
+
+ if (validFiles.length === 0) {
+ alert('No supported files selected. Please select image or video files.');
+ return;
+ }
+
+ try {
+ // Use FormData to upload files
+ const formData = new FormData();
+ formData.append('model_hash', modelHash);
+
+ validFiles.forEach(file => {
+ formData.append('files', file);
+ });
+
+ // Call API to import files
+ const response = await fetch('/api/import-example-images', {
+ method: 'POST',
+ body: formData
+ });
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.error || 'Failed to import example files');
+ }
+
+ // Get updated local files
+ const updatedFilesResponse = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
+ const updatedFilesResult = await updatedFilesResponse.json();
+
+ if (!updatedFilesResult.success) {
+ throw new Error(updatedFilesResult.error || 'Failed to get updated file list');
+ }
+
+ // Re-render the showcase content
+ const showcaseTab = document.getElementById('showcase-tab');
+ if (showcaseTab) {
+ // Get the updated images from the result
+ const regularImages = result.regular_images || [];
+ const customImages = result.custom_images || [];
+ // Combine both arrays for rendering
+ const allImages = [...regularImages, ...customImages];
+ showcaseTab.innerHTML = renderShowcaseContent(allImages, updatedFilesResult.files);
+
+ // Re-initialize showcase functionality
+ const carousel = showcaseTab.querySelector('.carousel');
+ if (carousel && !carousel.classList.contains('collapsed')) {
+ initShowcaseContent(carousel);
+ }
+
+ // Initialize the import UI for the new content
+ initExampleImport(modelHash, showcaseTab);
+
+ showToast('Example images imported successfully', 'success');
+
+ // Update VirtualScroller if available
+ if (state.virtualScroller && result.model_file_path) {
+ // Create an update object with only the necessary properties
+ const updateData = {
+ civitai: {
+ images: regularImages,
+ customImages: customImages
+ }
+ };
+
+ // Update the item in the virtual scroller
+ state.virtualScroller.updateSingleItem(result.model_file_path, updateData);
+ }
+ }
+ } catch (error) {
+ console.error('Error importing examples:', error);
+ showToast('Failed to import example images', 'error');
+ }
+}
+
+/**
+ * Toggle showcase expansion
+ * @param {HTMLElement} element - The scroll indicator element
+ */
+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');
+ initShowcaseContent(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');
+
+ // Make sure any open metadata panels get closed
+ const carouselContainer = carousel.querySelector('.carousel-container');
+ if (carouselContainer) {
+ carouselContainer.style.height = '0';
+ setTimeout(() => {
+ carouselContainer.style.height = '';
+ }, 300);
+ }
+ }
+}
+
+/**
+ * Initialize all showcase content interactions
+ * @param {HTMLElement} carousel - The carousel element
+ */
+export function initShowcaseContent(carousel) {
+ if (!carousel) return;
+
+ initLazyLoading(carousel);
+ initNsfwBlurHandlers(carousel);
+ initMetadataPanelHandlers(carousel);
+ initMediaControlHandlers(carousel);
+ positionAllMediaControls(carousel);
+
+ // Add window resize handler
+ const resizeHandler = () => positionAllMediaControls(carousel);
+ window.removeEventListener('resize', resizeHandler);
+ window.addEventListener('resize', resizeHandler);
+
+ // Handle images loading which might change dimensions
+ const mediaElements = carousel.querySelectorAll('img, video');
+ mediaElements.forEach(media => {
+ media.addEventListener('load', () => positionAllMediaControls(carousel));
+ if (media.tagName === 'VIDEO') {
+ media.addEventListener('loadedmetadata', () => positionAllMediaControls(carousel));
+ }
+ });
+}
+
+/**
+ * Scroll to top of modal content
+ * @param {HTMLElement} button - Back to top button
+ */
+export function scrollToTop(button) {
+ const modalContent = button.closest('.modal-content');
+ if (modalContent) {
+ modalContent.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ });
+ }
+}
+
+/**
+ * Set up showcase scroll functionality
+ * @param {string} modalId - ID of the modal element
+ */
+export function setupShowcaseScroll(modalId) {
+ // Listen for wheel events
+ document.addEventListener('wheel', (event) => {
+ const modalContent = document.querySelector(`#${modalId} .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 modal = document.getElementById(modalId);
+ if (modal && modal.querySelector('.modal-content')) {
+ setupBackToTopButton(modal.querySelector('.modal-content'));
+ }
+ }
+ }
+ });
+
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ // Try to set up the button immediately in case the modal is already open
+ const modalContent = document.querySelector(`#${modalId} .modal-content`);
+ if (modalContent) {
+ setupBackToTopButton(modalContent);
+ }
+}
+
+/**
+ * Set up back-to-top button
+ * @param {HTMLElement} modalContent - Modal content element
+ */
+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'));
+}
\ No newline at end of file
diff --git a/static/js/loras.js b/static/js/loras.js
index 3f46a091..131abed8 100644
--- a/static/js/loras.js
+++ b/static/js/loras.js
@@ -1,6 +1,6 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
-import { showLoraModal, toggleShowcase, scrollToTop } from './components/loraModal/index.js';
+import { showLoraModal } from './components/loraModal/index.js';
import { loadMoreLoras } from './api/loraApi.js';
import { updateCardsForBulkMode } from './components/LoraCard.js';
import { bulkManager } from './managers/BulkManager.js';
@@ -43,8 +43,6 @@ class LoraPageManager {
window.closeExcludeModal = closeExcludeModal;
window.downloadManager = this.downloadManager;
window.moveManager = moveManager;
- window.toggleShowcase = toggleShowcase;
- window.scrollToTop = scrollToTop;
// Bulk operations
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js
index 98de6a5c..9265df9e 100644
--- a/static/js/utils/uiHelpers.js
+++ b/static/js/utils/uiHelpers.js
@@ -441,521 +441,4 @@ export async function openExampleImagesFolder(modelHash) {
showToast('Failed to open example images folder', 'error');
return false;
}
-}
-
-/**
- * Gets local URLs for example images with primary and fallback options
- * @param {Object} img - Image object
- * @param {number} index - Image index
- * @param {string} modelHash - Model hash
- * @returns {Object} - Object with primary and fallback URLs
- */
-export function getLocalExampleImageUrl(img, index, modelHash) {
- if (!modelHash) return { primary: null, fallback: null };
-
- // Get remote extension
- const remoteExt = (img.url || '').split('?')[0].split('.').pop().toLowerCase();
-
- // If it's a video (mp4), use that extension with no fallback
- if (remoteExt === 'mp4') {
- const videoUrl = `/example_images_static/${modelHash}/image_${index + 1}.mp4`;
- return { primary: videoUrl, fallback: null };
- }
-
- // For images, prepare both possible formats
- const basePath = `/example_images_static/${modelHash}/image_${index + 1}`;
- const webpUrl = `${basePath}.webp`;
- const originalExtUrl = remoteExt ? `${basePath}.${remoteExt}` : `${basePath}.jpg`;
-
- // Check if optimization is enabled (defaults to true)
- const optimizeImages = state.settings.optimizeExampleImages !== false;
-
- // Return primary and fallback URLs based on current settings
- return {
- primary: optimizeImages ? webpUrl : originalExtUrl,
- fallback: optimizeImages ? originalExtUrl : webpUrl
- };
-}
-
-/**
- * Try to load local image first, fall back to remote if local fails
- * @param {HTMLImageElement} imgElement - The image element to update
- * @param {Object} urls - Object with local URLs {primary, fallback} and remote URL
- */
-export function tryLocalImageOrFallbackToRemote(imgElement, urls) {
- const { primary: localUrl, fallback: fallbackUrl } = urls.local || {};
- const remoteUrl = urls.remote;
-
- // If no local options, use remote directly
- if (!localUrl) {
- imgElement.src = remoteUrl;
- return;
- }
-
- // Try primary local URL
- const testImg = new Image();
- testImg.onload = () => {
- // Primary local image loaded successfully
- imgElement.src = localUrl;
- };
- testImg.onerror = () => {
- // Try fallback URL if available
- if (fallbackUrl) {
- const fallbackImg = new Image();
- fallbackImg.onload = () => {
- imgElement.src = fallbackUrl;
- };
- fallbackImg.onerror = () => {
- // Both local options failed, use remote
- imgElement.src = remoteUrl;
- };
- fallbackImg.src = fallbackUrl;
- } else {
- // No fallback, use remote
- imgElement.src = remoteUrl;
- }
- };
- testImg.src = localUrl;
-}
-
-/**
- * Try to load local video first, fall back to remote if local fails
- * @param {HTMLVideoElement} videoElement - The video element to update
- * @param {Object} urls - Object with local URLs {primary} and remote URL
- */
-export function tryLocalVideoOrFallbackToRemote(videoElement, urls) {
- const { primary: localUrl } = urls.local || {};
- const remoteUrl = urls.remote;
-
- // Only try local if we have a local path
- if (localUrl) {
- // Try to fetch local file headers to see if it exists
- fetch(localUrl, { method: 'HEAD' })
- .then(response => {
- if (response.ok) {
- // Local video exists, use it
- videoElement.src = localUrl;
- const source = videoElement.querySelector('source');
- if (source) source.src = localUrl;
- } else {
- // Local video doesn't exist, use remote
- videoElement.src = remoteUrl;
- const source = videoElement.querySelector('source');
- if (source) source.src = remoteUrl;
- }
- videoElement.load();
- })
- .catch(() => {
- // Error fetching, use remote
- videoElement.src = remoteUrl;
- const source = videoElement.querySelector('source');
- if (source) source.src = remoteUrl;
- videoElement.load();
- });
- } else {
- // No local path, use remote directly
- videoElement.src = remoteUrl;
- const source = videoElement.querySelector('source');
- if (source) source.src = remoteUrl;
- videoElement.load();
- }
-}
-
-/**
- * Initialize lazy loading for images and videos in a container
- * @param {HTMLElement} container - The container with lazy-loadable elements
- */
-export function initLazyLoading(container) {
- const lazyElements = container.querySelectorAll('.lazy');
-
- const lazyLoad = (element) => {
- // Get URLs from data attributes
- const localUrls = {
- primary: element.dataset.localSrc || null,
- fallback: element.dataset.localFallbackSrc || null
- };
- const remoteUrl = element.dataset.remoteSrc;
-
- const urls = {
- local: localUrls,
- remote: remoteUrl
- };
-
- // Check if element is a video or image
- if (element.tagName.toLowerCase() === 'video') {
- tryLocalVideoOrFallbackToRemote(element, urls);
- } else {
- tryLocalImageOrFallbackToRemote(element, urls);
- }
-
- element.classList.remove('lazy');
- };
-
- const observer = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- lazyLoad(entry.target);
- observer.unobserve(entry.target);
- }
- });
- });
-
- lazyElements.forEach(element => observer.observe(element));
-}
-
-/**
- * Get the actual rendered rectangle of a media element with object-fit: contain
- * @param {HTMLElement} mediaElement - The img or video element
- * @param {number} containerWidth - Width of the container
- * @param {number} containerHeight - Height of the container
- * @returns {Object} - Rect with left, top, right, bottom coordinates
- */
-export function getRenderedMediaRect(mediaElement, containerWidth, containerHeight) {
- // Get natural dimensions of the media
- const naturalWidth = mediaElement.naturalWidth || mediaElement.videoWidth || mediaElement.clientWidth;
- const naturalHeight = mediaElement.naturalHeight || mediaElement.videoHeight || mediaElement.clientHeight;
-
- if (!naturalWidth || !naturalHeight) {
- // Fallback if dimensions cannot be determined
- return { left: 0, top: 0, right: containerWidth, bottom: containerHeight };
- }
-
- // Calculate aspect ratios
- const containerRatio = containerWidth / containerHeight;
- const mediaRatio = naturalWidth / naturalHeight;
-
- let renderedWidth, renderedHeight, left = 0, top = 0;
-
- // Apply object-fit: contain logic
- if (containerRatio > mediaRatio) {
- // Container is wider than media - will have empty space on sides
- renderedHeight = containerHeight;
- renderedWidth = renderedHeight * mediaRatio;
- left = (containerWidth - renderedWidth) / 2;
- } else {
- // Container is taller than media - will have empty space top/bottom
- renderedWidth = containerWidth;
- renderedHeight = renderedWidth / mediaRatio;
- top = (containerHeight - renderedHeight) / 2;
- }
-
- return {
- left,
- top,
- right: left + renderedWidth,
- bottom: top + renderedHeight
- };
-}
-
-/**
- * Initialize metadata panel interaction handlers
- * @param {HTMLElement} container - Container element with media wrappers
- */
-export function initMetadataPanelHandlers(container) {
- const mediaWrappers = container.querySelectorAll('.media-wrapper');
-
- mediaWrappers.forEach(wrapper => {
- // Get the metadata panel and media element (img or video)
- const metadataPanel = wrapper.querySelector('.image-metadata-panel');
- const mediaElement = wrapper.querySelector('img, video');
-
- if (!metadataPanel || !mediaElement) return;
-
- let isOverMetadataPanel = false;
-
- // Add event listeners to the wrapper for mouse tracking
- wrapper.addEventListener('mousemove', (e) => {
- // Get mouse position relative to wrapper
- const rect = wrapper.getBoundingClientRect();
- const mouseX = e.clientX - rect.left;
- const mouseY = e.clientY - rect.top;
-
- // Get the actual displayed dimensions of the media element
- const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
-
- // Check if mouse is over the actual media content
- const isOverMedia = (
- mouseX >= mediaRect.left &&
- mouseX <= mediaRect.right &&
- mouseY >= mediaRect.top &&
- mouseY <= mediaRect.bottom
- );
-
- // Show metadata panel when over media content or metadata panel itself
- if (isOverMedia || isOverMetadataPanel) {
- metadataPanel.classList.add('visible');
- } else {
- metadataPanel.classList.remove('visible');
- }
- });
-
- wrapper.addEventListener('mouseleave', () => {
- if (!isOverMetadataPanel) {
- metadataPanel.classList.remove('visible');
- }
- });
-
- // Add mouse enter/leave events for the metadata panel itself
- metadataPanel.addEventListener('mouseenter', () => {
- isOverMetadataPanel = true;
- metadataPanel.classList.add('visible');
- });
-
- metadataPanel.addEventListener('mouseleave', () => {
- isOverMetadataPanel = false;
- // Only hide if mouse is not over the media
- const rect = wrapper.getBoundingClientRect();
- const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
- const mouseX = event.clientX - rect.left;
- const mouseY = event.clientY - rect.top;
-
- const isOverMedia = (
- mouseX >= mediaRect.left &&
- mouseX <= mediaRect.right &&
- mouseY >= mediaRect.top &&
- mouseY <= mediaRect.bottom
- );
-
- if (!isOverMedia) {
- metadataPanel.classList.remove('visible');
- }
- });
-
- // 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) => {
- const isAtTop = metadataPanel.scrollTop === 0;
- const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
-
- // Only prevent default if scrolling would cause the panel to scroll
- if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
- e.stopPropagation();
- }
- }, { passive: true });
- });
-}
-
-/**
- * Initialize NSFW content blur toggle handlers
- * @param {HTMLElement} container - Container element with media wrappers
- */
-export 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';
- }
- });
- });
-}
-
-/**
- * Toggle showcase expansion
- * @param {HTMLElement} element - The scroll indicator element
- */
-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');
-
- // Make sure any open metadata panels get closed
- const carouselContainer = carousel.querySelector('.carousel-container');
- if (carouselContainer) {
- carouselContainer.style.height = '0';
- setTimeout(() => {
- carouselContainer.style.height = '';
- }, 300);
- }
- }
-}
-
-/**
- * Set up showcase scroll functionality
- * @param {string} modalId - ID of the modal element
- */
-export function setupShowcaseScroll(modalId) {
- // Listen for wheel events
- document.addEventListener('wheel', (event) => {
- const modalContent = document.querySelector(`#${modalId} .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 modal = document.getElementById(modalId);
- if (modal && modal.querySelector('.modal-content')) {
- setupBackToTopButton(modal.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(`#${modalId} .modal-content`);
- if (modalContent) {
- setupBackToTopButton(modalContent);
- }
-}
-
-/**
- * Set up back-to-top button
- * @param {HTMLElement} modalContent - Modal content element
- */
-export 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
- * @param {HTMLElement} button - Back to top button element
- */
-export function scrollToTop(button) {
- const modalContent = button.closest('.modal-content');
- if (modalContent) {
- modalContent.scrollTo({
- top: 0,
- behavior: 'smooth'
- });
- }
-}
-
-/**
- * Get example image files for a specific model from the backend
- * @param {string} modelHash - The model's hash
- * @returns {Promise} Array of file objects with path and metadata
- */
-export async function getExampleImageFiles(modelHash) {
- try {
- const response = await fetch(`/api/example-image-files?model_hash=${modelHash}`);
- const result = await response.json();
-
- if (result.success) {
- return result.files;
- } else {
- console.error('Failed to get example image files:', result.error);
- return [];
- }
- } catch (error) {
- console.error('Error fetching example image files:', error);
- return [];
- }
}
\ No newline at end of file