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 ` +
${translate('modals.model.examples.loadError', {}, 'Failed to load media')}
+