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
This commit is contained in:
Will Miao
2026-02-06 23:04:22 +08:00
parent d27e3c8126
commit 469f7a1829
4 changed files with 533 additions and 22 deletions

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 */

View File

@@ -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_<id>
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 {
<div class="showcase__main">
${hasImages ? `
<div class="showcase__image-wrapper">
<img class="showcase__image" alt="${translate('modals.model.examples.title', {}, 'Example')}">
<div class="showcase__media-container">
<!-- Media will be loaded here -->
</div>
<div class="showcase__controls">
<button class="showcase__control-btn ${this.hasNsfwContent() ? '' : 'hidden'}"
data-action="toggle-global-blur"
title="${this.globalBlurEnabled ? 'Show content' : 'Blur content'}">
<i class="fas ${this.globalBlurEnabled ? 'fa-eye' : 'fa-eye-slash'}"></i>
</button>
<button class="showcase__control-btn" data-action="toggle-params" title="${translate('modals.model.actions.viewParams', {}, 'View parameters (I)')}">
<i class="fas fa-info-circle"></i>
</button>
<button class="showcase__control-btn showcase__control-btn--primary" data-action="set-preview" title="${translate('modals.model.actions.setPreview', {}, 'Set as preview')}">
<i class="fas fa-image"></i>
</button>
<button class="showcase__control-btn" data-action="set-nsfw" title="${translate('modals.model.actions.setContentRating', {}, 'Set content rating')}">
<i class="fas fa-exclamation-triangle"></i>
</button>
<button class="showcase__control-btn showcase__control-btn--danger" data-action="delete-example" title="${translate('modals.model.actions.delete', {}, 'Delete')}">
<i class="fas fa-trash-alt"></i>
</button>
@@ -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 `
<div class="thumbnail-rail__item ${index === 0 ? 'active' : ''} ${isNsfw ? 'nsfw' : ''}"
<div class="thumbnail-rail__item ${index === 0 ? 'active' : ''} ${shouldBlur ? 'thumbnail-rail__item--nsfw-blurred' : ''}"
data-index="${index}"
data-action="select-image">
<img src="${url}" loading="lazy" alt="">
${isNsfw ? '<span class="thumbnail-rail__nsfw-badge">NSFW</span>' : ''}
data-action="select-image"
data-nsfw-level="${nsfwLevel}">
${isVideo ? `
<div class="thumbnail-rail__video-indicator">
<i class="fas fa-play-circle"></i>
</div>
` : ''}
<img src="${url}" loading="lazy" alt="" ${shouldBlur ? 'class="blurred"' : ''}>
${shouldBlur ? '<span class="thumbnail-rail__nsfw-badge">NSFW</span>' : ''}
</div>
`;
}).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 ? `
<video controls autoplay muted loop crossorigin="anonymous"
referrerpolicy="no-referrer"
class="showcase__media ${blurClass}">
<source src="${url}" type="video/mp4">
Your browser does not support video playback
</video>
` : `
<img src="${url}"
alt="${translate('modals.model.examples.title', {}, 'Example')}"
class="showcase__media ${blurClass}">
`;
const nsfwOverlay = shouldBlur ? `
<div class="showcase__nsfw-notice">
<div class="showcase__nsfw-notice-content">
<p class="showcase__nsfw-notice-text">${nsfwText}</p>
<button class="showcase__nsfw-show-btn" data-action="show-content">
<i class="fas fa-eye"></i> ${translate('common.show', {}, 'Show')}
</button>
</div>
</div>
` : '';
return `
<div class="showcase-media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-nsfw-level="${nsfwLevel}">
<div class="showcase__media-inner">
${mediaHtml}
${nsfwOverlay}
</div>
</div>
`;
}
/**
* 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', '<span class="thumbnail-rail__nsfw-badge">NSFW</span>');
} 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', '<span class="thumbnail-rail__nsfw-badge">NSFW</span>');
} 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);