${shouldBlur ? `
@@ -232,10 +203,10 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
` : ''}
${shouldBlur ? `
@@ -254,7 +225,7 @@ function generateVideoWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
/**
* 生成图片包装HTML
*/
-function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl) {
+function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadataPanel, urls) {
return `
${shouldBlur ? `
@@ -262,8 +233,9 @@ function generateImageWrapper(img, heightPercent, shouldBlur, nsfwText, metadata
` : ''}
-
![Preview]()
{
- carouselContainer.style.height = '';
- }, 300);
- }
- }
-}
+// Use the shared setupShowcaseScroll function with the correct modal ID
+export { setupShowcaseScroll, scrollToTop, toggleShowcase };
-/**
- * 初始化元数据面板交互处理
- */
-function initMetadataPanelHandlers(container) {
- // Find all media wrappers
- 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
- if (isOverMedia || isOverMetadataPanel) {
- metadataPanel.classList.add('visible');
- } else {
- metadataPanel.classList.remove('visible');
- }
- });
-
- wrapper.addEventListener('mouseleave', () => {
- // Only hide panel when mouse leaves the wrapper and not over the metadata panel
- 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 the metadata panel from bubbling
- metadataPanel.addEventListener('click', (e) => {
- e.stopPropagation();
- });
-
- // Handle copy prompt button clicks
- 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(); // Prevent bubbling
-
- if (!promptElement) return;
-
- try {
- await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
- } catch (err) {
- console.error('Copy failed:', err);
- showToast('Copy failed', 'error');
- }
- });
- });
-
- // Prevent scrolling in the metadata panel from scrolling the whole modal
- 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 });
- });
-}
-
-/**
- * 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
- */
-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
- };
-}
-
-/**
- * 初始化模糊切换处理
- */
-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';
- }
- });
- });
-}
-
-/**
- * 初始化延迟加载
- */
-function initLazyLoading(container) {
- const lazyElements = container.querySelectorAll('.lazy');
-
- const lazyLoad = (element) => {
- const localSrc = element.dataset.localSrc;
- const remoteSrc = element.dataset.remoteSrc;
-
- // Check if element is an image or video
- if (element.tagName.toLowerCase() === 'video') {
- // Try local first, then remote
- tryLocalOrFallbackToRemote(element, localSrc, remoteSrc);
- } else {
- // For images, we'll use an Image object to test if local file exists
- tryLocalImageOrFallbackToRemote(element, localSrc, remoteSrc);
- }
-
- element.classList.remove('lazy');
- };
-
- // Try to load local image first, fall back to remote if local fails
- const tryLocalImageOrFallbackToRemote = (imgElement, localSrc, remoteSrc) => {
- // Only try local if we have a local path
- if (localSrc) {
- const testImg = new Image();
- testImg.onload = () => {
- // Local image loaded successfully
- imgElement.src = localSrc;
- };
- testImg.onerror = () => {
- // Local image failed, use remote
- imgElement.src = remoteSrc;
- };
- // Start loading test image
- testImg.src = localSrc;
- } else {
- // No local path, use remote directly
- imgElement.src = remoteSrc;
- }
- };
-
- // Try to load local video first, fall back to remote if local fails
- const tryLocalOrFallbackToRemote = (videoElement, localSrc, remoteSrc) => {
- // Only try local if we have a local path
- if (localSrc) {
- // Try to fetch local file headers to see if it exists
- fetch(localSrc, { method: 'HEAD' })
- .then(response => {
- if (response.ok) {
- // Local video exists, use it
- videoElement.src = localSrc;
- videoElement.querySelector('source').src = localSrc;
- } else {
- // Local video doesn't exist, use remote
- videoElement.src = remoteSrc;
- videoElement.querySelector('source').src = remoteSrc;
- }
- videoElement.load();
- })
- .catch(() => {
- // Error fetching, use remote
- videoElement.src = remoteSrc;
- videoElement.querySelector('source').src = remoteSrc;
- videoElement.load();
- });
- } else {
- // No local path, use remote directly
- videoElement.src = remoteSrc;
- videoElement.querySelector('source').src = remoteSrc;
- videoElement.load();
- }
- };
-
- const observer = new IntersectionObserver((entries) => {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- lazyLoad(entry.target);
- observer.unobserve(entry.target);
- }
- });
- });
-
- lazyElements.forEach(element => observer.observe(element));
-}
-
-/**
- * 设置展示区域的滚动处理
- */
-export function setupShowcaseScroll() {
- // Add event listener to document for wheel events
- document.addEventListener('wheel', (event) => {
- // Find the active modal content
- const modalContent = document.querySelector('#loraModal .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 instead of deprecated DOMNodeInserted
- const observer = new MutationObserver((mutations) => {
- for (const mutation of mutations) {
- if (mutation.type === 'childList' && mutation.addedNodes.length) {
- // Check if loraModal content was added
- const loraModal = document.getElementById('loraModal');
- if (loraModal && loraModal.querySelector('.modal-content')) {
- setupBackToTopButton(loraModal.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('#loraModal .modal-content');
- if (modalContent) {
- setupBackToTopButton(modalContent);
- }
-}
-
-/**
- * 设置返回顶部按钮
- */
-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'));
-}
-
-/**
- * 滚动到顶部
- */
-export function scrollToTop(button) {
- const modalContent = button.closest('.modal-content');
- if (modalContent) {
- modalContent.scrollTo({
- top: 0,
- behavior: 'smooth'
- });
- }
-}
+// Initialize the showcase scroll when this module is imported
+document.addEventListener('DOMContentLoaded', () => {
+ setupShowcaseScroll('loraModal');
+});
diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js
index b75a0e1d..6533ff0b 100644
--- a/static/js/utils/uiHelpers.js
+++ b/static/js/utils/uiHelpers.js
@@ -1,6 +1,7 @@
import { state } from '../state/index.js';
import { resetAndReload } from '../api/loraApi.js';
import { getStorageItem, setStorageItem } from './storageHelpers.js';
+import { NSFW_LEVELS } from './constants.js';
/**
* Utility function to copy text to clipboard with fallback for older browsers
@@ -440,4 +441,499 @@ 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'
+ });
+ }
}
\ No newline at end of file