mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 14:12:11 -03:00
feat(example-images): add NSFW level setting endpoint
Add new POST endpoint `/api/lm/example-images/set-nsfw-level` to allow updating NSFW classification for individual example images. The endpoint supports both regular and custom images, validates required parameters, and updates the corresponding model metadata. This enables users to manually adjust NSFW ratings for better content filtering.
This commit is contained in:
126
static/js/components/shared/NsfwLevelSelector.js
Normal file
126
static/js/components/shared/NsfwLevelSelector.js
Normal file
@@ -0,0 +1,126 @@
|
||||
import { getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
|
||||
let selectorController = null;
|
||||
|
||||
function buildController(selectorElement) {
|
||||
if (!selectorElement) return null;
|
||||
|
||||
const levelButtons = Array.from(selectorElement.querySelectorAll('.nsfw-level-btn'));
|
||||
const closeBtn = selectorElement.querySelector('.close-nsfw-selector');
|
||||
const currentLevelEl = selectorElement.querySelector('#currentNSFWLevel');
|
||||
|
||||
let onSelect = null;
|
||||
let onClose = null;
|
||||
let isOpen = false;
|
||||
let ignoreNextOutside = false;
|
||||
|
||||
const setLabel = (level, multipleLabel) => {
|
||||
if (!currentLevelEl) return;
|
||||
if (multipleLabel) {
|
||||
currentLevelEl.textContent = multipleLabel;
|
||||
return;
|
||||
}
|
||||
currentLevelEl.textContent = getNSFWLevelName(level);
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
selectorElement.style.display = 'none';
|
||||
isOpen = false;
|
||||
if (typeof onClose === 'function') {
|
||||
onClose();
|
||||
}
|
||||
onSelect = null;
|
||||
onClose = null;
|
||||
};
|
||||
|
||||
const show = ({ currentLevel = 0, onSelect: selectCb, onClose: closeCb, multipleLabel = '' } = {}) => {
|
||||
onSelect = selectCb || null;
|
||||
onClose = closeCb || null;
|
||||
isOpen = true;
|
||||
ignoreNextOutside = true; // ignore the click that triggered open
|
||||
|
||||
// Position near center of viewport
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
const rect = selectorElement.getBoundingClientRect();
|
||||
const finalX = Math.max((viewportWidth - rect.width) / 2, 0);
|
||||
const finalY = Math.max((viewportHeight - rect.height) / 2, 0);
|
||||
|
||||
selectorElement.style.left = `${finalX}px`;
|
||||
selectorElement.style.top = `${finalY}px`;
|
||||
|
||||
setLabel(currentLevel, multipleLabel);
|
||||
|
||||
// Highlight selected level (if not showing multiple values)
|
||||
levelButtons.forEach((btn) => {
|
||||
const btnLevel = parseInt(btn.dataset.level || '0', 10);
|
||||
if (multipleLabel) {
|
||||
btn.classList.remove('active');
|
||||
} else if (btnLevel === currentLevel) {
|
||||
btn.classList.add('active');
|
||||
} else {
|
||||
btn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
selectorElement.style.display = 'block';
|
||||
};
|
||||
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', hide);
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!isOpen) return;
|
||||
if (ignoreNextOutside) {
|
||||
ignoreNextOutside = false;
|
||||
return;
|
||||
}
|
||||
if (!selectorElement.contains(e.target)) {
|
||||
hide();
|
||||
}
|
||||
});
|
||||
|
||||
levelButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!isOpen) return;
|
||||
const level = parseInt(btn.dataset.level || '0', 10);
|
||||
if (typeof onSelect === 'function') {
|
||||
try {
|
||||
const result = await onSelect(level);
|
||||
if (result === false) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('NSFW selector onSelect failed', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
hide();
|
||||
});
|
||||
});
|
||||
|
||||
const showMultiple = (labelKey = 'modals.contentRating.multiple') => {
|
||||
const fallback = 'Multiple values';
|
||||
const text = translate(labelKey, {}, fallback);
|
||||
show({ multipleLabel: text });
|
||||
};
|
||||
|
||||
return {
|
||||
show,
|
||||
hide,
|
||||
showMultiple,
|
||||
isOpen: () => isOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export function getNsfwLevelSelector() {
|
||||
if (selectorController) {
|
||||
return selectorController;
|
||||
}
|
||||
|
||||
const element = document.getElementById('nsfwLevelSelector');
|
||||
selectorController = buildController(element);
|
||||
return selectorController;
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
* Media-specific utility functions for showcase components
|
||||
* (Moved from uiHelpers.js to better organize code)
|
||||
*/
|
||||
import { showToast, copyToClipboard } from '../../../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, getNSFWLevelName } from '../../../utils/uiHelpers.js';
|
||||
import { state } from '../../../state/index.js';
|
||||
import { getModelApiClient } from '../../../api/modelApiFactory.js';
|
||||
import { NSFW_LEVELS } from '../../../utils/constants.js';
|
||||
import { getNsfwLevelSelector } from '../NsfwLevelSelector.js';
|
||||
|
||||
/**
|
||||
* Try to load local image first, fall back to remote if local fails
|
||||
@@ -471,6 +473,9 @@ export function initMediaControlHandlers(container) {
|
||||
|
||||
// Initialize set preview buttons
|
||||
initSetPreviewHandlers(container);
|
||||
|
||||
// Initialize NSFW level buttons
|
||||
initSetNsfwHandlers(container);
|
||||
|
||||
// Media control visibility is now handled in initMetadataPanelHandlers
|
||||
// Any click handlers or other functionality can still be added here
|
||||
@@ -590,4 +595,143 @@ export function positionAllMediaControls(container) {
|
||||
mediaWrappers.forEach(wrapper => {
|
||||
positionMediaControlsInMediaRect(wrapper);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyNsfwLevelChange(mediaWrapper, nsfwLevel) {
|
||||
if (!mediaWrapper) return;
|
||||
|
||||
const mediaElement = mediaWrapper.querySelector('img, video');
|
||||
if (mediaElement) {
|
||||
mediaElement.dataset.nsfwLevel = String(nsfwLevel);
|
||||
}
|
||||
mediaWrapper.dataset.nsfwLevel = String(nsfwLevel);
|
||||
|
||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||
let overlay = mediaWrapper.querySelector('.nsfw-overlay');
|
||||
let toggleBtn = mediaWrapper.querySelector('.toggle-blur-btn');
|
||||
|
||||
if (shouldBlur) {
|
||||
mediaWrapper.classList.add('nsfw-media-wrapper');
|
||||
if (mediaElement) {
|
||||
mediaElement.classList.add('blurred');
|
||||
}
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.className = 'nsfw-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="nsfw-warning">
|
||||
<p>Mature Content</p>
|
||||
<button class="show-content-btn">Show</button>
|
||||
</div>
|
||||
`;
|
||||
mediaWrapper.appendChild(overlay);
|
||||
} else {
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
if (!toggleBtn) {
|
||||
toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'toggle-blur-btn showcase-toggle-btn';
|
||||
toggleBtn.title = 'Toggle blur';
|
||||
toggleBtn.innerHTML = '<i class="fas fa-eye"></i>';
|
||||
mediaWrapper.insertBefore(toggleBtn, mediaWrapper.firstChild);
|
||||
} else {
|
||||
const icon = toggleBtn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-eye';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mediaWrapper.classList.remove('nsfw-media-wrapper');
|
||||
if (mediaElement) {
|
||||
mediaElement.classList.remove('blurred');
|
||||
}
|
||||
if (overlay) {
|
||||
overlay.style.display = 'none';
|
||||
}
|
||||
if (toggleBtn) {
|
||||
const icon = toggleBtn.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = 'fas fa-eye-slash';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-bind blur toggles for any newly added elements
|
||||
initNsfwBlurHandlers(mediaWrapper);
|
||||
}
|
||||
|
||||
function initSetNsfwHandlers(container) {
|
||||
const nsfwButtons = container.querySelectorAll('.set-nsfw-btn');
|
||||
const selector = getNsfwLevelSelector();
|
||||
|
||||
nsfwButtons.forEach((btn) => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!selector) {
|
||||
console.warn('NSFW selector not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaWrapper = btn.closest('.media-wrapper');
|
||||
const currentLevel = parseInt(mediaWrapper?.dataset.nsfwLevel || '0', 10);
|
||||
const modelHash = document.querySelector('.showcase-section')?.dataset.modelHash;
|
||||
const mediaSource = btn.dataset.mediaSource || 'civitai';
|
||||
const mediaIndex = parseInt(btn.dataset.mediaIndex || '-1', 10);
|
||||
const mediaId = btn.dataset.mediaId || '';
|
||||
|
||||
selector.show({
|
||||
currentLevel,
|
||||
onSelect: async (level) => {
|
||||
if (!modelHash) {
|
||||
showToast('toast.contextMenu.contentRatingFailed', { message: 'Missing model hash' }, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
const originalIcon = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
model_hash: modelHash,
|
||||
nsfw_level: level,
|
||||
source: mediaSource,
|
||||
};
|
||||
|
||||
if (mediaSource === 'custom') {
|
||||
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');
|
||||
}
|
||||
|
||||
applyNsfwLevelChange(mediaWrapper, level);
|
||||
showToast('toast.contextMenu.contentRatingSet', { level: getNSFWLevelName(level) }, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error updating NSFW level:', error);
|
||||
showToast('toast.contextMenu.contentRatingFailed', { message: error.message }, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalIcon;
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -211,6 +211,13 @@ function renderMediaItem(img, index, exampleFiles) {
|
||||
<button class="media-control-btn set-preview-btn" title="Set as preview">
|
||||
<i class="fas fa-image"></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 || ''}"
|
||||
|
||||
Reference in New Issue
Block a user