diff --git a/static/css/components/model-modal/showcase.css b/static/css/components/model-modal/showcase.css index edad7347..289f0fcb 100644 --- a/static/css/components/model-modal/showcase.css +++ b/static/css/components/model-modal/showcase.css @@ -77,7 +77,12 @@ max-height: 70vh; object-fit: contain; border-radius: var(--border-radius-sm); - transition: filter 0.2s ease; + transition: filter 0.2s ease, opacity 0.3s ease; + opacity: 0; +} + +.showcase__media.loaded { + opacity: 1; } .showcase__media.blurred { @@ -384,6 +389,51 @@ font-size: 2rem; } +/* Skeleton loading state */ +.showcase__skeleton { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--lora-surface); +} + +.skeleton-animation { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--space-3); + color: var(--text-color); + opacity: 0.6; +} + +.skeleton-spinner { + font-size: 2.5rem; + color: var(--lora-accent); +} + +/* Error state */ +.showcase__error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--lora-error); + gap: var(--space-2); +} + +.showcase__error i { + font-size: 2rem; +} + +.showcase__error p { + margin: 0; + font-size: 0.9em; +} + /* Empty state */ .showcase__empty { display: flex; diff --git a/static/css/components/model-modal/thumbnail-rail.css b/static/css/components/model-modal/thumbnail-rail.css index 374b335e..e3fe28ae 100644 --- a/static/css/components/model-modal/thumbnail-rail.css +++ b/static/css/components/model-modal/thumbnail-rail.css @@ -36,7 +36,19 @@ position: relative; border: 2px solid transparent; transition: border-color 0.2s ease, transform 0.2s ease; - background: var(--bg-color); + background: var(--lora-surface); +} + +.thumbnail-rail__item img { + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0; + transition: opacity 0.3s ease; +} + +.thumbnail-rail__item img.loaded { + opacity: 1; } .thumbnail-rail__item:hover { @@ -49,12 +61,6 @@ box-shadow: 0 0 0 2px oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3); } -.thumbnail-rail__item img { - width: 100%; - height: 100%; - object-fit: cover; -} - /* NSFW blur for thumbnails - BEM naming to avoid conflicts with global .nsfw-blur */ .thumbnail-rail__item--nsfw-blurred img { filter: blur(8px); diff --git a/static/js/components/model-modal/Showcase.js b/static/js/components/model-modal/Showcase.js index f31f9290..b5148691 100644 --- a/static/js/components/model-modal/Showcase.js +++ b/static/js/components/model-modal/Showcase.js @@ -28,6 +28,7 @@ export class Showcase { this.isUploading = false; this.localFiles = []; this.globalBlurEnabled = true; // Will be initialized based on user settings + this.isLoading = false; // Track loading state } /** @@ -155,6 +156,45 @@ export class Showcase { return 'Mature Content'; } + /** + * Preload media (image or video) + * @param {string} url - Media URL + * @param {boolean} isVideo - Whether media is video + * @returns {Promise} Resolves when media is loaded + */ + preloadMedia(url, isVideo = false) { + return new Promise((resolve, reject) => { + if (isVideo) { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.src = url; + video.addEventListener('loadeddata', () => resolve(url)); + video.addEventListener('error', reject); + } else { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.referrerPolicy = 'no-referrer'; + img.onload = () => resolve(url); + img.onerror = reject; + img.src = url; + } + }); + } + + /** + * Render loading skeleton + * @returns {string} Skeleton HTML + */ + renderLoadingSkeleton() { + return ` +
+
+ +
+
+ `; + } + /** * Get the HTML template */ @@ -239,6 +279,7 @@ export class Showcase { const needsBlur = nsfwLevel > NSFW_LEVELS.PG13; const shouldBlur = this.globalBlurEnabled && needsBlur; const isVideo = this.isVideo(img, localFile); + const blurClass = shouldBlur ? 'blurred' : ''; return `
` : ''} - + ${shouldBlur ? 'NSFW' : ''} `; @@ -539,7 +580,7 @@ export class Showcase { /** * Load and display an image by index */ - loadImage(index) { + async loadImage(index) { if (index < 0 || index >= this.images.length) return; this.currentIndex = index; @@ -556,7 +597,32 @@ export class Showcase { // Update main media container const mediaContainer = this.element.querySelector('.showcase__media-container'); if (mediaContainer) { - mediaContainer.innerHTML = this.renderMediaElement(url, isVideo, shouldBlur, nsfwText, nsfwLevel); + // Show skeleton while loading + mediaContainer.innerHTML = this.renderLoadingSkeleton(); + + try { + // Preload media + await this.preloadMedia(url, isVideo); + + // Render media with fade-in effect + mediaContainer.innerHTML = this.renderMediaElement(url, isVideo, shouldBlur, nsfwText, nsfwLevel); + + // Trigger fade-in animation + const media = mediaContainer.querySelector('.showcase__media'); + if (media) { + requestAnimationFrame(() => { + media.classList.add('loaded'); + }); + } + } catch (error) { + console.error('Failed to load media:', error); + mediaContainer.innerHTML = ` +
+ +

${translate('modals.model.examples.loadError', {}, 'Failed to load media')}

+
+ `; + } } // Update global blur toggle button visibility