From e992ace11c26062c7ebd7e13def75ac36a24ff2f Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 12 Mar 2025 22:21:30 +0800 Subject: [PATCH] Add NSFW browse control functionality - Done --- py/services/lora_scanner.py | 9 + py/utils/constants.py | 8 + static/css/components/bulk.css | 77 +++++++ static/js/components/ContextMenu.js | 267 +++++++++++++++++++++++++ static/js/managers/SettingsManager.js | 4 + static/js/utils/constants.js | 1 + static/js/utils/uiHelpers.js | 11 + templates/components/context_menu.html | 20 ++ 8 files changed, 397 insertions(+) create mode 100644 py/utils/constants.py diff --git a/py/services/lora_scanner.py b/py/services/lora_scanner.py index d7ed0986..559227cf 100644 --- a/py/services/lora_scanner.py +++ b/py/services/lora_scanner.py @@ -11,6 +11,8 @@ from ..utils.file_utils import load_metadata, get_file_info from .lora_cache import LoraCache from difflib import SequenceMatcher from .lora_hash_index import LoraHashIndex +from .settings_manager import settings +from ..utils.constants import NSFW_LEVELS logger = logging.getLogger(__name__) @@ -196,6 +198,13 @@ class LoraScanner: # Get the base data set filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name + # Apply SFW filtering if enabled + if settings.get('show_only_sfw', False): + filtered_data = [ + item for item in filtered_data + if not item.get('preview_nsfw_level') or item.get('preview_nsfw_level') < NSFW_LEVELS['R'] + ] + # Apply folder filtering if folder is not None: if recursive: diff --git a/py/utils/constants.py b/py/utils/constants.py new file mode 100644 index 00000000..69a96ca2 --- /dev/null +++ b/py/utils/constants.py @@ -0,0 +1,8 @@ +NSFW_LEVELS = { + "PG": 1, + "PG13": 2, + "R": 4, + "X": 8, + "XXX": 16, + "Blocked": 32, # Probably not actually visible through the API without being logged in on model owner account? +} \ No newline at end of file diff --git a/static/css/components/bulk.css b/static/css/components/bulk.css index 76f88397..ce60a52c 100644 --- a/static/css/components/bulk.css +++ b/static/css/components/bulk.css @@ -262,6 +262,83 @@ background: var(--lora-accent); } +/* NSFW Level Selector */ +.nsfw-level-selector { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-base); + padding: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2); + z-index: var(--z-modal); + width: 300px; + display: none; +} + +.nsfw-level-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.nsfw-level-header h3 { + margin: 0; + font-size: 16px; + font-weight: 500; +} + +.close-nsfw-selector { + background: transparent; + border: none; + color: var(--text-color); + cursor: pointer; + padding: 4px; + border-radius: var(--border-radius-xs); +} + +.close-nsfw-selector:hover { + background: var(--border-color); +} + +.current-level { + margin-bottom: 12px; + padding: 8px; + background: var(--bg-color); + border-radius: var(--border-radius-xs); + border: 1px solid var(--border-color); +} + +.nsfw-level-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.nsfw-level-btn { + flex: 1 0 calc(33% - 8px); + padding: 8px; + border-radius: var(--border-radius-xs); + background: var(--bg-color); + border: 1px solid var(--border-color); + color: var(--text-color); + cursor: pointer; + transition: all 0.2s ease; +} + +.nsfw-level-btn:hover { + background: var(--lora-border); +} + +.nsfw-level-btn.active { + background: var(--lora-accent); + color: white; + border-color: var(--lora-accent); +} + /* Mobile optimizations */ @media (max-width: 768px) { .selected-thumbnails-strip { diff --git a/static/js/components/ContextMenu.js b/static/js/components/ContextMenu.js index 7cb13884..b02f2828 100644 --- a/static/js/components/ContextMenu.js +++ b/static/js/components/ContextMenu.js @@ -1,9 +1,12 @@ import { refreshSingleLoraMetadata } from '../api/loraApi.js'; +import { showToast, getNSFWLevelName } from '../utils/uiHelpers.js'; +import { NSFW_LEVELS } from '../utils/constants.js'; export class LoraContextMenu { constructor() { this.menu = document.getElementById('loraContextMenu'); this.currentCard = null; + this.nsfwSelector = document.getElementById('nsfwLevelSelector'); this.init(); } @@ -58,10 +61,274 @@ export class LoraContextMenu { case 'refresh-metadata': refreshSingleLoraMetadata(this.currentCard.dataset.filepath); break; + case 'set-nsfw': + this.showNSFWLevelSelector(null, null, this.currentCard); + break; } this.hideMenu(); }); + + // Initialize NSFW Level Selector events + this.initNSFWSelector(); + } + + initNSFWSelector() { + // Close button + const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector'); + closeBtn.addEventListener('click', () => { + this.nsfwSelector.style.display = 'none'; + }); + + // Level buttons + const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn'); + levelButtons.forEach(btn => { + btn.addEventListener('click', async () => { + const level = parseInt(btn.dataset.level); + const filePath = this.nsfwSelector.dataset.cardPath; + + if (!filePath) return; + + try { + await this.saveModelMetadata(filePath, { preview_nsfw_level: level }); + + // Update card data + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (card) { + let metaData = {}; + try { + metaData = JSON.parse(card.dataset.meta || '{}'); + } catch (err) { + console.error('Error parsing metadata:', err); + } + + metaData.preview_nsfw_level = level; + card.dataset.meta = JSON.stringify(metaData); + card.dataset.nsfwLevel = level.toString(); + + // Apply blur effect immediately + this.updateCardBlurEffect(card, level); + } + + showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success'); + this.nsfwSelector.style.display = 'none'; + } catch (error) { + showToast(`Failed to set content rating: ${error.message}`, 'error'); + } + }); + }); + + // Close when clicking outside + document.addEventListener('click', (e) => { + if (this.nsfwSelector.style.display === 'block' && + !this.nsfwSelector.contains(e.target) && + !e.target.closest('.context-menu-item[data-action="set-nsfw"]')) { + this.nsfwSelector.style.display = 'none'; + } + }); + } + + async saveModelMetadata(filePath, data) { + const response = await fetch('/loras/api/save-metadata', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + return await response.json(); + } + + updateCardBlurEffect(card, level) { + // Get user settings for blur threshold + const blurThreshold = parseInt(localStorage.getItem('nsfwBlurLevel') || '4'); + + // Get card preview container + const previewContainer = card.querySelector('.card-preview'); + if (!previewContainer) return; + + // Get preview media element + const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video'); + if (!previewMedia) return; + + // Check if blur should be applied + if (level >= blurThreshold) { + // Add blur class to the preview container + previewContainer.classList.add('blurred'); + + // Get or create the NSFW overlay + let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay'); + if (!nsfwOverlay) { + // Create new overlay + nsfwOverlay = document.createElement('div'); + nsfwOverlay.className = 'nsfw-overlay'; + + // Create and configure the warning content + const warningContent = document.createElement('div'); + warningContent.className = 'nsfw-warning'; + + // Determine NSFW warning text based on level + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + + // Add warning text and show button + warningContent.innerHTML = ` +

