diff --git a/static/css/components/lora-modal/showcase.css b/static/css/components/lora-modal/showcase.css index 843da923..6efe8b1d 100644 --- a/static/css/components/lora-modal/showcase.css +++ b/static/css/components/lora-modal/showcase.css @@ -79,14 +79,79 @@ /* Position the toggle button at the top left of showcase media */ .showcase-toggle-btn { position: absolute; - left: var(--space-1); - top: var(--space-1); z-index: 3; } -/* Make sure media wrapper maintains position: relative for absolute positioning of children */ -.carousel .media-wrapper { - position: relative; +/* Add styles for showcase media controls */ +.media-controls { + position: absolute; + display: flex; + gap: 6px; + z-index: 4; + opacity: 0; + transform: translateY(-5px); + transition: opacity 0.2s ease, transform 0.2s ease; + pointer-events: none; +} + +.media-wrapper:hover .media-controls, +.media-controls.visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.media-control-btn { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-color); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); + padding: 0; +} + +.media-control-btn:hover { + transform: translateY(-2px); + box-shadow: 0 3px 7px rgba(0, 0, 0, 0.2); +} + +.media-control-btn.set-preview-btn:hover { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + +.media-control-btn.example-delete-btn:hover { + background: var(--lora-error); + color: white; + border-color: var(--lora-error); +} + +/* Two-step confirmation for delete button */ +.media-control-btn.example-delete-btn.confirm { + background: var(--lora-error); + color: white; + border-color: var(--lora-error); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); + } + 70% { + box-shadow: 0 0 0 5px rgba(220, 53, 69, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); + } } /* Image Metadata Panel Styles */ diff --git a/static/js/components/checkpointModal/ShowcaseView.js b/static/js/components/checkpointModal/ShowcaseView.js deleted file mode 100644 index a5cdfa2f..00000000 --- a/static/js/components/checkpointModal/ShowcaseView.js +++ /dev/null @@ -1,346 +0,0 @@ -/** - * ShowcaseView.js - * Handles showcase content (images, videos) display for checkpoint modal - */ -import { - toggleShowcase, - setupShowcaseScroll, - scrollToTop -} from '../../utils/uiHelpers.js'; -import { state } from '../../state/index.js'; -import { NSFW_LEVELS } from '../../utils/constants.js'; - -/** - * Render showcase content - * @param {Array} images - Array of images/videos to show - * @param {string} modelHash - Model hash for identifying local files - * @param {Array} exampleFiles - Local example files already fetched - * @returns {string} HTML content - */ -export function renderShowcaseContent(images, exampleFiles = []) { - 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, urls) { - // Calculate appropriate aspect ratio - 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, urls); - } - - return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, urls); -} - -/** - * 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, remoteUrl) { - return ` -
- ${shouldBlur ? ` - - ` : ''} - - ${shouldBlur ? ` -
-
-

${nsfwText}

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

${nsfwText}

- -
-
- ` : ''} - ${metadataPanel} -
- `; -} - -// Use the shared setupShowcaseScroll function with the correct modal ID -export { setupShowcaseScroll, scrollToTop, toggleShowcase }; - -// Initialize the showcase scroll when this module is imported -document.addEventListener('DOMContentLoaded', () => { - setupShowcaseScroll('checkpointModal'); -}); diff --git a/static/js/components/checkpointModal/index.js b/static/js/components/checkpointModal/index.js index 24065c50..3a287f3b 100644 --- a/static/js/components/checkpointModal/index.js +++ b/static/js/components/checkpointModal/index.js @@ -3,9 +3,16 @@ * * Modularized checkpoint modal component that handles checkpoint model details display */ -import { showToast, initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers } from '../../utils/uiHelpers.js'; +import { showToast } from '../../utils/uiHelpers.js'; import { modalManager } from '../../managers/ModalManager.js'; -import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js'; +import { + renderShowcaseContent, + initShowcaseContent, + toggleShowcase, + setupShowcaseScroll, + scrollToTop, + initExampleImport +} from '../shared/showcase/ShowcaseView.js'; import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; import { setupModelNameEditing, @@ -157,9 +164,8 @@ export function showCheckpointModal(checkpoint) { * Load example images asynchronously * @param {Array} images - Array of image objects * @param {string} modelHash - Model hash for fetching local files - * @param {string} filePath - File path for fetching local files */ -async function loadExampleImages(images, modelHash, filePath) { +async function loadExampleImages(images, modelHash) { try { const showcaseTab = document.getElementById('showcase-tab'); if (!showcaseTab) return; @@ -186,14 +192,12 @@ async function loadExampleImages(images, modelHash, filePath) { // Re-initialize the showcase event listeners const carousel = showcaseTab.querySelector('.carousel'); - if (carousel) { - // Only initialize if we actually have examples and they're expanded - if (!carousel.classList.contains('collapsed')) { - initLazyLoading(carousel); - initNsfwBlurHandlers(carousel); - initMetadataPanelHandlers(carousel); - } + if (carousel && !carousel.classList.contains('collapsed')) { + initShowcaseContent(carousel); } + + // Initialize the example import functionality + initExampleImport(modelHash, showcaseTab); } catch (error) { console.error('Error loading example images:', error); const showcaseTab = document.getElementById('showcase-tab'); diff --git a/static/js/components/loraModal/ShowcaseView.js b/static/js/components/loraModal/ShowcaseView.js deleted file mode 100644 index 0a0abcce..00000000 --- a/static/js/components/loraModal/ShowcaseView.js +++ /dev/null @@ -1,485 +0,0 @@ -/** - * ShowcaseView.js - * 处理LoRA模型展示内容(图片、视频)的功能模块 - */ -import { - toggleShowcase, - setupShowcaseScroll, - scrollToTop -} from '../../utils/uiHelpers.js'; -import { state } from '../../state/index.js'; -import { NSFW_LEVELS } from '../../utils/constants.js'; - -/** - * 获取展示内容并进行渲染 - * @param {Array} images - 要展示的图片/视频数组 - * @param {Array} exampleFiles - Local example files already fetched - * @returns {Promise} HTML内容 - */ -export function renderShowcaseContent(images, exampleFiles = []) { - if (!images?.length) { - // Replace empty message with import interface - return renderImportInterface(true); - } - - // 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 -
- - `; -} - -/** - * 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

-
- - -
-
- `; -} - -/** - * Initialize the import functionality for example images - * @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 { - // Get file paths to send to backend - const filePaths = validFiles.map(file => { - // We need the full path, but we only have the filename - // For security reasons, browsers don't provide full paths - // This will only work if the backend can handle just filenames - return URL.createObjectURL(file); - }); - - // Use FileReader to get the file data for direct upload - 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) { - if (!carousel.classList.contains('collapsed')) { - initLazyLoading(carousel); - initNsfwBlurHandlers(carousel); - initMetadataPanelHandlers(carousel); - } - // Initialize the import UI for the new content - initExampleImport(modelHash, showcaseTab); - } - - // 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); - console.log('Updated VirtualScroller item with new example images'); - } - } - } catch (error) { - console.error('Error importing examples:', error); - } -} - -/** - * 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 = '
'; - return content; -} - -/** - * 生成视频包装HTML - */ -function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) { - return ` -
- ${shouldBlur ? ` - - ` : ''} - - ${shouldBlur ? ` -
-
-

${nsfwText}

- -
-
- ` : ''} - ${metadataPanel} -
- `; -} - -/** - * 生成图片包装HTML - */ -function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) { - return ` -
- ${shouldBlur ? ` - - ` : ''} - Preview - ${shouldBlur ? ` -
-
-

${nsfwText}

- -
-
- ` : ''} - ${metadataPanel} -
- `; -} - -// Use the shared setupShowcaseScroll function with the correct modal ID -export { setupShowcaseScroll, scrollToTop, toggleShowcase }; - -// Initialize the showcase scroll when this module is imported -document.addEventListener('DOMContentLoaded', () => { - setupShowcaseScroll('loraModal'); -}); diff --git a/static/js/components/loraModal/index.js b/static/js/components/loraModal/index.js index ae7eb73a..566899cb 100644 --- a/static/js/components/loraModal/index.js +++ b/static/js/components/loraModal/index.js @@ -7,11 +7,12 @@ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { modalManager } from '../../managers/ModalManager.js'; import { renderShowcaseContent, - toggleShowcase, + initShowcaseContent, + toggleShowcase, setupShowcaseScroll, scrollToTop, - initExampleImport -} from './ShowcaseView.js'; + initExampleImport +} from '../shared/showcase/ShowcaseView.js'; import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { parsePresets, renderPresetTags } from './PresetTags.js'; @@ -175,7 +176,7 @@ export function showLoraModal(lora) { modalManager.showModal('loraModal', content); setupEditableFields(lora.file_path); - setupShowcaseScroll(); + setupShowcaseScroll('loraModal'); setupTabSwitching(); setupTagTooltip(); setupTriggerWordsEditMode(); @@ -232,13 +233,8 @@ async function loadExampleImages(images, modelHash) { // Re-initialize the showcase event listeners const carousel = showcaseTab.querySelector('.carousel'); - if (carousel) { - // Only initialize if we actually have examples and they're expanded - if (!carousel.classList.contains('collapsed')) { - initLazyLoading(carousel); - initNsfwBlurHandlers(carousel); - initMetadataPanelHandlers(carousel); - } + if (carousel && !carousel.classList.contains('collapsed')) { + initShowcaseContent(carousel); } // Initialize the example import functionality @@ -368,5 +364,4 @@ function setupEditableFields(filePath) { }); } -// Export functions for global access -export { toggleShowcase, scrollToTop }; \ No newline at end of file +window.scrollToTop = scrollToTop; \ No newline at end of file diff --git a/static/js/components/shared/showcase/MediaRenderers.js b/static/js/components/shared/showcase/MediaRenderers.js new file mode 100644 index 00000000..1cb371f3 --- /dev/null +++ b/static/js/components/shared/showcase/MediaRenderers.js @@ -0,0 +1,88 @@ +/** + * MediaRenderers.js + * HTML generators for media items (images/videos) in the showcase + */ + +/** + * Generate video wrapper HTML + * @param {Object} media - Media metadata + * @param {number} heightPercent - Height percentage for container + * @param {boolean} shouldBlur - Whether content should be blurred + * @param {string} nsfwText - NSFW warning text + * @param {string} metadataPanel - Metadata panel HTML + * @param {string} localUrl - Local file URL + * @param {string} remoteUrl - Remote file URL + * @param {string} mediaControlsHtml - HTML for media control buttons + * @returns {string} HTML content + */ +export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') { + return ` +
+ ${shouldBlur ? ` + + ` : ''} + ${mediaControlsHtml} + + ${shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''} + ${metadataPanel} +
+ `; +} + +/** + * Generate image wrapper HTML + * @param {Object} media - Media metadata + * @param {number} heightPercent - Height percentage for container + * @param {boolean} shouldBlur - Whether content should be blurred + * @param {string} nsfwText - NSFW warning text + * @param {string} metadataPanel - Metadata panel HTML + * @param {string} localUrl - Local file URL + * @param {string} remoteUrl - Remote file URL + * @param {string} mediaControlsHtml - HTML for media control buttons + * @returns {string} HTML content + */ +export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') { + return ` +
+ ${shouldBlur ? ` + + ` : ''} + ${mediaControlsHtml} + Preview + ${shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''} + ${metadataPanel} +
+ `; +} \ No newline at end of file diff --git a/static/js/components/shared/showcase/MediaUtils.js b/static/js/components/shared/showcase/MediaUtils.js new file mode 100644 index 00000000..7b85cf00 --- /dev/null +++ b/static/js/components/shared/showcase/MediaUtils.js @@ -0,0 +1,532 @@ +/** + * MediaUtils.js + * Media-specific utility functions for showcase components + * (Moved from uiHelpers.js to better organize code) + */ +import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js'; +import { state } from '../../../state/index.js'; + +/** + * 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'; + } + }); + }); +} + +/** + * Initialize media control buttons event handlers + * @param {HTMLElement} container - Container with media wrappers + */ +export function initMediaControlHandlers(container) { + // Find all delete buttons in the container + const deleteButtons = container.querySelectorAll('.example-delete-btn'); + + deleteButtons.forEach(btn => { + // Set initial state + btn.dataset.state = 'initial'; + + btn.addEventListener('click', async function(e) { + e.stopPropagation(); + const shortId = this.dataset.shortId; + const state = this.dataset.state; + + if (!shortId) return; + + // Handle two-step confirmation + if (state === 'initial') { + // First click: show confirmation state + this.dataset.state = 'confirm'; + this.classList.add('confirm'); + this.title = 'Click again to confirm deletion'; + + // Auto-reset after 3 seconds + setTimeout(() => { + if (this.dataset.state === 'confirm') { + this.dataset.state = 'initial'; + this.classList.remove('confirm'); + this.title = 'Delete this example'; + } + }, 3000); + + return; + } + + // Second click within 3 seconds: proceed with deletion + if (state === 'confirm') { + this.disabled = true; + this.innerHTML = ''; + + // Get model hash from URL or data attribute + const mediaWrapper = this.closest('.media-wrapper'); + const modelIdAttr = document.querySelector('.showcase-section')?.dataset; + const modelHash = modelIdAttr?.loraId || modelIdAttr?.checkpointId; + + try { + // Call the API to delete the custom example + const response = await fetch('/api/delete-example-image', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + model_hash: modelHash, + short_id: shortId + }) + }); + + const result = await response.json(); + + if (result.success) { + // Success: remove the media wrapper from the DOM + mediaWrapper.style.opacity = '0'; + mediaWrapper.style.height = '0'; + mediaWrapper.style.transition = 'opacity 0.3s ease, height 0.3s ease 0.3s'; + + setTimeout(() => { + mediaWrapper.remove(); + }, 600); + + // Show success toast + showToast('Example image deleted', '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: result.regular_images || [], + customImages: result.custom_images || [] + } + }; + + // Update the item in the virtual scroller + state.virtualScroller.updateSingleItem(result.model_file_path, updateData); + } + } else { + // Show error message + showToast(result.error || 'Failed to delete example image', 'error'); + + // Reset button state + this.disabled = false; + this.dataset.state = 'initial'; + this.classList.remove('confirm'); + this.innerHTML = ''; + this.title = 'Delete this example'; + } + } catch (error) { + console.error('Error deleting example image:', error); + showToast('Failed to delete example image', 'error'); + + // Reset button state + this.disabled = false; + this.dataset.state = 'initial'; + this.classList.remove('confirm'); + this.innerHTML = ''; + this.title = 'Delete this example'; + } + } + }); + }); + + // Find all media controls + const mediaControls = container.querySelectorAll('.media-controls'); + + // Set up same visibility behavior as metadata panel + mediaControls.forEach(controlsEl => { + const mediaWrapper = controlsEl.closest('.media-wrapper'); + const mediaElement = mediaWrapper.querySelector('img, video'); + + // Media controls should be visible when metadata panel is visible + const metadataPanel = mediaWrapper.querySelector('.image-metadata-panel'); + if (metadataPanel) { + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + if (metadataPanel.classList.contains('visible')) { + controlsEl.classList.add('visible'); + } else if (!mediaWrapper.matches(':hover')) { + controlsEl.classList.remove('visible'); + } + } + }); + }); + + observer.observe(metadataPanel, { attributes: true }); + } + }); +} + +/** + * Position media controls within the actual rendered media rectangle + * @param {HTMLElement} mediaWrapper - The wrapper containing the media and controls + */ +export function positionMediaControlsInMediaRect(mediaWrapper) { + const mediaElement = mediaWrapper.querySelector('img, video'); + const controlsElement = mediaWrapper.querySelector('.media-controls'); + + if (!mediaElement || !controlsElement) return; + + // Get wrapper dimensions + const wrapperRect = mediaWrapper.getBoundingClientRect(); + + // Calculate the actual rendered media rectangle + const mediaRect = getRenderedMediaRect( + mediaElement, + wrapperRect.width, + wrapperRect.height + ); + + // Calculate the position for controls - place them inside the actual media area + const padding = 8; // Padding from the edge of the media + + // Position at top-right inside the actual media rectangle + controlsElement.style.top = `${mediaRect.top + padding}px`; + controlsElement.style.right = `${wrapperRect.width - mediaRect.right + padding}px`; + + // Also position any toggle blur buttons in the same way but on the left + const toggleBlurBtn = mediaWrapper.querySelector('.toggle-blur-btn'); + if (toggleBlurBtn) { + toggleBlurBtn.style.top = `${mediaRect.top + padding}px`; + toggleBlurBtn.style.left = `${mediaRect.left + padding}px`; + } +} + +/** + * Position all media controls in a container + * @param {HTMLElement} container - Container with media wrappers + */ +export function positionAllMediaControls(container) { + const mediaWrappers = container.querySelectorAll('.media-wrapper'); + mediaWrappers.forEach(wrapper => { + positionMediaControlsInMediaRect(wrapper); + }); +} \ No newline at end of file diff --git a/static/js/components/shared/showcase/MetadataPanel.js b/static/js/components/shared/showcase/MetadataPanel.js new file mode 100644 index 00000000..f34d9b03 --- /dev/null +++ b/static/js/components/shared/showcase/MetadataPanel.js @@ -0,0 +1,83 @@ +/** + * MetadataPanel.js + * Generates metadata panels for showcase media items + */ + +/** + * Generate metadata panel HTML + * @param {boolean} hasParams - Whether there are generation parameters + * @param {boolean} hasPrompts - Whether there are prompts + * @param {string} prompt - Prompt text + * @param {string} negativePrompt - Negative prompt text + * @param {string} size - Image size + * @param {string} seed - Generation seed + * @param {string} model - Model used + * @param {string} steps - Steps used + * @param {string} sampler - Sampler used + * @param {string} cfgScale - CFG scale + * @param {string} clipSkip - Clip skip value + * @returns {string} HTML content + */ +export 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 = '
'; + return content; +} \ No newline at end of file diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js new file mode 100644 index 00000000..7878532f --- /dev/null +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -0,0 +1,532 @@ +/** + * 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, + positionAllMediaControls +} from './MediaUtils.js'; +import { generateMetadataPanel } from './MetadataPanel.js'; +import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; + +/** + * Render showcase content + * @param {Array} images - Array of images/videos to show + * @param {Array} exampleFiles - Local example files + * @param {Object} options - Options for rendering + * @returns {string} HTML content + */ +export function renderShowcaseContent(images, exampleFiles = []) { + if (!images?.length) { + // Show empty state with import interface + return renderImportInterface(true); + } + + // 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 +
+ + `; +} + +/** + * 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

+
+ + +
+
+ `; +} + +/** + * 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