/**
* ShowcaseView.js
* Shared showcase component for displaying examples in model modals (Lora/Checkpoint)
*
* The showcase starts collapsed as a slim indicator bar ("Show N examples"),
* so opening the modal never triggers remote image fetches. Expanding reveals
* a gallery: a single main viewer with prev/next controls, a horizontal
* thumbnail strip for overview/random access, and an always-visible import
* entry — no scrolling through a vertical stack of full-width examples.
*/
import { showToast } from '../../../utils/uiHelpers.js';
import { state } from '../../../state/index.js';
import { modalManager } from '../../../managers/ModalManager.js';
import { translate } from '../../../utils/i18nHelpers.js';
import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js';
import {
initLazyLoading,
initNsfwBlurHandlers,
initMetadataPanelHandlers,
initMediaControlHandlers,
positionAllMediaControls
} from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl, getThumbnailUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
import { escapeAttribute } from '../utils.js';
/**
* Current gallery state. The model modal is a singleton, so a single module-level
* state object is sufficient; it is replaced on every render.
*
* The gallery starts collapsed: only the indicator bar renders, so remote
* example images are never fetched until the user explicitly expands the
* gallery — same lazy behavior as the legacy collapsed carousel.
*/
const galleryState = {
rawImages: [],
images: [],
exampleFiles: [],
activeIndex: 0,
previewUrl: '',
expanded: false,
};
/**
* Load example images asynchronously
* @param {Array} images - Array of image objects (both regular and custom)
* @param {string} modelHash - Model hash for fetching local files
* @param {string} previewUrl - Model preview URL shown in the collapsed state
*/
export async function loadExampleImages(images, modelHash, previewUrl = '') {
try {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// First fetch local example files
let localFiles = [];
try {
const endpoint = '/api/lm/example-image-files';
const params = `model_hash=${modelHash}`;
const response = await fetch(`${endpoint}?${params}`);
const result = await response.json();
if (result.success) {
localFiles = result.files;
}
} catch (error) {
console.error("Failed to get example files:", error);
}
// Then render with both remote images and local files
showcaseTab.innerHTML = renderShowcaseContent(images, localFiles, previewUrl);
const gallery = showcaseTab.querySelector('.showcase-gallery');
if (gallery) {
initShowcaseContent(gallery);
}
// Initialize the example import functionality
initExampleImport(modelHash, showcaseTab);
} catch (error) {
console.error('Error loading example images:', error);
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
showcaseTab.innerHTML = `
Error loading example images
`;
}
}
}
/**
* Render a small local preview thumbnail for the collapsed indicator bar
* (local file, no remote fetch)
* @param {string} previewUrl - Model preview URL
* @returns {string} HTML content, empty when no preview exists
*/
function renderPreviewThumb(previewUrl) {
if (!previewUrl) return '';
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const media = isVideo
? ``
: ``;
return `${media}`;
}
/**
* Render showcase content: collapsed indicator bar by default, gallery
* (main viewer + thumbnail strip + import entry) when expanded
* @param {Array} images - Array of images/videos to show
* @param {Array} exampleFiles - Local example files
* @param {string} previewUrl - Model preview URL for the collapsed indicator bar
* @param {boolean} expanded - Whether to render the full gallery (loads remote media)
* @returns {string} HTML content
*/
export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '', expanded = false) {
galleryState.rawImages = images || [];
galleryState.exampleFiles = exampleFiles;
galleryState.previewUrl = previewUrl;
galleryState.expanded = expanded;
if (!images?.length) {
galleryState.images = [];
galleryState.activeIndex = 0;
// Empty state: show the import interface directly
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) {
galleryState.images = [];
galleryState.activeIndex = 0;
return `
${translate('modals.model.showcase.allFiltered', {}, 'All example images are filtered due to NSFW content settings')}
${translate('modals.model.showcase.sfwOnlyEnabled', {}, 'Your settings are currently set to show only safe-for-work content')}
${translate('modals.model.showcase.changeInSettings', {}, 'You can change this in Settings')}
` : '';
// Collapsed resting state: a slim indicator bar only — remote examples are
// not rendered (and therefore not fetched) until the user expands.
if (!expanded) {
const showText = translate('modals.model.showcase.showExamples', {}, 'Show examples');
return `
`;
}
/**
* Render the position badge that floats over the main media
* @param {string} positionText - e.g. "3 / 10"
* @returns {string} HTML for the badge
*/
function renderPositionBadge(positionText) {
return `${positionText}`;
}
/**
* Compute the aspect ratio (w/h) for the main viewer, falling back to 4:3
* when dimensions are missing (prevents NaN layout)
* @param {Object} img - Image/video metadata
* @returns {number} width / height
*/
function mediaAspectRatio(img) {
const w = img?.width || 4;
const h = img?.height || 3;
return w / h;
}
/**
* Render a thumbnail for the gallery strip
* @param {Object} img - Image/video metadata
* @param {number} index - Index in the array
* @param {Array} exampleFiles - Local files
* @returns {string} HTML for the thumbnail button
*/
function renderThumbnail(img, index, exampleFiles) {
const localFile = findLocalFile(img, index, exampleFiles);
const originalRemoteUrl = img.url || '';
const isVideo = localFile ? localFile.is_video :
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
const thumbUrl = localFile ? localFile.path : getThumbnailUrl(originalRemoteUrl, mediaType);
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
const activeClass = index === galleryState.activeIndex ? ' active' : '';
const blurClass = shouldBlur ? ' blurred' : '';
const mediaHtml = isVideo ?
`
` :
``;
const nsfwBadge = shouldBlur ? '' : '';
return ``;
}
/**
* Render the active media item in the main viewer
* @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);
// Get original remote URL
const originalRemoteUrl = img.url || '';
// Determine media type for optimization
const isVideo = localFile ? localFile.is_video :
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
// Optimize CivitAI URLs for showcase display (full quality)
const remoteUrl = getShowcaseUrl(originalRemoteUrl, mediaType);
const localUrl = localFile ? localFile.path : '';
// Extract CivitAI image ID from CDN URL for import status check
const cdnImageId = (img.url || '').match(/\/(\d+)\.(?:jpeg|jpg|png|webp|gif)(?:\?|#|$)/)?.[1] || '';
// Check if media should be blurred
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
const shouldBlur = state.settings.blur_mature_content && nsfwLevel >= matureBlurThreshold;
// Determine NSFW warning text based on level
let nsfwText = translate('modals.model.showcase.nsfwMature', {}, 'Mature Content');
if (nsfwLevel >= NSFW_LEVELS.XXX) {
nsfwText = translate('modals.model.showcase.nsfwXxx', {}, 'XXX-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.X) {
nsfwText = translate('modals.model.showcase.nsfwX', {}, 'X-rated Content');
} else if (nsfwLevel >= NSFW_LEVELS.R) {
nsfwText = translate('modals.model.showcase.nsfwR', {}, '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.cfg_scale || meta.cfgScale || '';
const clipSkip = meta.clip_skip || 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(typeof img.id === 'string' && img.id);
const hasGenMeta = img.hasMeta || (img.meta && (img.meta.prompt || img.meta.seed || img.meta.resources));
// Create the media control buttons HTML
const mediaControlsHtml = `
${hasGenMeta ? `
` : ''}
`;
// Generate the appropriate wrapper based on media type
if (isVideo) {
return generateVideoWrapper(
img, shouldBlur, nsfwText, metadataPanel,
localUrl, remoteUrl, mediaControlsHtml
);
}
return generateImageWrapper(
img, 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 (typeof img.id === 'string' && img.id) {
// This is a custom image, find by custom_
const customPrefix = `custom_${img.id}`;
localFile = exampleFiles.find(file => file.name.startsWith(customPrefix));
} else {
// This is a regular image from civitai, find by index
localFile = exampleFiles.find(file => {
const match = file.name.match(/image_(\d+)\./);
return match && parseInt(match[1]) === index;
});
}
return localFile;
}
/**
* Switch the main viewer to another example (wraps around)
* @param {number} index - Target index in galleryState.images
*/
export function updateMainDisplay(index) {
const count = galleryState.images.length;
if (!count || !galleryState.expanded) return;
galleryState.activeIndex = ((index % count) + count) % count;
const container = document.getElementById('mainMediaContainer');
if (!container) return;
const activeImg = galleryState.images[galleryState.activeIndex];
container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg));
// The badge lives inside the container, so rebuild it together with the media
container.innerHTML = renderMediaItem(
activeImg,
galleryState.activeIndex,
galleryState.exampleFiles
) + renderPositionBadge(`${galleryState.activeIndex + 1} / ${count}`);
// Update thumbnail active state and scroll it into view
document.querySelectorAll('.gallery-strip .gallery-thumb').forEach(thumb => {
const isActive = Number(thumb.dataset.index) === galleryState.activeIndex;
thumb.classList.toggle('active', isActive);
if (isActive) {
thumb.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}
});
initMainMediaInteractions(container);
}
/**
* Build the item list for the full-size media viewer from current gallery state
* @returns {Array<{url: string, type: string}>}
*/
function buildViewerItems() {
return galleryState.images.map((img, index) => {
const localFile = findLocalFile(img, index, galleryState.exampleFiles);
const originalRemoteUrl = img.url || '';
const isVideo = localFile ? localFile.is_video :
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
return {
url: localFile?.path || getShowcaseUrl(originalRemoteUrl, isVideo ? 'video' : 'image'),
type: isVideo ? 'video' : 'image'
};
});
}
/**
* Wire up interactions for the media currently shown in the main viewer
* @param {HTMLElement} container - The main media container
*/
function initMainMediaInteractions(container) {
initLazyLoading(container);
initNsfwBlurHandlers(container);
initMetadataPanelHandlers(container);
initMediaControlHandlers(container);
positionAllMediaControls(container);
// Hoist the metadata panel to the gallery-main level so it spans the full
// column width (legacy behavior) instead of being squeezed to the media's
// width. Handler references stay valid — they are bound to the element.
const panel = container.querySelector('.image-metadata-panel');
const galleryMain = container.closest('.gallery-main');
if (panel && galleryMain) {
// Drop the panel of the previously displayed item, if any
galleryMain.querySelectorAll(':scope > .image-metadata-panel').forEach(p => p.remove());
galleryMain.appendChild(panel);
}
// Click-to-view: open full-size media viewer at the active index
const mediaEl = container.querySelector('.media-wrapper img, .media-wrapper video');
if (mediaEl) {
mediaEl.addEventListener('click', (e) => {
e.stopPropagation();
openMediaViewer(buildViewerItems(), galleryState.activeIndex);
});
}
// Reposition controls once media dimensions are known
container.querySelectorAll('img, video').forEach(media => {
media.addEventListener('load', () => positionAllMediaControls(container));
if (media.tagName === 'VIDEO') {
media.addEventListener('loadedmetadata', () => positionAllMediaControls(container));
}
});
}
/**
* 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'
});
}
}
/**
* Toggle the inline import zone; without a configured path, open settings instead
* @param {HTMLElement} gallery - The gallery root element
*/
function toggleImportZone(gallery) {
const exampleImagesPath = state.global.settings.example_images_path;
const isPathConfigured = exampleImagesPath && exampleImagesPath.trim() !== '';
if (!isPathConfigured) {
openSettingsForExampleImages();
return;
}
gallery.querySelector('.gallery-import-zone')?.classList.toggle('hidden');
}
/**
* Remove a deleted custom example from the gallery and re-render
* @param {string} shortId - Custom image short id
*/
function handleExampleDeleted(shortId) {
const isDeleted = (img) => img.id === shortId;
galleryState.rawImages = galleryState.rawImages.filter(img => !isDeleted(img));
galleryState.images = galleryState.images.filter(img => !isDeleted(img));
galleryState.exampleFiles = galleryState.exampleFiles.filter(
file => !file.name.startsWith(`custom_${shortId}`)
);
if (galleryState.activeIndex >= galleryState.images.length) {
galleryState.activeIndex = Math.max(0, galleryState.images.length - 1);
}
rerenderGallery(galleryState.expanded);
}
/**
* Re-render the gallery in place from current state and rebind everything
* @param {boolean} expanded - Whether the re-rendered gallery starts expanded
*/
function rerenderGallery(expanded) {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
showcaseTab.innerHTML = renderShowcaseContent(
galleryState.rawImages,
galleryState.exampleFiles,
galleryState.previewUrl,
expanded
);
const gallery = showcaseTab.querySelector('.showcase-gallery');
if (gallery) {
initShowcaseContent(gallery);
}
const modelHash = document.querySelector('.showcase-section')?.dataset.modelHash;
if (modelHash) {
initExampleImport(modelHash, showcaseTab);
}
}
// Track the gallery whose controls need repositioning on window resize
let resizeBoundGallery = null;
// Scroll-to-expand: expands the collapsed gallery when the user keeps
// scrolling down near the bottom of the modal (legacy muscle memory)
let scrollExpandTarget = null;
function setupScrollToExpand(gallery) {
const modalContent = gallery.closest('.modal-content');
if (!modalContent) return;
if (scrollExpandTarget === modalContent) return; // already bound
scrollExpandTarget = modalContent;
modalContent.addEventListener('wheel', (event) => {
if (galleryState.expanded || !galleryState.images.length) return;
if (event.deltaY <= 0) return;
const nearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100;
if (nearBottom) {
rerenderGallery(true);
}
}, { passive: true });
}
/**
* Initialize all gallery interactions
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
export function initShowcaseContent(gallery) {
if (!gallery) return;
// While expanded the thumbnail strip occupies the modal's bottom-right
// corner; hide the back-to-top button there (Hide examples is the
// equivalent "return to top" affordance)
gallery.closest('.modal-content')?.classList.toggle('showcase-expanded', galleryState.expanded);
// Toolbar: show/hide toggle (expanding renders the gallery and starts remote loads)
gallery.querySelector('#galleryShowBtn')?.addEventListener('click', () => {
rerenderGallery(!galleryState.expanded);
});
// Same expansion via mouse wheel near the bottom of the modal
setupScrollToExpand(gallery);
// Toolbar: import toggle; scroll the freshly opened zone into view
gallery.querySelector('#galleryImportBtn')?.addEventListener('click', () => {
toggleImportZone(gallery);
const zone = gallery.querySelector('.gallery-import-zone:not(.hidden)');
zone?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
// Prev/next navigation (wraps around)
gallery.querySelector('#galleryPrevBtn')?.addEventListener('click', () => {
updateMainDisplay(galleryState.activeIndex - 1);
});
gallery.querySelector('#galleryNextBtn')?.addEventListener('click', () => {
updateMainDisplay(galleryState.activeIndex + 1);
});
// Thumbnail strip: click to select, wheel scrolls horizontally
gallery.querySelectorAll('.gallery-thumb').forEach(thumb => {
thumb.addEventListener('click', () => {
updateMainDisplay(Number(thumb.dataset.index));
});
});
const strip = gallery.querySelector('.gallery-strip');
if (strip) {
strip.addEventListener('wheel', (e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; // let native horizontal scrolling through
e.preventDefault();
strip.scrollLeft += e.deltaY;
}, { passive: false });
}
// Custom example deleted elsewhere (media controls) → refresh gallery
gallery.addEventListener('example-media-deleted', (e) => {
handleExampleDeleted(e.detail?.shortId);
});
// Main viewer interactions (only exists in the expanded state)
const container = gallery.querySelector('.main-media-container');
if (container && galleryState.expanded) {
initMainMediaInteractions(container);
}
// Reposition controls on window resize
resizeBoundGallery = gallery;
}
// Bind the resize handler once; it always repositions the latest gallery
window.addEventListener('resize', () => {
if (resizeBoundGallery && resizeBoundGallery.isConnected) {
positionAllMediaControls(resizeBoundGallery);
}
});
/**
* 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) {
// Check if example images path is configured
const exampleImagesPath = state.global.settings.example_images_path;
const isPathConfigured = exampleImagesPath && exampleImagesPath.trim() !== '';
// If path is not configured, show setup guidance
if (!isPathConfigured) {
const title = translate('uiHelpers.exampleImages.setupRequired', {}, 'Example Images Storage');
const description = translate('uiHelpers.exampleImages.setupDescription', {}, 'To add custom example images, you need to set a download location first.');
const usage = translate('uiHelpers.exampleImages.setupUsage', {}, 'This path is used for both downloaded and custom example images.');
const openSettings = translate('uiHelpers.exampleImages.openSettings', {}, 'Open Settings');
return `
${title}
${description}
${usage}
`;
}
return `
${isEmpty
? translate('modals.model.showcase.noExamples', {}, 'No example images available')
: translate('modals.model.showcase.addMoreExamples', {}, 'Add more examples')}
${translate('modals.model.showcase.dragDrop', {}, 'Drag & drop images or videos here')}