diff --git a/docs/i18n-translation-guidelines.md b/docs/i18n-translation-guidelines.md index 922116e7..eca53c6f 100644 --- a/docs/i18n-translation-guidelines.md +++ b/docs/i18n-translation-guidelines.md @@ -133,6 +133,13 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL). > now always on. All 9 locales are translated (terminology in §2, "Civitai ids feature"), > so the "no remaining placeholders" claim holds again. +> **Status (2026-10, showcase layout option):** the model modal's example images became +> switchable between the on-demand gallery and the classic vertical list (issue #1136), +> adding 6 keys (`settings.layoutSettings.showcaseLayout`, `.showcaseLayoutHelp`, +> `.showcaseLayoutOptions.gallery` / `.vertical`, `modals.model.showcase.layoutGallery` / +> `.layoutList`). All 9 locales are translated (terminology in §2, "Showcase layout +> feature"), so the "no remaining placeholders" claim holds again. + --- ## 1. Hard rules (do not violate) @@ -629,6 +636,28 @@ verbatim (R9). The copy/copied strings reuse each locale's existing clipboard pa Hebrew uses its established מזהה ("identifier") noun instead of Latin `ID` in these labels, matching `recipes.*.copyId` (העתק מזהה מתכון). +### Showcase layout feature (gallery / vertical list toggle) + +The model modal's example images can switch between a one-at-a-time **gallery** and the +classic **vertical list**, via a Settings select (`settings.layoutSettings.showcaseLayout*`, +mirroring the `recipesLayout*` select shape) and an in-modal segmented toggle whose two +icon buttons are labelled by `modals.model.showcase.layoutGallery` / `.layoutList` +(kept short — they are icon-button tooltips). + +"Showcase Layout" is rendered as the **example-images layout** in most locales (the +section's user-facing content), reusing each locale's fixed "example images" noun +(`modelCardFooterActionOptions.exampleImages`); ja keeps its established ショーケース +loanword instead. The `layoutList` tooltip reuses the fixed "list view" noun from the +folder-sidebar row (above), so it stays byte-consistent with `sidebar.listView` where +that form fits a tooltip (ru shortens both toggle labels to bare «Галерея» / «Список»). + +| Term | Rendering | +|---|---| +| showcase layout (settings label) | zh-CN 示例图片布局 · zh-TW 範例圖片版面 · ja ショーケースのレイアウト · ko 예시 이미지 레이아웃 · fr Disposition des images d'exemple · de Beispielbilder-Layout · es Diseño de imágenes de ejemplo · ru Макет примеров изображений · he פריסת תמונות דוגמה | +| example images | zh-CN 示例图片 · zh-TW 範例圖片 · ja 例画像 · ko 예시 이미지 · fr images d'exemple · de Beispielbilder · es imágenes de ejemplo · ru примеры изображений · he תמונות דוגמה | +| gallery / vertical list (option labels) | zh-CN 画廊 / 纵向列表 · zh-TW 圖庫 / 垂直清單 · ja ギャラリー / 縦並びリスト · ko 갤러리 / 세로 목록 · fr Galerie / Liste verticale · de Galerie / Vertikale Liste · es Galería / Lista vertical · ru Галерея / Вертикальный список · he גלריה / רשימה אנכית | +| gallery view / list view (toggle tooltips) | zh-CN 画廊视图 / 列表视图 · zh-TW 圖庫檢視 / 清單檢視 · ja ギャラリー表示 / リスト表示 · ko 갤러리 보기 / 목록 보기 · fr Vue galerie / Vue liste · de Galerieansicht / Listenansicht · es Vista de galería / Vista de lista · ru Галерея / Список · he תצוגת גלריה / תצוגת רשימה | + --- ## 3. Cross-cutting confusion hot-spots (must-fix list) diff --git a/locales/de.json b/locales/de.json index 950fffa4..c39795ef 100644 --- a/locales/de.json +++ b/locales/de.json @@ -525,6 +525,12 @@ "hover": "Bei Hover anzeigen" }, "cardInfoDisplayHelp": "Wählen Sie, wann Modellinformationen und Aktionsschaltflächen angezeigt werden sollen", + "showcaseLayout": "Beispielbilder-Layout", + "showcaseLayoutHelp": "Wählen Sie, wie Beispielbilder im Modell-Dialog angezeigt werden: eine Galerie mit jeweils einem Bild oder die klassische vertikale Liste.", + "showcaseLayoutOptions": { + "gallery": "Galerie", + "vertical": "Vertikale Liste" + }, "showVersionOnCard": "Version auf Karte anzeigen", "showVersionOnCardHelp": "Den Versionsnamen auf Modellkarten ein- oder ausblenden", "modelCardFooterAction": "Aktion der Modellkarten-Schaltfläche", @@ -1977,7 +1983,9 @@ "nsfwMature": "Nicht jugendfreie Inhalte", "nsfwR": "Inhalte ab 18 (R)", "nsfwX": "Inhalte mit X-Einstufung", - "nsfwXxx": "Inhalte mit XXX-Einstufung" + "nsfwXxx": "Inhalte mit XXX-Einstufung", + "layoutGallery": "Galerieansicht", + "layoutList": "Listenansicht" }, "versions": { "heading": "Modellversionen", diff --git a/locales/en.json b/locales/en.json index 4450155d..12c7fe64 100644 --- a/locales/en.json +++ b/locales/en.json @@ -525,6 +525,12 @@ "hover": "Reveal on Hover" }, "cardInfoDisplayHelp": "Choose when to display model information and action buttons", + "showcaseLayout": "Showcase Layout", + "showcaseLayoutHelp": "Choose how example images are displayed in the model modal: a one-at-a-time gallery or the classic vertical list.", + "showcaseLayoutOptions": { + "gallery": "Gallery", + "vertical": "Vertical List" + }, "showVersionOnCard": "Show Version on Card", "showVersionOnCardHelp": "Show or hide the version name on model cards", "modelCardFooterAction": "Model Card Button Action", @@ -1977,7 +1983,9 @@ "nsfwMature": "Mature Content", "nsfwR": "R-rated Content", "nsfwX": "X-rated Content", - "nsfwXxx": "XXX-rated Content" + "nsfwXxx": "XXX-rated Content", + "layoutGallery": "Gallery view", + "layoutList": "List view" }, "versions": { "heading": "Model versions", diff --git a/locales/es.json b/locales/es.json index 3436ddee..5b98470a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -525,6 +525,12 @@ "hover": "Mostrar al pasar el ratón" }, "cardInfoDisplayHelp": "Elige cuándo mostrar información del modelo y botones de acción", + "showcaseLayout": "Diseño de imágenes de ejemplo", + "showcaseLayoutHelp": "Elige cómo se muestran las imágenes de ejemplo en el modal del modelo: una galería que las muestra de una en una o la clásica lista vertical.", + "showcaseLayoutOptions": { + "gallery": "Galería", + "vertical": "Lista vertical" + }, "showVersionOnCard": "Mostrar versión en la tarjeta", "showVersionOnCardHelp": "Mostrar u ocultar el nombre de versión en las tarjetas de modelo", "modelCardFooterAction": "Acción del botón de tarjeta de modelo", @@ -1977,7 +1983,9 @@ "nsfwMature": "Contenido para adultos", "nsfwR": "Contenido clasificación R", "nsfwX": "Contenido clasificación X", - "nsfwXxx": "Contenido clasificación XXX" + "nsfwXxx": "Contenido clasificación XXX", + "layoutGallery": "Vista de galería", + "layoutList": "Vista de lista" }, "versions": { "heading": "Versiones del modelo", diff --git a/locales/fr.json b/locales/fr.json index 9dcdb70b..f45a4b30 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -525,6 +525,12 @@ "hover": "Révéler au survol" }, "cardInfoDisplayHelp": "Choisissez quand afficher les informations du modèle et les boutons d'action", + "showcaseLayout": "Disposition des images d'exemple", + "showcaseLayoutHelp": "Choisissez comment les images d'exemple sont affichées dans la fenêtre modale du modèle : une galerie une image à la fois ou la liste verticale classique.", + "showcaseLayoutOptions": { + "gallery": "Galerie", + "vertical": "Liste verticale" + }, "showVersionOnCard": "Afficher la version sur la carte", "showVersionOnCardHelp": "Afficher ou masquer le nom de version sur les cartes de modèle", "modelCardFooterAction": "Action du bouton de carte de modèle", @@ -1977,7 +1983,9 @@ "nsfwMature": "Contenu pour adultes", "nsfwR": "Contenu classé R", "nsfwX": "Contenu classé X", - "nsfwXxx": "Contenu classé XXX" + "nsfwXxx": "Contenu classé XXX", + "layoutGallery": "Vue galerie", + "layoutList": "Vue liste" }, "versions": { "heading": "Versions du modèle", diff --git a/locales/he.json b/locales/he.json index 59e85aae..3bac6305 100644 --- a/locales/he.json +++ b/locales/he.json @@ -525,6 +525,12 @@ "hover": "חשוף בריחוף" }, "cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה", + "showcaseLayout": "פריסת תמונות דוגמה", + "showcaseLayoutHelp": "בחר כיצד יוצגו תמונות הדוגמה בחלון המודל: גלריה המציגה תמונה אחת בכל פעם או הרשימה האנכית הקלאסית.", + "showcaseLayoutOptions": { + "gallery": "גלריה", + "vertical": "רשימה אנכית" + }, "showVersionOnCard": "הצג גרסה בכרטיס", "showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל", "modelCardFooterAction": "פעולת כפתור כרטיס מודל", @@ -1977,7 +1983,9 @@ "nsfwMature": "תוכן למבוגרים", "nsfwR": "תוכן בדירוג R", "nsfwX": "תוכן בדירוג X", - "nsfwXxx": "תוכן בדירוג XXX" + "nsfwXxx": "תוכן בדירוג XXX", + "layoutGallery": "תצוגת גלריה", + "layoutList": "תצוגת רשימה" }, "versions": { "heading": "גרסאות המודל", diff --git a/locales/ja.json b/locales/ja.json index df6d00f0..fcfe18d6 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -525,6 +525,12 @@ "hover": "ホバー時に表示" }, "cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択", + "showcaseLayout": "ショーケースのレイアウト", + "showcaseLayoutHelp": "モデルモーダルでの例画像の表示方法を選択:1枚ずつ表示するギャラリー、または従来の縦並びリスト。", + "showcaseLayoutOptions": { + "gallery": "ギャラリー", + "vertical": "縦並びリスト" + }, "showVersionOnCard": "カードにバージョンを表示", "showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます", "modelCardFooterAction": "モデルカードボタンのアクション", @@ -1977,7 +1983,9 @@ "nsfwMature": "成人向けコンテンツ", "nsfwR": "R指定コンテンツ", "nsfwX": "X指定コンテンツ", - "nsfwXxx": "XXX指定コンテンツ" + "nsfwXxx": "XXX指定コンテンツ", + "layoutGallery": "ギャラリー表示", + "layoutList": "リスト表示" }, "versions": { "heading": "モデルバージョン", diff --git a/locales/ko.json b/locales/ko.json index a9f5db33..d583214c 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -525,6 +525,12 @@ "hover": "호버 시 표시" }, "cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요", + "showcaseLayout": "예시 이미지 레이아웃", + "showcaseLayoutHelp": "모델 모달에서 예시 이미지의 표시 방식을 선택하세요: 한 번에 하나씩 보여주는 갤러리 또는 기존 세로 목록.", + "showcaseLayoutOptions": { + "gallery": "갤러리", + "vertical": "세로 목록" + }, "showVersionOnCard": "카드에 버전 표시", "showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다", "modelCardFooterAction": "모델 카드 버튼 동작", @@ -1977,7 +1983,9 @@ "nsfwMature": "성인 콘텐츠", "nsfwR": "R등급 콘텐츠", "nsfwX": "X등급 콘텐츠", - "nsfwXxx": "XXX등급 콘텐츠" + "nsfwXxx": "XXX등급 콘텐츠", + "layoutGallery": "갤러리 보기", + "layoutList": "목록 보기" }, "versions": { "heading": "모델 버전", diff --git a/locales/ru.json b/locales/ru.json index 992e64e4..61548626 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -525,6 +525,12 @@ "hover": "Показать при наведении" }, "cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий", + "showcaseLayout": "Макет примеров изображений", + "showcaseLayoutHelp": "Выберите, как отображаются примеры изображений в окне модели: галерея с просмотром по одному изображению или классический вертикальный список.", + "showcaseLayoutOptions": { + "gallery": "Галерея", + "vertical": "Вертикальный список" + }, "showVersionOnCard": "Показывать версию на карточке", "showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей", "modelCardFooterAction": "Действие кнопки карточки модели", @@ -1977,7 +1983,9 @@ "nsfwMature": "Контент для взрослых", "nsfwR": "Контент с рейтингом R", "nsfwX": "Контент с рейтингом X", - "nsfwXxx": "Контент с рейтингом XXX" + "nsfwXxx": "Контент с рейтингом XXX", + "layoutGallery": "Галерея", + "layoutList": "Список" }, "versions": { "heading": "Версии модели", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 81532243..26970753 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -525,6 +525,12 @@ "hover": "悬停时显示" }, "cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮", + "showcaseLayout": "示例图片布局", + "showcaseLayoutHelp": "选择示例图片在模型弹窗中的显示方式:一次显示一张的画廊,或经典的纵向列表。", + "showcaseLayoutOptions": { + "gallery": "画廊", + "vertical": "纵向列表" + }, "showVersionOnCard": "在卡片上显示版本", "showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称", "modelCardFooterAction": "模型卡片按钮操作", @@ -1977,7 +1983,9 @@ "nsfwMature": "成熟内容", "nsfwR": "R 级内容", "nsfwX": "X 级内容", - "nsfwXxx": "XXX 级内容" + "nsfwXxx": "XXX 级内容", + "layoutGallery": "画廊视图", + "layoutList": "列表视图" }, "versions": { "heading": "模型版本", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index ca730e0b..d37d2c58 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -525,6 +525,12 @@ "hover": "滑鼠懸停顯示" }, "cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕", + "showcaseLayout": "範例圖片版面", + "showcaseLayoutHelp": "選擇範例圖片在模型彈窗中的顯示方式:一次一張的圖庫,或傳統的垂直清單。", + "showcaseLayoutOptions": { + "gallery": "圖庫", + "vertical": "垂直清單" + }, "showVersionOnCard": "在卡片上顯示版本", "showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱", "modelCardFooterAction": "模型卡片按鈕操作", @@ -1977,7 +1983,9 @@ "nsfwMature": "成熟內容", "nsfwR": "R 級內容", "nsfwX": "X 級內容", - "nsfwXxx": "XXX 級內容" + "nsfwXxx": "XXX 級內容", + "layoutGallery": "圖庫檢視", + "layoutList": "清單檢視" }, "versions": { "heading": "模型版本", diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 594844ab..24216116 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -121,6 +121,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "display_density": "default", "recipes_layout": "grid", "card_info_display": "always", + "showcase_layout": "gallery", "include_trigger_words": False, "compact_mode": False, "priority_tags": DEFAULT_PRIORITY_TAG_CONFIG.copy(), diff --git a/static/css/components/lora-modal/showcase.css b/static/css/components/lora-modal/showcase.css index 869b4205..d093fa10 100644 --- a/static/css/components/lora-modal/showcase.css +++ b/static/css/components/lora-modal/showcase.css @@ -297,6 +297,64 @@ margin-top: 0; } +/* Segmented layout toggle (gallery / vertical list), shown in the collapsed + indicator bar and in each layout's toolbar */ +.showcase-layout-toggle { + display: inline-flex; + flex-shrink: 0; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-xs); + overflow: hidden; +} + +.showcase-layout-toggle .layout-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 10px; + background: var(--card-bg); + border: none; + color: var(--text-color); + font-size: 0.9em; + cursor: pointer; + transition: var(--transition-base); +} + +.showcase-layout-toggle .layout-toggle-btn + .layout-toggle-btn { + border-left: 1px solid var(--border-color); +} + +.showcase-layout-toggle .layout-toggle-btn:hover { + color: var(--lora-accent); +} + +.showcase-layout-toggle .layout-toggle-btn.active { + background: var(--lora-accent); + color: var(--lora-text); +} + +/* Vertical list layout (showcase_layout = vertical): every example stacked + full-width. Each .media-wrapper carries an inline padding-bottom aspect + ratio, so the column is stable before any media loads; the media is + absolutely positioned into that box (revived legacy carousel styles, + scoped under .showcase-vertical to avoid clashing with the gallery) */ +.showcase-vertical .vertical-list-container { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.showcase-vertical .media-wrapper img, +.showcase-vertical .media-wrapper video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: contain; + cursor: zoom-in; +} + .media-wrapper { position: relative; width: 100%; diff --git a/static/js/components/shared/showcase/MediaRenderers.js b/static/js/components/shared/showcase/MediaRenderers.js index 3b1e2c7b..a9099280 100644 --- a/static/js/components/shared/showcase/MediaRenderers.js +++ b/static/js/components/shared/showcase/MediaRenderers.js @@ -2,6 +2,11 @@ * MediaRenderers.js * HTML generators for media items (images/videos) in the showcase */ +import { state } from '../../../state/index.js'; +import { translate } from '../../../utils/i18nHelpers.js'; +import { NSFW_LEVELS, getMatureBlurThreshold } from '../../../utils/constants.js'; +import { generateMetadataPanel } from './MetadataPanel.js'; +import { getDisplayUrl, getShowcaseUrl } from '../../../utils/civitaiUtils.js'; /** * Generate video wrapper HTML. The wrapper fills its container (the gallery's @@ -13,13 +18,16 @@ * @param {string} localUrl - Local file URL * @param {string} remoteUrl - Remote file URL * @param {string} mediaControlsHtml - HTML for media control buttons + * @param {number|null} heightPercent - Vertical-list aspect ratio (padding-bottom + * percent) sizing the wrapper before the media loads; null in gallery mode * @returns {string} HTML content */ -export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') { +export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '', heightPercent = null) { const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0; + const wrapperStyle = heightPercent !== null ? ` style="padding-bottom: ${heightPercent}%"` : ''; return ` -
+
${shouldBlur ? ` ` : ''} ${mediaControlsHtml} - Preview + ${heightPercent === null ? 'fetchpriority="high"' : ''} + class="lazy ${shouldBlur ? 'blurred' : ''}"> ${shouldBlur ? `
@@ -89,4 +100,162 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, ${metadataPanel}
`; -} \ No newline at end of file +} + +/** + * 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 + */ +export 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; +} + +/** + * Render a single showcase media item (image or video) with its metadata + * panel and media controls. Shared by the gallery's main viewer and the + * vertical list layout. + * @param {Object} img - Image/video metadata + * @param {number} index - Index in the array + * @param {Array} exampleFiles - Local files + * @param {Object} options - Rendering options + * @param {number|null} options.heightPercent - Vertical-list aspect ratio + * (padding-bottom percent) for the wrapper; null sizes the wrapper from its + * container (gallery main viewer) + * @param {boolean} options.fullQuality - True to use the full-quality showcase + * URL (vertical list), false for the display-optimized URL (gallery viewer) + * @returns {string} HTML for the media item + */ +export function renderShowcaseMediaItem(img, index, exampleFiles, { heightPercent = null, fullQuality = false } = {}) { + // 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'; + + // Gallery main viewer: CivitAI URLs capped at width=2400 (the full-size + // media viewer uses getShowcaseUrl separately). Vertical list: full + // quality, matching the legacy behavior. + const remoteUrl = fullQuality + ? getShowcaseUrl(originalRemoteUrl, mediaType) + : getDisplayUrl(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, heightPercent + ); + } + + return generateImageWrapper( + img, shouldBlur, nsfwText, metadataPanel, + localUrl, remoteUrl, mediaControlsHtml, heightPercent + ); +} diff --git a/static/js/components/shared/showcase/ShowcaseView.js b/static/js/components/shared/showcase/ShowcaseView.js index d5476513..d1b1e13d 100644 --- a/static/js/components/shared/showcase/ShowcaseView.js +++ b/static/js/components/shared/showcase/ShowcaseView.js @@ -4,9 +4,11 @@ * * 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. + * the layout chosen by the `showcase_layout` setting: either a gallery (a + * single main viewer with prev/next controls, a horizontal thumbnail strip + * for overview/random access, and an always-visible import entry) or the + * classic vertical list (every example stacked full-width, see + * VerticalListView.js). A segmented toggle in the toolbar switches layouts. */ import { showToast } from '../../../utils/uiHelpers.js'; import { state } from '../../../state/index.js'; @@ -20,8 +22,8 @@ import { initMediaControlHandlers, positionAllMediaControls } from './MediaUtils.js'; -import { generateMetadataPanel } from './MetadataPanel.js'; -import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js'; +import { renderShowcaseMediaItem, findLocalFile } from './MediaRenderers.js'; +import { renderVerticalList, initVerticalList } from './VerticalListView.js'; import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js'; import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js'; import { escapeAttribute } from '../utils.js'; @@ -118,12 +120,45 @@ function renderPreviewThumb(previewUrl) { } /** - * Render showcase content: collapsed indicator bar by default, gallery - * (main viewer + thumbnail strip + import entry) when expanded + * Active showcase layout ('gallery' | 'vertical') from the persisted setting; + * unknown/missing values fall back to the gallery + * @returns {string} + */ +function getShowcaseLayout() { + return state.settings.showcase_layout === 'vertical' ? 'vertical' : 'gallery'; +} + +/** + * Render the segmented gallery/list layout toggle + * @param {string} layout - Active layout ('gallery' | 'vertical') + * @returns {string} HTML for the toggle + */ +function renderLayoutToggle(layout) { + const galleryLabel = translate('modals.model.showcase.layoutGallery', {}, 'Gallery view'); + const listLabel = translate('modals.model.showcase.layoutList', {}, 'List view'); + return ` +
+ + +
+ `; +} + +/** + * Render showcase content: collapsed indicator bar by default, the active + * layout (gallery or vertical list) 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) + * @param {boolean} expanded - Whether to render the full showcase (loads remote media) * @returns {string} HTML content */ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '', expanded = false) { @@ -189,6 +224,8 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '' ${renderImportInterface(false)}
` : ''; + const layout = getShowcaseLayout(); + // Collapsed resting state: a slim indicator bar only — remote examples are // not rendered (and therefore not fetched) until the user expands. if (!expanded) { @@ -201,6 +238,7 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '' ${translate('modals.model.showcase.showCount', { count }, `${showText} (${count})`)} ${hiddenNotification} + ${renderLayoutToggle(layout)} @@ -210,6 +248,26 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '' `; } + // Vertical list layout: every example stacked full-width + if (layout === 'vertical') { + return ` + + `; + } + const showNav = count > 1; const positionText = `${galleryState.activeIndex + 1} / ${count}`; const activeImg = filteredImages[galleryState.activeIndex]; @@ -222,6 +280,7 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = '' + ${renderLayoutToggle(layout)} @@ -300,150 +359,15 @@ function renderThumbnail(img, index, exampleFiles) { } /** - * Render the active media item in the main viewer + * Render the active media item in the main viewer (display-optimized URL, + * wrapper sized by its container — see MediaRenderers.js) * @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 in-modal display (images capped at width=2400; - // the full-size media viewer uses getShowcaseUrl separately) - const remoteUrl = getDisplayUrl(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; + return renderShowcaseMediaItem(img, index, exampleFiles); } // URLs already warmed in the HTTP cache, so repeat navigations and re-renders @@ -845,10 +769,12 @@ function isTypingTarget(target) { } // '[' / ']' switch examples while the gallery is expanded. ArrowLeft/Right -// stay reserved for model-level navigation (ModelModal), and the full-size -// media viewer owns its keys while open. +// stay reserved for model-level navigation (ModelModal), the full-size +// media viewer owns its keys while open, and the vertical list has no +// active-item concept to navigate. document.addEventListener('keydown', (event) => { if (event.key !== '[' && event.key !== ']') return; + if (getShowcaseLayout() !== 'gallery') return; if (!galleryState.expanded || galleryState.images.length < 2) return; if (isTypingTarget(event.target)) return; if (isMediaViewerOpen()) return; @@ -892,6 +818,32 @@ function initStripVideoLazyLoading(gallery) { videos.forEach(video => observer.observe(video)); } +/** + * Switch the showcase layout (gallery | vertical list), persist the setting + * immediately and re-render in place + * @param {string} layout - Target layout + */ +async function setShowcaseLayout(layout) { + if (layout !== 'gallery' && layout !== 'vertical') return; + if (layout === getShowcaseLayout()) return; + + // Components reach the settings manager through the global set by + // core.js (importing it here would create a module cycle) + const sm = window.settingsManager; + if (sm) { + try { + await sm.saveSetting('showcase_layout', layout); + } catch (error) { + console.error('Failed to save showcase layout setting:', error); + return; // Keep the current layout when the save fails + } + } else { + state.settings.showcase_layout = layout; + } + + rerenderGallery(galleryState.expanded); +} + /** * Initialize all gallery interactions * @param {HTMLElement} gallery - The .showcase-gallery element @@ -899,16 +851,25 @@ function initStripVideoLazyLoading(gallery) { 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); + // Gallery layout only: 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). The vertical + // list has no strip and is exactly where the button is needed most. + const hideBackToTop = galleryState.expanded && getShowcaseLayout() === 'gallery'; + gallery.closest('.modal-content')?.classList.toggle('showcase-expanded', hideBackToTop); // Toolbar: show/hide toggle (expanding renders the gallery and starts remote loads) gallery.querySelector('#galleryShowBtn')?.addEventListener('click', () => { rerenderGallery(!galleryState.expanded); }); + // Segmented layout toggle (gallery / vertical list) + gallery.querySelectorAll('[data-showcase-layout]').forEach(btn => { + btn.addEventListener('click', () => { + setShowcaseLayout(btn.dataset.showcaseLayout); + }); + }); + // Same expansion via mouse wheel near the bottom of the modal setupScrollToExpand(gallery); @@ -947,7 +908,7 @@ export function initShowcaseContent(gallery) { handleExampleDeleted(e.detail?.shortId); }); - // Main viewer interactions (only exists in the expanded state) + // Main viewer interactions (only exists in the expanded gallery layout) const container = gallery.querySelector('.main-media-container'); if (container && galleryState.expanded) { initMainMediaInteractions(container); @@ -960,6 +921,11 @@ export function initShowcaseContent(gallery) { initStripVideoLazyLoading(gallery); } + // Vertical list interactions (only exists in the expanded vertical layout) + if (gallery.querySelector('.vertical-list-container') && galleryState.expanded) { + initVerticalList(gallery, galleryState.images, galleryState.exampleFiles); + } + // Reposition controls on window resize resizeBoundGallery = gallery; } diff --git a/static/js/components/shared/showcase/VerticalListView.js b/static/js/components/shared/showcase/VerticalListView.js new file mode 100644 index 00000000..f45eda5e --- /dev/null +++ b/static/js/components/shared/showcase/VerticalListView.js @@ -0,0 +1,120 @@ +/** + * VerticalListView.js + * Classic vertical example list for the model modal showcase: every example + * rendered full-width and stacked, each with its own metadata panel and media + * controls. Revived from the pre-gallery implementation (removed in v1.2.2), + * adapted to the current shared showcase infrastructure (MediaRenderers, + * MediaUtils) — no legacy scroll/metrics machinery. + */ +import { + initLazyLoading, + initNsfwBlurHandlers, + initMetadataPanelHandlers, + initMediaControlHandlers, + positionAllMediaControls +} from './MediaUtils.js'; +import { renderShowcaseMediaItem, findLocalFile } from './MediaRenderers.js'; +import { getShowcaseUrl } from '../../../utils/civitaiUtils.js'; +import { openMediaViewer } from '../MediaViewer.js'; + +/** + * Render the vertical list: all examples stacked in a column + * @param {Array} images - Filtered images/videos to show + * @param {Array} exampleFiles - Local example files + * @returns {string} HTML content + */ +export function renderVerticalList(images, exampleFiles = []) { + return ` +
+ ${images.map((img, index) => renderVerticalMediaItem(img, index, exampleFiles)).join('')} +
+ `; +} + +/** + * Render a single vertical-list item. The wrapper is sized by the + * padding-bottom aspect-ratio hack so the column layout is stable before any + * media loads (and lazy loading has a box to intersect). + * @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 renderVerticalMediaItem(img, index, exampleFiles) { + return renderShowcaseMediaItem(img, index, exampleFiles, { + heightPercent: computeHeightPercent(img), + fullQuality: true, + }); +} + +/** + * Compute the wrapper height as a padding-bottom percent of the modal width, + * clamped so very tall or very wide media still gets a reasonable box + * (defensive 4:3 fallback for missing dimensions prevents NaN layout) + * @param {Object} img - Image/video metadata + * @returns {number} padding-bottom percent + */ +function computeHeightPercent(img) { + const safeW = img.width || 4; + const safeH = img.height || 3; + const aspectRatio = (safeH / safeW) * 100; + const containerWidth = 800; // modal content maximum width + const minHeightPercent = 40; + const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100; + return Math.max(minHeightPercent, Math.min(maxHeightPercent, aspectRatio)); +} + +/** + * Build the item list for the full-size media viewer from the rendered items + * @param {Array} images - Rendered images/videos + * @param {Array} exampleFiles - Local example files + * @returns {Array<{url: string, type: string}>} + */ +function buildViewerItems(images, exampleFiles) { + return images.map((img, index) => { + const localFile = findLocalFile(img, index, 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' + }; + }); +} + +/** + * Initialize all vertical-list interactions: lazy loading, NSFW blur, + * per-item metadata panels and media controls, click-to-view + * @param {HTMLElement} container - The .showcase-vertical root element + * @param {Array} images - Rendered images/videos (aligned with DOM order) + * @param {Array} exampleFiles - Local example files + */ +export function initVerticalList(container, images, exampleFiles = []) { + if (!container) return; + + initLazyLoading(container); + initNsfwBlurHandlers(container); + initMetadataPanelHandlers(container); + initMediaControlHandlers(container); + positionAllMediaControls(container); + + // Click-to-view: open the full-size media viewer at the clicked item + const viewerItems = buildViewerItems(images, exampleFiles); + container.querySelectorAll('.vertical-list-container .media-wrapper').forEach((wrapper, index) => { + const mediaEl = wrapper.querySelector('img, video'); + if (!mediaEl) return; + mediaEl.addEventListener('click', (e) => { + e.stopPropagation(); + openMediaViewer(viewerItems, index); + }); + }); + + // Reposition controls once media dimensions are known + container.querySelectorAll('.vertical-list-container img, .vertical-list-container video').forEach(media => { + media.addEventListener('load', () => positionAllMediaControls(container)); + if (media.tagName === 'VIDEO') { + media.addEventListener('loadedmetadata', () => positionAllMediaControls(container)); + } + }); +} diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index 2f9486f0..a34c801e 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1116,6 +1116,12 @@ export class SettingsManager { cardInfoDisplaySelect.value = state.global.settings.card_info_display || 'always'; } + // Set showcase layout setting + const showcaseLayoutSelect = document.getElementById('showcaseLayout'); + if (showcaseLayoutSelect) { + showcaseLayoutSelect.value = state.global.settings.showcase_layout || 'gallery'; + } + // Set model card footer action const modelCardFooterActionSelect = document.getElementById('modelCardFooterAction'); if (modelCardFooterActionSelect) { diff --git a/static/js/state/index.js b/static/js/state/index.js index d83ef896..ff80aa07 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -47,6 +47,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ display_density: 'default', recipes_layout: 'grid', card_info_display: 'always', + showcase_layout: 'gallery', model_name_display: 'model_name', lora_syntax_format: 'legacy', model_card_footer_action: 'example_images', diff --git a/templates/components/modals/settings/interface.html b/templates/components/modals/settings/interface.html index 56f7c042..285c5c31 100644 --- a/templates/components/modals/settings/interface.html +++ b/templates/components/modals/settings/interface.html @@ -87,6 +87,11 @@ ('hover', 'settings.layoutSettings.cardInfoDisplayOptions.hover'), ], 'settings.layoutSettings.cardInfoDisplayHelp') }} + {{ sm.setting_select('showcaseLayout', 'showcase_layout', 'settings.layoutSettings.showcaseLayout', [ + ('gallery', 'settings.layoutSettings.showcaseLayoutOptions.gallery'), + ('vertical', 'settings.layoutSettings.showcaseLayoutOptions.vertical'), + ], 'settings.layoutSettings.showcaseLayoutHelp') }} +
diff --git a/tests/frontend/components/showcase.verticalList.test.js b/tests/frontend/components/showcase.verticalList.test.js new file mode 100644 index 00000000..a98e5eb9 --- /dev/null +++ b/tests/frontend/components/showcase.verticalList.test.js @@ -0,0 +1,233 @@ +import { describe, it, beforeEach, afterEach, expect } from 'vitest'; + +const { SHOWCASE_MODULE, VERTICAL_MODULE, MEDIA_UTILS_MODULE, MEDIA_VIEWER_MODULE } = vi.hoisted(() => ({ + SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname, + VERTICAL_MODULE: new URL('../../../static/js/components/shared/showcase/VerticalListView.js', import.meta.url).pathname, + MEDIA_UTILS_MODULE: new URL('../../../static/js/components/shared/showcase/MediaUtils.js', import.meta.url).pathname, + MEDIA_VIEWER_MODULE: new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname, +})); + +vi.mock(MEDIA_UTILS_MODULE, () => ({ + initLazyLoading: vi.fn(), + initNsfwBlurHandlers: vi.fn(), + initMetadataPanelHandlers: vi.fn(), + initMediaControlHandlers: vi.fn(), + positionAllMediaControls: vi.fn(), +})); + +vi.mock(MEDIA_VIEWER_MODULE, () => ({ + openMediaViewer: vi.fn(), + isMediaViewerOpen: vi.fn(() => false), +})); + +const PREVIEW_URL = '/loras_static/preview/abc.png'; + +const IMAGES = [ + { url: 'https://image.civitai.com/vl/111.jpeg', width: 512, height: 768, nsfwLevel: 0 }, + { url: 'https://image.civitai.com/vl/222.jpeg', width: 768, height: 512, nsfwLevel: 0 }, + { url: 'https://image.civitai.com/vl/333.mp4', width: 512, height: 512, nsfwLevel: 0 }, +]; + +describe('Showcase vertical list layout', () => { + let state; + + beforeEach(async () => { + Element.prototype.scrollIntoView = vi.fn(); + const stateModule = await import('../../../static/js/state/index.js'); + state = stateModule.state; + state.settings.show_only_sfw = false; + state.settings.blur_mature_content = false; + state.settings.showcase_layout = 'vertical'; + state.global.settings.example_images_path = '/tmp/examples'; + document.body.innerHTML = ''; + }); + + afterEach(() => { + document.body.innerHTML = ''; + delete window.settingsManager; + state.settings.showcase_layout = 'gallery'; + }); + + it('defaults the showcase_layout setting to gallery', async () => { + const { createDefaultSettings } = await import('../../../static/js/state/index.js'); + // DEFAULT_SETTINGS_BASE feeds both the frontend state and the backend key set + expect(createDefaultSettings().showcase_layout).toBe('gallery'); + }); + + it('renders every example stacked full-width with its own panel and controls', async () => { + const { renderShowcaseContent } = await import(SHOWCASE_MODULE); + + const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true); + const host = document.createElement('div'); + host.innerHTML = html; + + expect(host.querySelector('.showcase-gallery.showcase-vertical')).toBeTruthy(); + const list = host.querySelector('.vertical-list-container'); + expect(list).toBeTruthy(); + const wrappers = list.querySelectorAll('.media-wrapper'); + expect(wrappers).toHaveLength(3); + // Every item keeps its aspect ratio via the padding-bottom hack and gets + // its own metadata panel + media controls + wrappers.forEach(wrapper => { + expect(wrapper.getAttribute('style')).toContain('padding-bottom'); + expect(wrapper.querySelector('.image-metadata-panel')).toBeTruthy(); + expect(wrapper.querySelector('.media-controls')).toBeTruthy(); + }); + // Gallery-only chrome is absent + expect(host.querySelector('.gallery-main')).toBeNull(); + expect(host.querySelector('.gallery-strip')).toBeNull(); + expect(host.querySelector('#galleryPrevBtn')).toBeNull(); + }); + + it('renders media lazy: data-* sources only, no real src until observed', async () => { + const { renderShowcaseContent } = await import(SHOWCASE_MODULE); + + const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true); + const host = document.createElement('div'); + host.innerHTML = html; + + const media = host.querySelectorAll('.vertical-list-container .media-wrapper img, .vertical-list-container .media-wrapper video'); + expect(media).toHaveLength(3); + media.forEach(el => { + expect(el.classList.contains('lazy')).toBe(true); + expect(el.dataset.remoteSrc).toBeTruthy(); + expect(el.hasAttribute('src')).toBe(false); + }); + // The third example is a video + expect(host.querySelectorAll('.vertical-list-container video')).toHaveLength(1); + }); + + it('filters NSFW examples and reports the hidden count', async () => { + const { renderShowcaseContent } = await import(SHOWCASE_MODULE); + state.settings.show_only_sfw = true; + + const images = [ + IMAGES[0], + { url: 'https://image.civitai.com/vl/444.jpeg', width: 10, height: 10, nsfwLevel: 32 }, + ]; + const html = renderShowcaseContent(images, [], '', true); + const host = document.createElement('div'); + host.innerHTML = html; + + expect(host.querySelectorAll('.vertical-list-container .media-wrapper')).toHaveLength(1); + expect(host.querySelector('.nsfw-filter-notification')).toBeTruthy(); + }); + + it('toggles between the collapsed indicator bar and the expanded list', async () => { + const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE); + + document.body.innerHTML = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}
`; + + // Collapsed: indicator bar only, no media rendered even in vertical mode + expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy(); + expect(document.querySelector('.vertical-list-container')).toBeNull(); + + initShowcaseContent(document.querySelector('.showcase-gallery')); + + // Expand → vertical list + document.querySelector('#galleryShowBtn').click(); + expect(document.querySelector('.showcase-vertical')).toBeTruthy(); + expect(document.querySelectorAll('.vertical-list-container .media-wrapper')).toHaveLength(3); + + // Collapse back to the indicator bar + document.querySelector('#galleryShowBtn').click(); + expect(document.querySelector('.vertical-list-container')).toBeNull(); + expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy(); + }); + + it('keeps the back-to-top button available in vertical mode (gallery hides it)', async () => { + const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE); + + // Expanded vertical list: no thumbnail strip in the corner, so the modal + // must NOT get the showcase-expanded class that hides back-to-top + document.body.innerHTML = ``; + initShowcaseContent(document.querySelector('.showcase-gallery')); + expect(document.querySelector('.modal-content').classList.contains('showcase-expanded')).toBe(false); + + // Expanded gallery: the strip occupies the corner, button hidden as before + state.settings.showcase_layout = 'gallery'; + document.body.innerHTML = ``; + initShowcaseContent(document.querySelector('.showcase-gallery')); + expect(document.querySelector('.modal-content').classList.contains('showcase-expanded')).toBe(true); + }); + + it('switches between gallery and vertical renderers via the segmented toggle', async () => { + const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE); + + // Stub the settings manager global (normally set by core.js); it persists + // into state like the real saveSetting does + const saveSetting = vi.fn(async (key, value) => { state.global.settings[key] = value; }); + window.settingsManager = { saveSetting }; + + state.settings.showcase_layout = 'gallery'; + document.body.innerHTML = `
${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}
`; + initShowcaseContent(document.querySelector('.showcase-gallery')); + + // Gallery renders with the gallery segment active + expect(document.querySelector('.gallery-strip')).toBeTruthy(); + expect(document.querySelector('[data-showcase-layout="gallery"]').classList.contains('active')).toBe(true); + + // Switch to the vertical list + document.querySelector('[data-showcase-layout="vertical"]').click(); + await vi.waitFor(() => { + expect(document.querySelector('.showcase-vertical')).toBeTruthy(); + }); + expect(saveSetting).toHaveBeenCalledWith('showcase_layout', 'vertical'); + expect(state.settings.showcase_layout).toBe('vertical'); + expect(document.querySelectorAll('.vertical-list-container .media-wrapper')).toHaveLength(3); + expect(document.querySelector('[data-showcase-layout="vertical"]').classList.contains('active')).toBe(true); + + // initShowcaseContent was re-run by the re-render; bind again and switch back + document.querySelector('[data-showcase-layout="gallery"]').click(); + await vi.waitFor(() => { + expect(document.querySelector('.gallery-strip')).toBeTruthy(); + }); + expect(saveSetting).toHaveBeenLastCalledWith('showcase_layout', 'gallery'); + expect(document.querySelector('.vertical-list-container')).toBeNull(); + }); + + it('keeps the collapsed indicator bar shared across layouts (toggle included)', async () => { + const { renderShowcaseContent } = await import(SHOWCASE_MODULE); + + const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL); + const host = document.createElement('div'); + host.innerHTML = html; + + const bar = host.querySelector('.gallery-indicator-bar'); + expect(bar).toBeTruthy(); + expect(bar.querySelector('[data-showcase-layout="vertical"]')).toBeTruthy(); + expect(bar.querySelector('[data-showcase-layout="gallery"]')).toBeTruthy(); + // Vertical layout active in the collapsed bar too + expect(bar.querySelector('[data-showcase-layout="vertical"]').classList.contains('active')).toBe(true); + }); + + it('initializes shared media handlers for every stacked item', async () => { + const { initLazyLoading, initNsfwBlurHandlers, initMetadataPanelHandlers, initMediaControlHandlers } = await import(MEDIA_UTILS_MODULE); + const { renderVerticalList, initVerticalList } = await import(VERTICAL_MODULE); + + document.body.innerHTML = ``; + initVerticalList(document.querySelector('.showcase-vertical'), IMAGES, []); + + expect(initLazyLoading).toHaveBeenCalled(); + expect(initNsfwBlurHandlers).toHaveBeenCalled(); + expect(initMetadataPanelHandlers).toHaveBeenCalled(); + expect(initMediaControlHandlers).toHaveBeenCalled(); + }); + + it('opens the full-size media viewer at the clicked item', async () => { + const { openMediaViewer } = await import(MEDIA_VIEWER_MODULE); + const { renderVerticalList, initVerticalList } = await import(VERTICAL_MODULE); + + document.body.innerHTML = ``; + initVerticalList(document.querySelector('.showcase-vertical'), IMAGES, []); + + const wrappers = document.querySelectorAll('.vertical-list-container .media-wrapper'); + wrappers[1].querySelector('img').click(); + + expect(openMediaViewer).toHaveBeenCalledTimes(1); + const [items, index] = openMediaViewer.mock.calls[0]; + expect(index).toBe(1); + expect(items).toHaveLength(3); + expect(items[2].type).toBe('video'); + }); +});