${nsfwText}

+ + `; + + // Add click event to the show button + const showBtn = warningContent.querySelector('.show-content-btn'); + showBtn.addEventListener('click', (e) => { + e.stopPropagation(); + previewContainer.classList.remove('blurred'); + nsfwOverlay.style.display = 'none'; + + // Update toggle button icon if it exists + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + }); + + nsfwOverlay.appendChild(warningContent); + previewContainer.appendChild(nsfwOverlay); + } else { + // Update existing overlay + const warningText = nsfwOverlay.querySelector('p'); + if (warningText) { + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + warningText.textContent = nsfwText; + } + nsfwOverlay.style.display = 'flex'; + } + + // Get or create the toggle button in the header + const cardHeader = previewContainer.querySelector('.card-header'); + if (cardHeader) { + let toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); + + if (!toggleBtn) { + toggleBtn = document.createElement('button'); + toggleBtn.className = 'toggle-blur-btn'; + toggleBtn.title = 'Toggle blur'; + toggleBtn.innerHTML = ''; + + // Add click event to toggle button + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isBlurred = previewContainer.classList.toggle('blurred'); + const icon = toggleBtn.querySelector('i'); + + // Update icon and overlay visibility + if (isBlurred) { + icon.className = 'fas fa-eye'; + nsfwOverlay.style.display = 'flex'; + } else { + icon.className = 'fas fa-eye-slash'; + nsfwOverlay.style.display = 'none'; + } + }); + + // Add to the beginning of header + cardHeader.insertBefore(toggleBtn, cardHeader.firstChild); + + // Update base model label class + const baseModelLabel = cardHeader.querySelector('.base-model-label'); + if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) { + baseModelLabel.classList.add('with-toggle'); + } + } else { + // Update existing toggle button + toggleBtn.querySelector('i').className = 'fas fa-eye'; + } + } + } else { + // Remove blur + previewContainer.classList.remove('blurred'); + + // Hide overlay if it exists + const overlay = previewContainer.querySelector('.nsfw-overlay'); + if (overlay) overlay.style.display = 'none'; + + // Update or remove toggle button + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + // We'll leave the button but update the icon + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + } + } + + showNSFWLevelSelector(x, y, card) { + const selector = document.getElementById('nsfwLevelSelector'); + const currentLevelEl = document.getElementById('currentNSFWLevel'); + + // Get current NSFW level + let currentLevel = 0; + try { + const metaData = JSON.parse(card.dataset.meta || '{}'); + currentLevel = metaData.preview_nsfw_level || 0; + + // Update if we have no recorded level but have a dataset attribute + if (!currentLevel && card.dataset.nsfwLevel) { + currentLevel = parseInt(card.dataset.nsfwLevel) || 0; + } + } catch (err) { + console.error('Error parsing metadata:', err); + } + + currentLevelEl.textContent = getNSFWLevelName(currentLevel); + + // Position the selector + if (x && y) { + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const selectorRect = selector.getBoundingClientRect(); + + // Center the selector if no coordinates provided + let finalX = (viewportWidth - selectorRect.width) / 2; + let finalY = (viewportHeight - selectorRect.height) / 2; + + selector.style.left = `${finalX}px`; + selector.style.top = `${finalY}px`; + } + + // Highlight current level button + document.querySelectorAll('.nsfw-level-btn').forEach(btn => { + if (parseInt(btn.dataset.level) === currentLevel) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Store reference to current card + selector.dataset.cardPath = card.dataset.filepath; + + // Show selector + selector.style.display = 'block'; } showMenu(x, y, card) { diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index a167e13e..0af6b5c8 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1,6 +1,7 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { state, saveSettings } from '../state/index.js'; +import { resetAndReload } from '../api/loraApi.js'; export class SettingsManager { constructor() { @@ -89,6 +90,9 @@ export class SettingsManager { // Apply frontend settings immediately this.applyFrontendSettings(); + + // Reload the loras without updating folders + await resetAndReload(false); } catch (error) { showToast('Failed to save settings: ' + error.message, 'error'); } diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index 3cb0c10a..c2f0ff27 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -90,6 +90,7 @@ export const BASE_MODEL_CLASSES = { }; export const NSFW_LEVELS = { + UNKNOWN: 0, PG: 1, PG13: 2, R: 4, diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 2b5f5022..0597488c 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -151,4 +151,15 @@ export function initBackToTop() { // Initial check toggleBackToTop(); +} + +export function getNSFWLevelName(level) { + if (level === 0) return 'Unknown'; + if (level >= 32) return 'Blocked'; + if (level >= 16) return 'XXX'; + if (level >= 8) return 'X'; + if (level >= 4) return 'R'; + if (level >= 2) return 'PG13'; + if (level >= 1) return 'PG'; + return 'Unknown'; } \ No newline at end of file diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index c9eed331..bb980c9d 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -14,6 +14,9 @@
Replace Preview
+
+ Set Content Rating +
Move to Folder @@ -21,4 +24,21 @@
Delete Model
+
+ +
+
+

Set Content Rating

+ +
+
+
Current: Unknown
+
+ + + + + +
+
\ No newline at end of file