Compare commits

...
Author SHA1 Message Date
pixelpaws 15dda1f168 Merge pull request #1138 from willmiao/feat/showcase-vertical-layout
feat(ui): add optional vertical list layout for model modal showcase
2026-10-05 09:39:19 +08:00
Will Miao 9dacaf06f7 feat(ui): add optional vertical list layout for model modal showcase
Bring back the pre-v1.2.2 classic vertical list for example images as a
persisted showcase_layout setting (gallery stays the default), switchable
from both a Settings select and an in-modal segmented toggle.

- Revive the vertical list renderer (adapted from 4a6042d0) as
  VerticalListView.js, reusing the shared MediaUtils/MetadataPanel
  infrastructure; legacy CSS scoped under .showcase-vertical
- Dispatch showcase rendering on state.settings.showcase_layout;
  collapsed bar, empty/filtered states and import flow stay shared
- Keep the back-to-top button available in vertical mode (the
  showcase-expanded class that hides it now only applies to the gallery,
  whose thumbnail strip occupies that corner)
- Persist the setting via DEFAULT_SETTINGS + DEFAULT_SETTINGS_BASE and a
  new select under Settings > Layout Settings
- i18n: 6 new keys translated in all 9 locales; terminology recorded in
  docs/i18n-translation-guidelines.md

Closes #1136
2026-10-05 09:38:48 +08:00
20 changed files with 840 additions and 172 deletions
+29
View File
@@ -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)
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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",
+9 -1
View File
@@ -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": "גרסאות המודל",
+9 -1
View File
@@ -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": "モデルバージョン",
+9 -1
View File
@@ -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": "모델 버전",
+9 -1
View File
@@ -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": "Версии модели",
+9 -1
View File
@@ -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": "模型版本",
+9 -1
View File
@@ -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": "模型版本",
+1
View File
@@ -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(),
@@ -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%;
@@ -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 `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}"${wrapperStyle} data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
@@ -57,27 +65,30 @@ export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel,
* @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 generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
export function generateImageWrapper(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 `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}"${wrapperStyle} data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
</button>
` : ''}
${mediaControlsHtml}
<img data-local-src="${localUrl || ''}"
<img data-local-src="${localUrl || ''}"
data-remote-src="${remoteUrl}"
data-nsfw-level="${nsfwLevel}"
alt="Preview"
alt="Preview"
width="${media.width}"
height="${media.height}"
fetchpriority="high"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${heightPercent === null ? 'fetchpriority="high"' : ''}
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
<div class="nsfw-warning">
@@ -89,4 +100,162 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel,
${metadataPanel}
</div>
`;
}
}
/**
* 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_<id>
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 = `
<div class="media-controls">
<button class="media-control-btn set-preview-btn" title="Set as preview">
<i class="fas fa-image"></i>
</button>
${hasGenMeta ? `
<button class="media-control-btn create-recipe-btn"
title="Create As Recipe"
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
data-image-url="${img.url || ''}"
data-image-nsfw="${img.nsfwLevel ?? ''}"
data-image-id="${cdnImageId}"
data-img-id="${img.id || ''}"
data-local-path="${localFile ? localFile.path : ''}">
<i class="fas fa-book-open"></i>
</button>
` : ''}
<button class="media-control-btn set-nsfw-btn"
title="Set content rating"
data-media-index="${index}"
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
data-media-id="${img.id || ''}">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
data-short-id="${img.id || ''}"
${!isCustomImage ? 'aria-disabled="true"' : ''}>
<i class="fas fa-trash-alt"></i>
<i class="fas fa-check confirm-icon"></i>
</button>
</div>
`;
// 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
);
}
@@ -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 `
<div class="showcase-layout-toggle" role="group">
<button type="button" class="layout-toggle-btn${layout === 'gallery' ? ' active' : ''}"
data-showcase-layout="gallery" title="${galleryLabel}"
aria-pressed="${layout === 'gallery'}">
<i class="fas fa-th-large"></i>
</button>
<button type="button" class="layout-toggle-btn${layout === 'vertical' ? ' active' : ''}"
data-showcase-layout="vertical" title="${listLabel}"
aria-pressed="${layout === 'vertical'}">
<i class="fas fa-list"></i>
</button>
</div>
`;
}
/**
* 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)}
</div>` : '';
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 = ''
<i class="fas fa-chevron-down"></i> ${translate('modals.model.showcase.showCount', { count }, `${showText} (${count})`)}
</button>
${hiddenNotification}
${renderLayoutToggle(layout)}
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
</button>
@@ -210,6 +248,26 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
`;
}
// Vertical list layout: every example stacked full-width
if (layout === 'vertical') {
return `
<div class="showcase-gallery showcase-vertical">
<div class="gallery-toolbar">
${hiddenNotification}
<button class="gallery-show-btn" id="galleryShowBtn">
<i class="fas fa-chevron-up"></i> ${translate('modals.model.showcase.hideExamples', {}, 'Hide examples')}
</button>
${renderLayoutToggle(layout)}
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
</button>
</div>
${renderVerticalList(filteredImages, exampleFiles)}
${importZone}
</div>
`;
}
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 = ''
<button class="gallery-show-btn" id="galleryShowBtn">
<i class="fas fa-chevron-up"></i> ${translate('modals.model.showcase.hideExamples', {}, 'Hide examples')}
</button>
${renderLayoutToggle(layout)}
<button class="gallery-import-btn" id="galleryImportBtn" title="${translate('modals.model.showcase.addExamples', {}, 'Add examples')}">
<i class="fas fa-plus"></i> ${translate('modals.model.showcase.addExamples', {}, 'Add examples')}
</button>
@@ -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 = `
<div class="media-controls">
<button class="media-control-btn set-preview-btn" title="Set as preview">
<i class="fas fa-image"></i>
</button>
${hasGenMeta ? `
<button class="media-control-btn create-recipe-btn"
title="Create As Recipe"
data-image-meta="${encodeURIComponent(JSON.stringify(img.meta || {}))}"
data-image-url="${img.url || ''}"
data-image-nsfw="${img.nsfwLevel ?? ''}"
data-image-id="${cdnImageId}"
data-img-id="${img.id || ''}"
data-local-path="${localFile ? localFile.path : ''}">
<i class="fas fa-book-open"></i>
</button>
` : ''}
<button class="media-control-btn set-nsfw-btn"
title="Set content rating"
data-media-index="${index}"
data-media-source="${isCustomImage ? 'custom' : 'civitai'}"
data-media-id="${img.id || ''}">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button class="media-control-btn example-delete-btn ${!isCustomImage ? 'disabled' : ''}"
title="${isCustomImage ? 'Delete this example' : 'Only custom images can be deleted'}"
data-short-id="${img.id || ''}"
${!isCustomImage ? 'aria-disabled="true"' : ''}>
<i class="fas fa-trash-alt"></i>
<i class="fas fa-check confirm-icon"></i>
</button>
</div>
`;
// 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_<id>
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;
}
@@ -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 `
<div class="vertical-list-container">
${images.map((img, index) => renderVerticalMediaItem(img, index, exampleFiles)).join('')}
</div>
`;
}
/**
* 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));
}
});
}
+6
View File
@@ -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) {
+1
View File
@@ -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',
@@ -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') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
@@ -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 = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
// 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 = `<div class="modal-content"><div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div></div>`;
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 = `<div class="modal-content"><div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div></div>`;
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 = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
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 = `<div class="showcase-gallery showcase-vertical">${renderVerticalList(IMAGES, [])}</div>`;
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 = `<div class="showcase-gallery showcase-vertical">${renderVerticalList(IMAGES, [])}</div>`;
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');
});
});