From 7fec107b98e2636911946feca8a7ae0e7ddef7ad Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Wed, 11 Jun 2025 20:52:45 +0800 Subject: [PATCH] Refactor context menus to use ModelContextMenuMixin for shared functionality - Introduced ModelContextMenuMixin to encapsulate shared methods for Lora and Checkpoint context menus. - Updated CheckpointContextMenu to utilize the mixin for common actions and NSFW level handling. - Simplified LoraContextMenu by integrating the mixin, removing redundant methods. - Removed duplicated NSFW handling logic and centralized it in the mixin. - Adjusted import/export statements to reflect the new structure and ensure proper functionality. --- .../ContextMenu/CheckpointContextMenu.js | 380 +---------------- .../components/ContextMenu/LoraContextMenu.js | 385 +----------------- .../ContextMenu/ModelContextMenuMixin.js | 373 +++++++++++++++++ static/js/components/ContextMenu/index.js | 3 +- 4 files changed, 413 insertions(+), 728 deletions(-) create mode 100644 static/js/components/ContextMenu/ModelContextMenuMixin.js diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 3ed3af0f..c5b46eec 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -1,17 +1,16 @@ import { BaseContextMenu } from './BaseContextMenu.js'; -import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview } from '../../api/checkpointApi.js'; -import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js'; -import { NSFW_LEVELS } from '../../utils/constants.js'; -import { getStorageItem } from '../../utils/storageHelpers.js'; +import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; +import { refreshSingleCheckpointMetadata, saveModelMetadata, replaceCheckpointPreview, resetAndReload } from '../../api/checkpointApi.js'; +import { showToast } from '../../utils/uiHelpers.js'; import { showExcludeModal } from '../../utils/modalUtils.js'; -import { modalManager } from '../../managers/ModalManager.js'; import { state } from '../../state/index.js'; -import { resetAndReload } from '../../api/checkpointApi.js'; export class CheckpointContextMenu extends BaseContextMenu { constructor() { super('checkpointContextMenu', '.lora-card'); this.nsfwSelector = document.getElementById('nsfwLevelSelector'); + this.modelType = 'checkpoint'; + this.resetAndReload = resetAndReload; // Initialize NSFW Level Selector events if (this.nsfwSelector) { @@ -19,30 +18,27 @@ export class CheckpointContextMenu extends BaseContextMenu { } } + // Implementation needed by the mixin + async saveModelMetadata(filePath, data) { + return saveModelMetadata(filePath, data); + } + handleMenuAction(action) { + // First try to handle with common actions + if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { + return; + } + + // Otherwise handle checkpoint-specific actions switch(action) { case 'details': // Show checkpoint details this.currentCard.click(); break; - case 'preview': - // Open example images folder instead of replacing preview - openExampleImagesFolder(this.currentCard.dataset.sha256); - break; case 'replace-preview': // Add new action for replacing preview images replaceCheckpointPreview(this.currentCard.dataset.filepath); break; - case 'civitai': - // Open civitai page - if (this.currentCard.dataset.from_civitai === 'true') { - if (this.currentCard.querySelector('.fa-globe')) { - this.currentCard.querySelector('.fa-globe').click(); - } - } else { - showToast('No CivitAI information available', 'info'); - } - break; case 'delete': // Delete checkpoint if (this.currentCard.querySelector('.fa-trash')) { @@ -59,14 +55,6 @@ export class CheckpointContextMenu extends BaseContextMenu { // Refresh metadata from CivitAI refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath); break; - case 'relink-civitai': - // Handle re-link to Civitai - this.showRelinkCivitaiModal(); - break; - case 'set-nsfw': - // Set NSFW level - this.showNSFWLevelSelector(null, null, this.currentCard); - break; case 'move': // Move to folder (placeholder) showToast('Move to folder feature coming soon', 'info'); @@ -74,339 +62,9 @@ export class CheckpointContextMenu extends BaseContextMenu { case 'exclude': showExcludeModal(this.currentCard.dataset.filepath, 'checkpoint'); break; - } } +} - // NSFW Selector methods - 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 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'; - } - }); - } - - updateCardBlurEffect(card, level) { - // Get user settings for blur threshold - const blurThreshold = parseInt(getStorageItem('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'; - - // Remove toggle button when content is set to PG or PG13 - const cardHeader = previewContainer.querySelector('.card-header'); - if (cardHeader) { - const toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - // Remove the toggle button completely - toggleBtn.remove(); - - // Update base model label class if it exists - const baseModelLabel = cardHeader.querySelector('.base-model-label'); - if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) { - baseModelLabel.classList.remove('with-toggle'); - } - } - } - } - } - - 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'; - } - - showRelinkCivitaiModal() { - const filePath = this.currentCard.dataset.filepath; - if (!filePath) return; - - // Set up confirm button handler - const confirmBtn = document.getElementById('confirmRelinkBtn'); - const urlInput = document.getElementById('civitaiModelUrl'); - const errorDiv = document.getElementById('civitaiModelUrlError'); - - // Remove previous event listener if exists - if (this._boundRelinkHandler) { - confirmBtn.removeEventListener('click', this._boundRelinkHandler); - } - - // Create new bound handler - this._boundRelinkHandler = async () => { - const url = urlInput.value.trim(); - const modelVersionId = this.extractModelVersionId(url); - - if (!modelVersionId) { - errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.'; - return; - } - - errorDiv.textContent = ''; - modalManager.closeModal('relinkCivitaiModal'); - - try { - state.loadingManager.showSimpleLoading('Re-linking to Civitai...'); - - const response = await fetch('/api/checkpoints/relink-civitai', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_path: filePath, - model_version_id: modelVersionId - }) - }); - - if (!response.ok) { - throw new Error(`Failed to re-link model: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - showToast('Model successfully re-linked to Civitai', 'success'); - // Reload the current view to show updated data - await resetAndReload(); - } else { - throw new Error(data.error || 'Failed to re-link model'); - } - } catch (error) { - console.error('Error re-linking model:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - state.loadingManager.hide(); - } - }; - - // Set new event listener - confirmBtn.addEventListener('click', this._boundRelinkHandler); - - // Clear previous input - urlInput.value = ''; - errorDiv.textContent = ''; - - // Show modal - modalManager.showModal('relinkCivitaiModal'); - } - - extractModelVersionId(url) { - try { - const parsedUrl = new URL(url); - const modelVersionId = parsedUrl.searchParams.get('modelVersionId'); - return modelVersionId; - } catch (e) { - return null; - } - } -} \ No newline at end of file +// Mix in shared methods +Object.assign(CheckpointContextMenu.prototype, ModelContextMenuMixin); \ No newline at end of file diff --git a/static/js/components/ContextMenu/LoraContextMenu.js b/static/js/components/ContextMenu/LoraContextMenu.js index 540b6c7f..fbd9e9ea 100644 --- a/static/js/components/ContextMenu/LoraContextMenu.js +++ b/static/js/components/ContextMenu/LoraContextMenu.js @@ -1,16 +1,15 @@ import { BaseContextMenu } from './BaseContextMenu.js'; +import { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview, resetAndReload } from '../../api/loraApi.js'; -import { showToast, getNSFWLevelName, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js'; -import { NSFW_LEVELS } from '../../utils/constants.js'; -import { getStorageItem } from '../../utils/storageHelpers.js'; +import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js'; import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js'; -import { modalManager } from '../../managers/ModalManager.js'; -import { state } from '../../state/index.js'; export class LoraContextMenu extends BaseContextMenu { constructor() { super('loraContextMenu', '.lora-card'); this.nsfwSelector = document.getElementById('nsfwLevelSelector'); + this.modelType = 'lora'; + this.resetAndReload = resetAndReload; // Initialize NSFW Level Selector events if (this.nsfwSelector) { @@ -18,24 +17,23 @@ export class LoraContextMenu extends BaseContextMenu { } } + // Use the saveModelMetadata implementation from loraApi + async saveModelMetadata(filePath, data) { + return saveModelMetadata(filePath, data); + } + handleMenuAction(action, menuItem) { + // First try to handle with common actions + if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) { + return; + } + + // Otherwise handle lora-specific actions switch(action) { case 'detail': // Trigger the main card click which shows the modal this.currentCard.click(); break; - case 'civitai': - // Only trigger if the card is from civitai - if (this.currentCard.dataset.from_civitai === 'true') { - if (this.currentCard.dataset.meta === '{}') { - showToast('Please fetch metadata from CivitAI first', 'info'); - } else { - this.currentCard.querySelector('.fa-globe')?.click(); - } - } else { - showToast('No CivitAI information available', 'info'); - } - break; case 'copyname': // Generate and copy LoRA syntax this.copyLoraSyntax(); @@ -48,10 +46,6 @@ export class LoraContextMenu extends BaseContextMenu { // Send LoRA to workflow (replace mode) this.sendLoraToWorkflow(true); break; - case 'preview': - // Open example images folder instead of showing preview image dialog - openExampleImagesFolder(this.currentCard.dataset.sha256); - break; case 'replace-preview': // Add a new action for replacing preview images replacePreview(this.currentCard.dataset.filepath); @@ -66,19 +60,13 @@ export class LoraContextMenu extends BaseContextMenu { case 'refresh-metadata': refreshSingleLoraMetadata(this.currentCard.dataset.filepath); break; - case 'relink-civitai': - this.showRelinkCivitaiModal(); - break; - case 'set-nsfw': - this.showNSFWLevelSelector(null, null, this.currentCard); - break; case 'exclude': showExcludeModal(this.currentCard.dataset.filepath); break; } } - // New method to handle copy syntax functionality + // Specific LoRA methods copyLoraSyntax() { const card = this.currentCard; const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); @@ -88,7 +76,6 @@ export class LoraContextMenu extends BaseContextMenu { copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard'); } - // New method to handle send to workflow functionality sendLoraToWorkflow(replaceMode) { const card = this.currentCard; const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); @@ -97,341 +84,7 @@ export class LoraContextMenu extends BaseContextMenu { sendLoraToWorkflow(loraSyntax, replaceMode, 'lora'); } +} - // New method to handle re-link to Civitai - showRelinkCivitaiModal() { - const filePath = this.currentCard.dataset.filepath; - if (!filePath) return; - - // Set up confirm button handler - const confirmBtn = document.getElementById('confirmRelinkBtn'); - const urlInput = document.getElementById('civitaiModelUrl'); - const errorDiv = document.getElementById('civitaiModelUrlError'); - - // Remove previous event listener if exists - if (this._boundRelinkHandler) { - confirmBtn.removeEventListener('click', this._boundRelinkHandler); - } - - // Create new bound handler - this._boundRelinkHandler = async () => { - const url = urlInput.value.trim(); - const modelVersionId = this.extractModelVersionId(url); - - if (!modelVersionId) { - errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.'; - return; - } - - errorDiv.textContent = ''; - modalManager.closeModal('relinkCivitaiModal'); - - try { - state.loadingManager.showSimpleLoading('Re-linking to Civitai...'); - - const response = await fetch('/api/relink-civitai', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - file_path: filePath, - model_version_id: modelVersionId - }) - }); - - if (!response.ok) { - throw new Error(`Failed to re-link model: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - showToast('Model successfully re-linked to Civitai', 'success'); - // Reload the current view to show updated data - await resetAndReload(); - } else { - throw new Error(data.error || 'Failed to re-link model'); - } - } catch (error) { - console.error('Error re-linking model:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - state.loadingManager.hide(); - } - }; - - // Set new event listener - confirmBtn.addEventListener('click', this._boundRelinkHandler); - - // Clear previous input - urlInput.value = ''; - errorDiv.textContent = ''; - - // Show modal - modalManager.showModal('relinkCivitaiModal'); - } - - extractModelVersionId(url) { - try { - const parsedUrl = new URL(url); - const modelVersionId = parsedUrl.searchParams.get('modelVersionId'); - return modelVersionId; - } catch (e) { - return null; - } - } - - // NSFW Selector methods from the original context menu - 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) { - return saveModelMetadata(filePath, data); - } - - updateCardBlurEffect(card, level) { - // Get user settings for blur threshold - const blurThreshold = parseInt(getStorageItem('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'; - - // Remove toggle button when content is set to PG or PG13 - const cardHeader = previewContainer.querySelector('.card-header'); - if (cardHeader) { - const toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - // Remove the toggle button completely - toggleBtn.remove(); - - // Update base model label class if it exists - const baseModelLabel = cardHeader.querySelector('.base-model-label'); - if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) { - baseModelLabel.classList.remove('with-toggle'); - } - } - } - } - } - - 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'; - } -} \ No newline at end of file +// Mix in shared methods +Object.assign(LoraContextMenu.prototype, ModelContextMenuMixin); \ No newline at end of file diff --git a/static/js/components/ContextMenu/ModelContextMenuMixin.js b/static/js/components/ContextMenu/ModelContextMenuMixin.js new file mode 100644 index 00000000..1f404370 --- /dev/null +++ b/static/js/components/ContextMenu/ModelContextMenuMixin.js @@ -0,0 +1,373 @@ +import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../utils/uiHelpers.js'; +import { NSFW_LEVELS } from '../../utils/constants.js'; +import { getStorageItem } from '../../utils/storageHelpers.js'; +import { modalManager } from '../../managers/ModalManager.js'; +import { state } from '../../state/index.js'; + +// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu +export const ModelContextMenuMixin = { + // NSFW Selector methods + 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'; + } + }); + }, + + updateCardBlurEffect(card, level) { + // Get user settings for blur threshold + const blurThreshold = parseInt(getStorageItem('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'; + + // Remove toggle button when content is set to PG or PG13 + const cardHeader = previewContainer.querySelector('.card-header'); + if (cardHeader) { + const toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + // Remove the toggle button completely + toggleBtn.remove(); + + // Update base model label class if it exists + const baseModelLabel = cardHeader.querySelector('.base-model-label'); + if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) { + baseModelLabel.classList.remove('with-toggle'); + } + } + } + } + }, + + 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'; + }, + + // Civitai re-linking methods + showRelinkCivitaiModal() { + const filePath = this.currentCard.dataset.filepath; + if (!filePath) return; + + // Set up confirm button handler + const confirmBtn = document.getElementById('confirmRelinkBtn'); + const urlInput = document.getElementById('civitaiModelUrl'); + const errorDiv = document.getElementById('civitaiModelUrlError'); + + // Remove previous event listener if exists + if (this._boundRelinkHandler) { + confirmBtn.removeEventListener('click', this._boundRelinkHandler); + } + + // Create new bound handler + this._boundRelinkHandler = async () => { + const url = urlInput.value.trim(); + const modelVersionId = this.extractModelVersionId(url); + + if (!modelVersionId) { + errorDiv.textContent = 'Invalid URL format. Must include modelVersionId parameter.'; + return; + } + + errorDiv.textContent = ''; + modalManager.closeModal('relinkCivitaiModal'); + + try { + state.loadingManager.showSimpleLoading('Re-linking to Civitai...'); + + const endpoint = this.modelType === 'checkpoint' ? + '/api/checkpoints/relink-civitai' : + '/api/relink-civitai'; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + file_path: filePath, + model_version_id: modelVersionId + }) + }); + + if (!response.ok) { + throw new Error(`Failed to re-link model: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + showToast('Model successfully re-linked to Civitai', 'success'); + // Reload the current view to show updated data + await this.resetAndReload(); + } else { + throw new Error(data.error || 'Failed to re-link model'); + } + } catch (error) { + console.error('Error re-linking model:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + state.loadingManager.hide(); + } + }; + + // Set new event listener + confirmBtn.addEventListener('click', this._boundRelinkHandler); + + // Clear previous input + urlInput.value = ''; + errorDiv.textContent = ''; + + // Show modal + modalManager.showModal('relinkCivitaiModal'); + }, + + extractModelVersionId(url) { + try { + const parsedUrl = new URL(url); + const modelVersionId = parsedUrl.searchParams.get('modelVersionId'); + return modelVersionId; + } catch (e) { + return null; + } + }, + + // Common action handlers + handleCommonMenuActions(action) { + switch(action) { + case 'preview': + openExampleImagesFolder(this.currentCard.dataset.sha256); + return true; + case 'civitai': + if (this.currentCard.dataset.from_civitai === 'true') { + if (this.currentCard.querySelector('.fa-globe')) { + this.currentCard.querySelector('.fa-globe').click(); + } else { + showToast('Please fetch metadata from CivitAI first', 'info'); + } + } else { + showToast('No CivitAI information available', 'info'); + } + return true; + case 'relink-civitai': + this.showRelinkCivitaiModal(); + return true; + case 'set-nsfw': + this.showNSFWLevelSelector(null, null, this.currentCard); + return true; + default: + return false; + } + } +}; diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js index 092afbb7..6b7f165b 100644 --- a/static/js/components/ContextMenu/index.js +++ b/static/js/components/ContextMenu/index.js @@ -1,3 +1,4 @@ export { LoraContextMenu } from './LoraContextMenu.js'; export { RecipeContextMenu } from './RecipeContextMenu.js'; -export { CheckpointContextMenu } from './CheckpointContextMenu.js'; \ No newline at end of file +export { CheckpointContextMenu } from './CheckpointContextMenu.js'; +export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; \ No newline at end of file