mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: Implement context menus for checkpoints and recipes, including metadata refresh and NSFW level management
This commit is contained in:
@@ -5,7 +5,8 @@ import {
|
|||||||
refreshModels as baseRefreshModels,
|
refreshModels as baseRefreshModels,
|
||||||
deleteModel as baseDeleteModel,
|
deleteModel as baseDeleteModel,
|
||||||
replaceModelPreview,
|
replaceModelPreview,
|
||||||
fetchCivitaiMetadata
|
fetchCivitaiMetadata,
|
||||||
|
refreshSingleModelMetadata
|
||||||
} from './baseModelApi.js';
|
} from './baseModelApi.js';
|
||||||
|
|
||||||
// Load more checkpoints with pagination
|
// Load more checkpoints with pagination
|
||||||
@@ -54,4 +55,29 @@ export async function fetchCivitai() {
|
|||||||
fetchEndpoint: '/api/checkpoints/fetch-all-civitai',
|
fetchEndpoint: '/api/checkpoints/fetch-all-civitai',
|
||||||
resetAndReloadFunction: resetAndReload
|
resetAndReloadFunction: resetAndReload
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh single checkpoint metadata
|
||||||
|
export async function refreshSingleCheckpointMetadata(filePath) {
|
||||||
|
return refreshSingleModelMetadata(filePath, 'checkpoint');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save checkpoint metadata (similar to the Lora version)
|
||||||
|
export async function saveCheckpointMetadata(filePath, data) {
|
||||||
|
const response = await fetch('/api/checkpoints/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();
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
|
|||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
import { loadMoreCheckpoints } from './api/checkpointApi.js';
|
||||||
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
|
import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js';
|
||||||
|
import { CheckpointContextMenu } from './components/ContextMenu/index.js';
|
||||||
|
|
||||||
// Initialize the Checkpoints page
|
// Initialize the Checkpoints page
|
||||||
class CheckpointsPageManager {
|
class CheckpointsPageManager {
|
||||||
@@ -34,6 +35,9 @@ class CheckpointsPageManager {
|
|||||||
this.pageControls.restoreFolderFilter();
|
this.pageControls.restoreFolderFilter();
|
||||||
this.pageControls.initFolderTagsVisibility();
|
this.pageControls.initFolderTagsVisibility();
|
||||||
|
|
||||||
|
// Initialize context menu
|
||||||
|
new CheckpointContextMenu();
|
||||||
|
|
||||||
// Initialize infinite scroll
|
// Initialize infinite scroll
|
||||||
initializeInfiniteScroll('checkpoints');
|
initializeInfiniteScroll('checkpoints');
|
||||||
|
|
||||||
|
|||||||
@@ -366,4 +366,7 @@ export class LoraContextMenu {
|
|||||||
this.menu.style.display = 'none';
|
this.menu.style.display = 'none';
|
||||||
this.currentCard = null;
|
this.currentCard = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For backward compatibility, re-export the LoraContextMenu class
|
||||||
|
// export { LoraContextMenu } from './ContextMenu/LoraContextMenu.js';
|
||||||
84
static/js/components/ContextMenu/BaseContextMenu.js
Normal file
84
static/js/components/ContextMenu/BaseContextMenu.js
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
export class BaseContextMenu {
|
||||||
|
constructor(menuId, cardSelector) {
|
||||||
|
this.menu = document.getElementById(menuId);
|
||||||
|
this.cardSelector = cardSelector;
|
||||||
|
this.currentCard = null;
|
||||||
|
|
||||||
|
if (!this.menu) {
|
||||||
|
console.error(`Context menu element with ID ${menuId} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Hide menu on regular clicks
|
||||||
|
document.addEventListener('click', () => this.hideMenu());
|
||||||
|
|
||||||
|
// Show menu on right-click on cards
|
||||||
|
document.addEventListener('contextmenu', (e) => {
|
||||||
|
const card = e.target.closest(this.cardSelector);
|
||||||
|
if (!card) {
|
||||||
|
this.hideMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
this.showMenu(e.clientX, e.clientY, card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle menu item clicks
|
||||||
|
this.menu.addEventListener('click', (e) => {
|
||||||
|
const menuItem = e.target.closest('.context-menu-item');
|
||||||
|
if (!menuItem || !this.currentCard) return;
|
||||||
|
|
||||||
|
const action = menuItem.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
this.handleMenuAction(action, menuItem);
|
||||||
|
this.hideMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMenuAction(action, menuItem) {
|
||||||
|
// Override in subclass
|
||||||
|
console.warn('handleMenuAction not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
showMenu(x, y, card) {
|
||||||
|
this.currentCard = card;
|
||||||
|
this.menu.style.display = 'block';
|
||||||
|
|
||||||
|
// Get menu dimensions
|
||||||
|
const menuRect = this.menu.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Get viewport dimensions
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
|
|
||||||
|
// Calculate position
|
||||||
|
let finalX = x;
|
||||||
|
let finalY = y;
|
||||||
|
|
||||||
|
// Ensure menu doesn't go offscreen right
|
||||||
|
if (x + menuRect.width > viewportWidth) {
|
||||||
|
finalX = x - menuRect.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure menu doesn't go offscreen bottom
|
||||||
|
if (y + menuRect.height > viewportHeight) {
|
||||||
|
finalY = y - menuRect.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position menu
|
||||||
|
this.menu.style.left = `${finalX}px`;
|
||||||
|
this.menu.style.top = `${finalY}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
hideMenu() {
|
||||||
|
if (this.menu) {
|
||||||
|
this.menu.style.display = 'none';
|
||||||
|
}
|
||||||
|
this.currentCard = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
315
static/js/components/ContextMenu/CheckpointContextMenu.js
Normal file
315
static/js/components/ContextMenu/CheckpointContextMenu.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
|
import { refreshSingleCheckpointMetadata, saveCheckpointMetadata } from '../../api/checkpointApi.js';
|
||||||
|
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||||
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
|
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||||
|
|
||||||
|
export class CheckpointContextMenu extends BaseContextMenu {
|
||||||
|
constructor() {
|
||||||
|
super('checkpointContextMenu', '.lora-card');
|
||||||
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||||
|
|
||||||
|
// Initialize NSFW Level Selector events
|
||||||
|
if (this.nsfwSelector) {
|
||||||
|
this.initNSFWSelector();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMenuAction(action) {
|
||||||
|
switch(action) {
|
||||||
|
case 'details':
|
||||||
|
// Show checkpoint details
|
||||||
|
this.currentCard.click();
|
||||||
|
break;
|
||||||
|
case 'preview':
|
||||||
|
// Replace checkpoint preview
|
||||||
|
if (this.currentCard.querySelector('.fa-image')) {
|
||||||
|
this.currentCard.querySelector('.fa-image').click();
|
||||||
|
}
|
||||||
|
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')) {
|
||||||
|
this.currentCard.querySelector('.fa-trash').click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'copyname':
|
||||||
|
// Copy checkpoint name
|
||||||
|
if (this.currentCard.querySelector('.fa-copy')) {
|
||||||
|
this.currentCard.querySelector('.fa-copy').click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'refresh-metadata':
|
||||||
|
// Refresh metadata from CivitAI
|
||||||
|
refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath);
|
||||||
|
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');
|
||||||
|
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 saveCheckpointMetadata(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 = `
|
||||||
|
<p>${nsfwText}</p>
|
||||||
|
<button class="show-content-btn">Show</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = '<i class="fas fa-eye"></i>';
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
324
static/js/components/ContextMenu/LoraContextMenu.js
Normal file
324
static/js/components/ContextMenu/LoraContextMenu.js
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
|
import { refreshSingleLoraMetadata } from '../../api/loraApi.js';
|
||||||
|
import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js';
|
||||||
|
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||||
|
import { getStorageItem } from '../../utils/storageHelpers.js';
|
||||||
|
|
||||||
|
export class LoraContextMenu extends BaseContextMenu {
|
||||||
|
constructor() {
|
||||||
|
super('loraContextMenu', '.lora-card');
|
||||||
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||||
|
|
||||||
|
// Initialize NSFW Level Selector events
|
||||||
|
if (this.nsfwSelector) {
|
||||||
|
this.initNSFWSelector();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMenuAction(action, menuItem) {
|
||||||
|
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':
|
||||||
|
this.currentCard.querySelector('.fa-copy')?.click();
|
||||||
|
break;
|
||||||
|
case 'preview':
|
||||||
|
this.currentCard.querySelector('.fa-image')?.click();
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
this.currentCard.querySelector('.fa-trash')?.click();
|
||||||
|
break;
|
||||||
|
case 'move':
|
||||||
|
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
case 'refresh-metadata':
|
||||||
|
refreshSingleLoraMetadata(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
case 'set-nsfw':
|
||||||
|
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const response = await fetch('/api/loras/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(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 = `
|
||||||
|
<p>${nsfwText}</p>
|
||||||
|
<button class="show-content-btn">Show</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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 = '<i class="fas fa-eye"></i>';
|
||||||
|
|
||||||
|
// 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
41
static/js/components/ContextMenu/RecipeContextMenu.js
Normal file
41
static/js/components/ContextMenu/RecipeContextMenu.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
|
constructor() {
|
||||||
|
super('recipeContextMenu', '.lora-card');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMenuAction(action) {
|
||||||
|
switch(action) {
|
||||||
|
case 'details':
|
||||||
|
// Show recipe details
|
||||||
|
this.currentCard.click();
|
||||||
|
break;
|
||||||
|
case 'copy':
|
||||||
|
// Copy recipe to clipboard
|
||||||
|
if (window.recipeManager) {
|
||||||
|
window.recipeManager.copyRecipe(this.currentCard.dataset.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'share':
|
||||||
|
// Share recipe
|
||||||
|
if (window.recipeManager) {
|
||||||
|
window.recipeManager.shareRecipe(this.currentCard.dataset.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
// Delete recipe
|
||||||
|
if (this.currentCard.querySelector('.fa-trash')) {
|
||||||
|
this.currentCard.querySelector('.fa-trash').click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'edit':
|
||||||
|
// Edit recipe
|
||||||
|
if (window.recipeManager && window.recipeManager.editRecipe) {
|
||||||
|
window.recipeManager.editRecipe(this.currentCard.dataset.id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
static/js/components/ContextMenu/index.js
Normal file
3
static/js/components/ContextMenu/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { LoraContextMenu } from './LoraContextMenu.js';
|
||||||
|
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
|
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
@@ -6,7 +6,7 @@ import { updateCardsForBulkMode } from './components/LoraCard.js';
|
|||||||
import { bulkManager } from './managers/BulkManager.js';
|
import { bulkManager } from './managers/BulkManager.js';
|
||||||
import { DownloadManager } from './managers/DownloadManager.js';
|
import { DownloadManager } from './managers/DownloadManager.js';
|
||||||
import { moveManager } from './managers/MoveManager.js';
|
import { moveManager } from './managers/MoveManager.js';
|
||||||
import { LoraContextMenu } from './components/ContextMenu.js';
|
import { LoraContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js';
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { RecipeCard } from './components/RecipeCard.js';
|
|||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { getCurrentPageState } from './state/index.js';
|
import { getCurrentPageState } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -37,6 +38,9 @@ class RecipeManager {
|
|||||||
// Set default search options if not already defined
|
// Set default search options if not already defined
|
||||||
this._initSearchOptions();
|
this._initSearchOptions();
|
||||||
|
|
||||||
|
// Initialize context menu
|
||||||
|
new RecipeContextMenu();
|
||||||
|
|
||||||
// Check for custom filter parameters in session storage
|
// Check for custom filter parameters in session storage
|
||||||
this._checkCustomFilter();
|
this._checkCustomFilter();
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,18 @@
|
|||||||
|
|
||||||
{% block additional_components %}
|
{% block additional_components %}
|
||||||
{% include 'components/checkpoint_modals.html' %}
|
{% include 'components/checkpoint_modals.html' %}
|
||||||
|
|
||||||
|
<div id="checkpointContextMenu" class="context-menu" style="display: none;">
|
||||||
|
<div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div>
|
||||||
|
<div class="context-menu-item" data-action="civitai"><i class="fas fa-external-link-alt"></i> View on CivitAI</div>
|
||||||
|
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> Refresh Civitai Data</div>
|
||||||
|
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> Copy Model Filename</div>
|
||||||
|
<div class="context-menu-item" data-action="preview"><i class="fas fa-image"></i> Replace Preview</div>
|
||||||
|
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> Set Content Rating</div>
|
||||||
|
<div class="context-menu-separator"></div>
|
||||||
|
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> Move to Folder</div>
|
||||||
|
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> Delete Model</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|||||||
@@ -16,6 +16,15 @@
|
|||||||
{% block additional_components %}
|
{% block additional_components %}
|
||||||
{% include 'components/import_modal.html' %}
|
{% include 'components/import_modal.html' %}
|
||||||
{% include 'components/recipe_modal.html' %}
|
{% include 'components/recipe_modal.html' %}
|
||||||
|
|
||||||
|
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||||
|
<div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div>
|
||||||
|
<div class="context-menu-item" data-action="edit"><i class="fas fa-edit"></i> Edit Recipe</div>
|
||||||
|
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> Copy Recipe</div>
|
||||||
|
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> Share Recipe</div>
|
||||||
|
<div class="context-menu-separator"></div>
|
||||||
|
<div class="context-menu-item" data-action="delete"><i class="fas fa-trash"></i> Delete Recipe</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block init_title %}Initializing Recipe Manager{% endblock %}
|
{% block init_title %}Initializing Recipe Manager{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user