From 469f7a1829ef2837789f0a18f24d87d8d6c162b4 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 6 Feb 2026 23:04:22 +0800 Subject: [PATCH] feat(showcase): add Show button to NSFW notice in main media area - Add showcase__nsfw-notice-content wrapper for better layout - Add showcase__nsfw-show-btn with styling matching card.css show-content-btn - Add show-content action handler that triggers global blur toggle - Button uses blue accent color with eye icon and hover effects - Clicking Show button syncs with blur toggle button icon state - Use unique class names to avoid conflicts with card.css --- static/css/base.css | 2 + .../css/components/model-modal/showcase.css | 132 ++++++ .../components/model-modal/thumbnail-rail.css | 9 +- static/js/components/model-modal/Showcase.js | 412 +++++++++++++++++- 4 files changed, 533 insertions(+), 22 deletions(-) diff --git a/static/css/base.css b/static/css/base.css index 8d6130a9..5911f66b 100644 --- a/static/css/base.css +++ b/static/css/base.css @@ -65,6 +65,8 @@ body { --space-1: calc(8px * 1); --space-2: calc(8px * 2); --space-3: calc(8px * 3); + --space-4: calc(8px * 4); + --space-5: calc(8px * 5); /* Z-index Scale */ --z-base: 10; diff --git a/static/css/components/model-modal/showcase.css b/static/css/components/model-modal/showcase.css index b2254ce2..edad7347 100644 --- a/static/css/components/model-modal/showcase.css +++ b/static/css/components/model-modal/showcase.css @@ -44,6 +44,128 @@ opacity: 0.5; } +/* Media container for images and videos */ +.showcase__media-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.showcase-media-wrapper { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.showcase__media-inner { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.showcase__media { + max-width: 100%; + max-height: 70vh; + object-fit: contain; + border-radius: var(--border-radius-sm); + transition: filter 0.2s ease; +} + +.showcase__media.blurred { + filter: blur(25px); +} + +/* NSFW notice for main media - redesigned to avoid conflicts with card.css */ +.showcase__nsfw-notice { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: white; + background: rgba(0, 0, 0, 0.75); + padding: var(--space-4) var(--space-5); + border-radius: var(--border-radius-base); + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 5; + user-select: none; +} + +.showcase__nsfw-notice-content { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); +} + +.showcase__nsfw-notice-text { + margin: 0; + font-size: 1.1em; + font-weight: 600; + letter-spacing: 0.02em; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); +} + +/* Show content button in NSFW notice - styled like card.css show-content-btn */ +.showcase__nsfw-show-btn { + background: var(--lora-accent); + color: white; + border: none; + border-radius: var(--border-radius-xs); + padding: 6px var(--space-3); + cursor: pointer; + font-size: 0.9em; + font-weight: 500; + transition: background-color 0.2s ease, transform 0.2s ease; + display: flex; + align-items: center; + gap: 6px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.showcase__nsfw-show-btn:hover { + background: oklch(58% 0.28 256); + transform: scale(1.05); +} + +.showcase__nsfw-show-btn i { + font-size: 1em; +} + +/* Control button active state for blur toggle */ +.showcase__control-btn.hidden { + display: none !important; +} + +/* Video indicator for thumbnails */ +.thumbnail-rail__video-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 1.5rem; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); + pointer-events: none; + z-index: 2; +} + +/* NSFW blur for thumbnails */ +.thumbnail-rail__item.nsfw-blur img { + filter: blur(8px); +} + /* Navigation arrows */ .showcase__nav { position: absolute; @@ -133,6 +255,16 @@ background: var(--lora-error); } +/* Active state for toggle buttons */ +.showcase__control-btn.active { + background: var(--lora-accent); + color: white; +} + +.showcase__control-btn.active:hover { + background: var(--lora-accent-hover, #3182ce); +} + /* Params panel (slide up) */ .showcase__params { position: absolute; diff --git a/static/css/components/model-modal/thumbnail-rail.css b/static/css/components/model-modal/thumbnail-rail.css index 7fd407b9..374b335e 100644 --- a/static/css/components/model-modal/thumbnail-rail.css +++ b/static/css/components/model-modal/thumbnail-rail.css @@ -55,7 +55,12 @@ object-fit: cover; } -/* NSFW blur */ +/* NSFW blur for thumbnails - BEM naming to avoid conflicts with global .nsfw-blur */ +.thumbnail-rail__item--nsfw-blurred img { + filter: blur(8px); +} + +/* Legacy support for old class names (deprecated) */ .thumbnail-rail__item.nsfw img { filter: blur(8px); } @@ -72,6 +77,8 @@ border-radius: var(--border-radius-xs); text-transform: uppercase; letter-spacing: 0.05em; + pointer-events: none; + user-select: none; } /* Add button */ diff --git a/static/js/components/model-modal/Showcase.js b/static/js/components/model-modal/Showcase.js index ff2f9c06..f31f9290 100644 --- a/static/js/components/model-modal/Showcase.js +++ b/static/js/components/model-modal/Showcase.js @@ -12,6 +12,9 @@ import { escapeHtml } from '../shared/utils.js'; import { translate } from '../../utils/i18nHelpers.js'; import { showToast } from '../../utils/uiHelpers.js'; import { getModelApiClient } from '../../api/modelApiFactory.js'; +import { state } from '../../state/index.js'; +import { NSFW_LEVELS } from '../../utils/constants.js'; +import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js'; export class Showcase { constructor(container) { @@ -23,18 +26,26 @@ export class Showcase { this.paramsVisible = false; this.uploadAreaVisible = false; this.isUploading = false; + this.localFiles = []; + this.globalBlurEnabled = true; // Will be initialized based on user settings } /** * Render the showcase */ - render({ images, modelHash, filePath }) { + async render({ images, modelHash, filePath }) { this.images = images || []; this.modelHash = modelHash || ''; this.filePath = filePath || ''; this.currentIndex = 0; this.paramsVisible = false; this.uploadAreaVisible = false; + + // Initialize global blur state based on user settings + this.globalBlurEnabled = state.settings.blur_mature_content; + + // Fetch local example files + await this.fetchLocalFiles(); this.element.innerHTML = this.getTemplate(); this.bindEvents(); @@ -44,6 +55,106 @@ export class Showcase { } } + /** + * Fetch local example files from the server + */ + async fetchLocalFiles() { + if (!this.modelHash) { + this.localFiles = []; + return; + } + + try { + const response = await fetch(`/api/lm/example-image-files?model_hash=${this.modelHash}`); + const result = await response.json(); + + if (result.success) { + this.localFiles = result.files || []; + } else { + this.localFiles = []; + } + } catch (error) { + console.error('Failed to fetch local example files:', error); + this.localFiles = []; + } + } + + /** + * Find the matching local file for an image + * @param {Object} img - Image metadata + * @param {number} index - Image index + * @returns {Object|null} Matching local file or null + */ + findLocalFile(img, index) { + if (!this.localFiles || this.localFiles.length === 0) return null; + + let localFile = null; + + if (typeof img.id === 'string' && img.id) { + // This is a custom image, find by custom_ + const customPrefix = `custom_${img.id}`; + localFile = this.localFiles.find(file => file.name.startsWith(customPrefix)); + } else { + // This is a regular image from civitai, find by index + localFile = this.localFiles.find(file => { + const match = file.name.match(/image_(\d+)\./); + return match && parseInt(match[1]) === index; + }); + } + + return localFile; + } + + /** + * Check if media is a video + * @param {Object} img - Image metadata + * @param {Object} localFile - Local file object + * @returns {boolean} + */ + isVideo(img, localFile) { + if (localFile) { + return localFile.is_video; + } + const url = img.url || ''; + return url.endsWith('.mp4') || url.endsWith('.webm'); + } + + /** + * Check if content should be blurred based on NSFW settings + * @param {number} nsfwLevel - NSFW level of the content + * @returns {boolean} + */ + shouldBlurContent(nsfwLevel) { + return this.globalBlurEnabled && nsfwLevel > NSFW_LEVELS.PG13; + } + + /** + * Check if any image in the showcase is NSFW + * @returns {boolean} + */ + hasNsfwContent() { + return this.images.some(img => { + const level = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; + return level > NSFW_LEVELS.PG13; + }); + } + + /** + * Get NSFW warning text based on level + * @param {number} nsfwLevel - NSFW level + * @returns {string} + */ + getNsfwText(nsfwLevel) { + if (nsfwLevel >= NSFW_LEVELS.XXX) { + return 'XXX-rated Content'; + } else if (nsfwLevel >= NSFW_LEVELS.X) { + return 'X-rated Content'; + } else if (nsfwLevel >= NSFW_LEVELS.R) { + return 'R-rated Content'; + } + return 'Mature Content'; + } + /** * Get the HTML template */ @@ -54,15 +165,25 @@ export class Showcase {
${hasImages ? `
- ${translate('modals.model.examples.title', {}, 'Example')} +
+ +
+ + @@ -109,14 +230,28 @@ export class Showcase { */ renderThumbnailRail() { const thumbnails = this.images.map((img, index) => { - const url = img.url || img; - const isNsfw = img.nsfw || false; + const localFile = this.findLocalFile(img, index); + const remoteUrl = img.url || img; + const localUrl = localFile ? localFile.path : ''; + const url = localUrl || remoteUrl; + const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; + // Check if this specific image needs blur based on global state + const needsBlur = nsfwLevel > NSFW_LEVELS.PG13; + const shouldBlur = this.globalBlurEnabled && needsBlur; + const isVideo = this.isVideo(img, localFile); + return ` -
- - ${isNsfw ? 'NSFW' : ''} + data-action="select-image" + data-nsfw-level="${nsfwLevel}"> + ${isVideo ? ` +
+ +
+ ` : ''} + + ${shouldBlur ? 'NSFW' : ''}
`; }).join(''); @@ -193,6 +328,9 @@ export class Showcase { case 'set-preview': this.setAsPreview(); break; + case 'set-nsfw': + this.setContentRating(); + break; case 'delete-example': this.deleteExample(); break; @@ -206,6 +344,13 @@ export class Showcase { case 'copy-prompt': this.copyPrompt(); break; + case 'toggle-blur': + this.toggleBlur(); + break; + case 'show-content': + case 'toggle-global-blur': + this.toggleGlobalBlur(); + break; } }); @@ -374,8 +519,12 @@ export class Showcase { this.images = allImages; this.currentIndex = allImages.length - 1; + // Re-fetch local files + await this.fetchLocalFiles(); + // Re-render - this.render({ images: allImages, modelHash: this.modelHash, filePath: this.filePath }); + this.element.innerHTML = this.getTemplate(); + this.bindEvents(); // Load the newly uploaded image if (this.currentIndex >= 0) { @@ -395,16 +544,26 @@ export class Showcase { this.currentIndex = index; const image = this.images[index]; - const url = image.url || image; + const localFile = this.findLocalFile(image, index); + const remoteUrl = image.url || image; + const localUrl = localFile ? localFile.path : ''; + const url = localUrl || remoteUrl; + const nsfwLevel = image.nsfwLevel !== undefined ? image.nsfwLevel : 0; + const shouldBlur = this.shouldBlurContent(nsfwLevel); + const isVideo = this.isVideo(image, localFile); + const nsfwText = this.getNsfwText(nsfwLevel); - // Update main image - const imgElement = this.element.querySelector('.showcase__image'); - if (imgElement) { - imgElement.classList.add('loading'); - imgElement.src = url; - imgElement.onload = () => { - imgElement.classList.remove('loading'); - }; + // Update main media container + const mediaContainer = this.element.querySelector('.showcase__media-container'); + if (mediaContainer) { + mediaContainer.innerHTML = this.renderMediaElement(url, isVideo, shouldBlur, nsfwText, nsfwLevel); + } + + // Update global blur toggle button visibility + const blurToggleBtn = this.element.querySelector('[data-action="toggle-global-blur"]'); + if (blurToggleBtn) { + const hasNsfw = this.hasNsfwContent(); + blurToggleBtn.classList.toggle('hidden', !hasNsfw); } // Update thumbnail rail active state @@ -416,6 +575,125 @@ export class Showcase { this.updateParams(image); } + /** + * Render media element (image or video) + */ + renderMediaElement(url, isVideo, shouldBlur, nsfwText, nsfwLevel) { + const blurClass = shouldBlur ? 'blurred' : ''; + + const mediaHtml = isVideo ? ` + + ` : ` + ${translate('modals.model.examples.title', {}, 'Example')} + `; + + const nsfwOverlay = shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''; + + return ` +
+
+ ${mediaHtml} + ${nsfwOverlay} +
+
+ `; + } + + /** + * Toggle global blur state for all images in the showcase + */ + toggleGlobalBlur() { + this.globalBlurEnabled = !this.globalBlurEnabled; + + // Update the toggle button + const toggleBtn = this.element.querySelector('[data-action="toggle-global-blur"]'); + if (toggleBtn) { + const icon = toggleBtn.querySelector('i'); + if (icon) { + icon.className = `fas ${this.globalBlurEnabled ? 'fa-eye' : 'fa-eye-slash'}`; + } + toggleBtn.title = this.globalBlurEnabled ? 'Show content' : 'Blur content'; + } + + // Update main image + this.updateMainImageBlur(); + + // Update all thumbnails + this.updateAllThumbnailsBlur(); + } + + /** + * Update main image blur state based on global setting + */ + updateMainImageBlur() { + const mediaContainer = this.element.querySelector('.showcase__media-container'); + if (!mediaContainer) return; + + const media = mediaContainer.querySelector('.showcase__media'); + const overlay = mediaContainer.querySelector('.showcase__nsfw-notice'); + const currentImage = this.images[this.currentIndex]; + + if (!media || !currentImage) return; + + const nsfwLevel = currentImage.nsfwLevel !== undefined ? currentImage.nsfwLevel : 0; + const shouldBlur = this.globalBlurEnabled && nsfwLevel > NSFW_LEVELS.PG13; + + media.classList.toggle('blurred', shouldBlur); + if (overlay) { + overlay.style.display = shouldBlur ? 'block' : 'none'; + } + } + + /** + * Update all thumbnails blur state based on global setting + */ + updateAllThumbnailsBlur() { + this.element.querySelectorAll('.thumbnail-rail__item').forEach((item, index) => { + const img = item.querySelector('img'); + const badge = item.querySelector('.thumbnail-rail__nsfw-badge'); + const image = this.images[index]; + + if (!image) return; + + const nsfwLevel = image.nsfwLevel !== undefined ? image.nsfwLevel : 0; + const needsBlur = nsfwLevel > NSFW_LEVELS.PG13; + const shouldBlur = this.globalBlurEnabled && needsBlur; + + // Update item class + item.classList.toggle('thumbnail-rail__item--nsfw-blurred', shouldBlur); + + // Update image blur + if (img) { + img.classList.toggle('blurred', shouldBlur); + } + + // Update badge visibility + if (needsBlur) { + if (shouldBlur && !badge) { + item.insertAdjacentHTML('beforeend', 'NSFW'); + } else if (!shouldBlur && badge) { + badge.remove(); + } + } + }); + } + /** * Navigate to previous image */ @@ -559,11 +837,20 @@ export class Showcase { const image = this.images[this.currentIndex]; if (!image || !this.filePath) return; - const url = image.url || image; + const localFile = this.findLocalFile(image, this.currentIndex); + const remoteUrl = image.url || image; + const localUrl = localFile ? localFile.path : ''; + const url = localUrl || remoteUrl; + const nsfwLevel = image.nsfwLevel !== undefined ? image.nsfwLevel : 0; try { + // Fetch the image/video as a blob + const response = await fetch(url); + const blob = await response.blob(); + const file = new File([blob], 'preview.jpg', { type: blob.type }); + const client = getModelApiClient(); - await client.setModelPreview(this.filePath, url); + await client.uploadPreview(this.filePath, file, nsfwLevel); showToast('modals.model.actions.previewSet', {}, 'success'); } catch (err) { @@ -572,6 +859,88 @@ export class Showcase { } } + /** + * Set content rating for current example + */ + async setContentRating() { + const image = this.images[this.currentIndex]; + if (!image || !this.modelHash) return; + + const selector = getNsfwLevelSelector(); + if (!selector) { + console.warn('NSFW selector not available'); + return; + } + + const currentLevel = image.nsfwLevel !== undefined ? image.nsfwLevel : 0; + const isCustomImage = Boolean(typeof image.id === 'string' && image.id); + const mediaIndex = this.currentIndex; + const mediaId = image.id || ''; + + selector.show({ + currentLevel, + onSelect: async (level) => { + try { + const payload = { + model_hash: this.modelHash, + nsfw_level: level, + source: isCustomImage ? 'custom' : 'civitai', + }; + + if (isCustomImage) { + payload.id = mediaId; + } else { + payload.index = mediaIndex; + } + + const response = await fetch('/api/lm/example-images/set-nsfw-level', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to update NSFW level'); + } + + // Update the image in our local array + image.nsfwLevel = level; + + // Re-render to apply the new blur state + this.loadImage(this.currentIndex); + + // Update thumbnail rail to reflect the change + const thumbnail = this.element.querySelector(`.thumbnail-rail__item[data-index="${this.currentIndex}"]`); + if (thumbnail) { + const shouldBlur = this.shouldBlurContent(level); + thumbnail.classList.toggle('thumbnail-rail__item--nsfw-blurred', shouldBlur); + thumbnail.dataset.nsfwLevel = level; + const thumbImg = thumbnail.querySelector('img'); + if (thumbImg) { + thumbImg.classList.toggle('blurred', shouldBlur); + } + const badge = thumbnail.querySelector('.thumbnail-rail__nsfw-badge'); + if (shouldBlur && !badge) { + thumbnail.insertAdjacentHTML('beforeend', 'NSFW'); + } else if (!shouldBlur && badge) { + badge.remove(); + } + } + + showToast('toast.contextMenu.contentRatingSet', { level }, 'success'); + return true; + } catch (error) { + console.error('Error updating NSFW level:', error); + showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error'); + return false; + } + }, + }); + } + /** * Delete current example */ @@ -610,7 +979,8 @@ export class Showcase { } // Re-render - this.render({ images: this.images, modelHash: this.modelHash, filePath: this.filePath }); + this.element.innerHTML = this.getTemplate(); + this.bindEvents(); if (this.images.length > 0) { this.loadImage(this.currentIndex);