mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-10-05 09:35:31 -03:00
Merge remote-tracking branch 'origin/main' into feat/paid-model-price-tracking
# Conflicts: # docs/i18n-translation-guidelines.md
This commit is contained in:
@@ -133,6 +133,12 @@ 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.
|
||||
> **Status (2026-10, Buzz download prices):** the paid/early-access obtainability feature added
|
||||
> 13 keys — the two `globalContextMenu.checkModelUpdates.gateEvents.*` counts, five
|
||||
> `modals.model.versions.badges.*` (sale, Blue Buzz, "free now" and its tooltip, and the early
|
||||
@@ -641,6 +647,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
@@ -530,6 +530,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",
|
||||
@@ -1989,7 +1995,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
@@ -530,6 +530,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",
|
||||
@@ -1989,7 +1995,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
@@ -530,6 +530,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",
|
||||
@@ -1989,7 +1995,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
@@ -530,6 +530,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",
|
||||
@@ -1989,7 +1995,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
@@ -530,6 +530,12 @@
|
||||
"hover": "חשוף בריחוף"
|
||||
},
|
||||
"cardInfoDisplayHelp": "בחר מתי להציג מידע על המודל וכפתורי פעולה",
|
||||
"showcaseLayout": "פריסת תמונות דוגמה",
|
||||
"showcaseLayoutHelp": "בחר כיצד יוצגו תמונות הדוגמה בחלון המודל: גלריה המציגה תמונה אחת בכל פעם או הרשימה האנכית הקלאסית.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "גלריה",
|
||||
"vertical": "רשימה אנכית"
|
||||
},
|
||||
"showVersionOnCard": "הצג גרסה בכרטיס",
|
||||
"showVersionOnCardHelp": "הצג או הסתר את שם הגרסה בכרטיסי המודל",
|
||||
"modelCardFooterAction": "פעולת כפתור כרטיס מודל",
|
||||
@@ -1989,7 +1995,9 @@
|
||||
"nsfwMature": "תוכן למבוגרים",
|
||||
"nsfwR": "תוכן בדירוג R",
|
||||
"nsfwX": "תוכן בדירוג X",
|
||||
"nsfwXxx": "תוכן בדירוג XXX"
|
||||
"nsfwXxx": "תוכן בדירוג XXX",
|
||||
"layoutGallery": "תצוגת גלריה",
|
||||
"layoutList": "תצוגת רשימה"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "גרסאות המודל",
|
||||
|
||||
+9
-1
@@ -530,6 +530,12 @@
|
||||
"hover": "ホバー時に表示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "モデル情報とアクションボタンの表示タイミングを選択",
|
||||
"showcaseLayout": "ショーケースのレイアウト",
|
||||
"showcaseLayoutHelp": "モデルモーダルでの例画像の表示方法を選択:1枚ずつ表示するギャラリー、または従来の縦並びリスト。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "ギャラリー",
|
||||
"vertical": "縦並びリスト"
|
||||
},
|
||||
"showVersionOnCard": "カードにバージョンを表示",
|
||||
"showVersionOnCardHelp": "モデルカード上のバージョン名の表示/非表示を切り替えます",
|
||||
"modelCardFooterAction": "モデルカードボタンのアクション",
|
||||
@@ -1989,7 +1995,9 @@
|
||||
"nsfwMature": "成人向けコンテンツ",
|
||||
"nsfwR": "R指定コンテンツ",
|
||||
"nsfwX": "X指定コンテンツ",
|
||||
"nsfwXxx": "XXX指定コンテンツ"
|
||||
"nsfwXxx": "XXX指定コンテンツ",
|
||||
"layoutGallery": "ギャラリー表示",
|
||||
"layoutList": "リスト表示"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
|
||||
+9
-1
@@ -530,6 +530,12 @@
|
||||
"hover": "호버 시 표시"
|
||||
},
|
||||
"cardInfoDisplayHelp": "모델 정보 및 액션 버튼을 언제 표시할지 선택하세요",
|
||||
"showcaseLayout": "예시 이미지 레이아웃",
|
||||
"showcaseLayoutHelp": "모델 모달에서 예시 이미지의 표시 방식을 선택하세요: 한 번에 하나씩 보여주는 갤러리 또는 기존 세로 목록.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "갤러리",
|
||||
"vertical": "세로 목록"
|
||||
},
|
||||
"showVersionOnCard": "카드에 버전 표시",
|
||||
"showVersionOnCardHelp": "모델 카드에 버전 이름 표시 여부를 전환합니다",
|
||||
"modelCardFooterAction": "모델 카드 버튼 동작",
|
||||
@@ -1989,7 +1995,9 @@
|
||||
"nsfwMature": "성인 콘텐츠",
|
||||
"nsfwR": "R등급 콘텐츠",
|
||||
"nsfwX": "X등급 콘텐츠",
|
||||
"nsfwXxx": "XXX등급 콘텐츠"
|
||||
"nsfwXxx": "XXX등급 콘텐츠",
|
||||
"layoutGallery": "갤러리 보기",
|
||||
"layoutList": "목록 보기"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
|
||||
+9
-1
@@ -530,6 +530,12 @@
|
||||
"hover": "Показать при наведении"
|
||||
},
|
||||
"cardInfoDisplayHelp": "Выберите когда отображать информацию о модели и кнопки действий",
|
||||
"showcaseLayout": "Макет примеров изображений",
|
||||
"showcaseLayoutHelp": "Выберите, как отображаются примеры изображений в окне модели: галерея с просмотром по одному изображению или классический вертикальный список.",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "Галерея",
|
||||
"vertical": "Вертикальный список"
|
||||
},
|
||||
"showVersionOnCard": "Показывать версию на карточке",
|
||||
"showVersionOnCardHelp": "Показать или скрыть название версии на карточках моделей",
|
||||
"modelCardFooterAction": "Действие кнопки карточки модели",
|
||||
@@ -1989,7 +1995,9 @@
|
||||
"nsfwMature": "Контент для взрослых",
|
||||
"nsfwR": "Контент с рейтингом R",
|
||||
"nsfwX": "Контент с рейтингом X",
|
||||
"nsfwXxx": "Контент с рейтингом XXX"
|
||||
"nsfwXxx": "Контент с рейтингом XXX",
|
||||
"layoutGallery": "Галерея",
|
||||
"layoutList": "Список"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
|
||||
+9
-1
@@ -530,6 +530,12 @@
|
||||
"hover": "悬停时显示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "选择何时显示模型信息和操作按钮",
|
||||
"showcaseLayout": "示例图片布局",
|
||||
"showcaseLayoutHelp": "选择示例图片在模型弹窗中的显示方式:一次显示一张的画廊,或经典的纵向列表。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "画廊",
|
||||
"vertical": "纵向列表"
|
||||
},
|
||||
"showVersionOnCard": "在卡片上显示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上显示或隐藏版本名称",
|
||||
"modelCardFooterAction": "模型卡片按钮操作",
|
||||
@@ -1989,7 +1995,9 @@
|
||||
"nsfwMature": "成熟内容",
|
||||
"nsfwR": "R 级内容",
|
||||
"nsfwX": "X 级内容",
|
||||
"nsfwXxx": "XXX 级内容"
|
||||
"nsfwXxx": "XXX 级内容",
|
||||
"layoutGallery": "画廊视图",
|
||||
"layoutList": "列表视图"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
|
||||
+9
-1
@@ -530,6 +530,12 @@
|
||||
"hover": "滑鼠懸停顯示"
|
||||
},
|
||||
"cardInfoDisplayHelp": "選擇何時顯示模型資訊與操作按鈕",
|
||||
"showcaseLayout": "範例圖片版面",
|
||||
"showcaseLayoutHelp": "選擇範例圖片在模型彈窗中的顯示方式:一次一張的圖庫,或傳統的垂直清單。",
|
||||
"showcaseLayoutOptions": {
|
||||
"gallery": "圖庫",
|
||||
"vertical": "垂直清單"
|
||||
},
|
||||
"showVersionOnCard": "在卡片上顯示版本",
|
||||
"showVersionOnCardHelp": "在模型卡片上顯示或隱藏版本名稱",
|
||||
"modelCardFooterAction": "模型卡片按鈕操作",
|
||||
@@ -1989,7 +1995,9 @@
|
||||
"nsfwMature": "成熟內容",
|
||||
"nsfwR": "R 級內容",
|
||||
"nsfwX": "X 級內容",
|
||||
"nsfwXxx": "XXX 級內容"
|
||||
"nsfwXxx": "XXX 級內容",
|
||||
"layoutGallery": "圖庫檢視",
|
||||
"layoutList": "清單檢視"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
|
||||
@@ -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,13 +65,16 @@ 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>
|
||||
@@ -76,7 +87,7 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel,
|
||||
alt="Preview"
|
||||
width="${media.width}"
|
||||
height="${media.height}"
|
||||
fetchpriority="high"
|
||||
${heightPercent === null ? 'fetchpriority="high"' : ''}
|
||||
class="lazy ${shouldBlur ? 'blurred' : ''}">
|
||||
${shouldBlur ? `
|
||||
<div class="nsfw-overlay">
|
||||
@@ -90,3 +101,161 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, 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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user