mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
Add CivitAI re-linking functionality and related UI components. Fixes #216
- Implemented new API endpoints for re-linking models to CivitAI. - Added context menu options for re-linking in both Lora and Checkpoint context menus. - Created a modal for user confirmation and input for CivitAI model URL. - Updated styles for the new modal and context menu items. - Enhanced error handling and user feedback during the re-linking process.
This commit is contained in:
@@ -45,6 +45,7 @@ class ApiRoutes:
|
||||
app.router.add_post('/api/delete_model', routes.delete_model)
|
||||
app.router.add_post('/api/loras/exclude', routes.exclude_model) # Add new exclude endpoint
|
||||
app.router.add_post('/api/fetch-civitai', routes.fetch_civitai)
|
||||
app.router.add_post('/api/relink-civitai', routes.relink_civitai) # Add new relink endpoint
|
||||
app.router.add_post('/api/replace_preview', routes.replace_preview)
|
||||
app.router.add_get('/api/loras', routes.get_loras)
|
||||
app.router.add_post('/api/fetch-all-civitai', routes.fetch_all_civitai)
|
||||
@@ -1285,3 +1286,9 @@ class ApiRoutes:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def relink_civitai(self, request: web.Request) -> web.Response:
|
||||
"""Handle CivitAI metadata re-linking request by model version ID for LoRAs"""
|
||||
if self.scanner is None:
|
||||
self.scanner = await ServiceRegistry.get_lora_scanner()
|
||||
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)
|
||||
|
||||
@@ -51,6 +51,7 @@ class CheckpointsRoutes:
|
||||
app.router.add_post('/api/checkpoints/delete', self.delete_model)
|
||||
app.router.add_post('/api/checkpoints/exclude', self.exclude_model) # Add new exclude endpoint
|
||||
app.router.add_post('/api/checkpoints/fetch-civitai', self.fetch_civitai)
|
||||
app.router.add_post('/api/checkpoints/relink-civitai', self.relink_civitai) # Add new relink endpoint
|
||||
app.router.add_post('/api/checkpoints/replace-preview', self.replace_preview)
|
||||
app.router.add_post('/api/checkpoints/download', self.download_checkpoint)
|
||||
app.router.add_post('/api/checkpoints/save-metadata', self.save_metadata) # Add new route
|
||||
@@ -811,3 +812,7 @@ class CheckpointsRoutes:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
async def relink_civitai(self, request: web.Request) -> web.Response:
|
||||
"""Handle CivitAI metadata re-linking request by model version ID for checkpoints"""
|
||||
return await ModelRouteUtils.handle_relink_civitai(request, self.scanner)
|
||||
|
||||
@@ -613,4 +613,68 @@ class ModelRouteUtils:
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def handle_relink_civitai(request: web.Request, scanner) -> web.Response:
|
||||
"""Handle CivitAI metadata re-linking request by model version ID
|
||||
|
||||
Args:
|
||||
request: The aiohttp request
|
||||
scanner: The model scanner instance with cache management methods
|
||||
|
||||
Returns:
|
||||
web.Response: The HTTP response
|
||||
"""
|
||||
try:
|
||||
data = await request.json()
|
||||
file_path = data.get('file_path')
|
||||
model_version_id = data.get('model_version_id')
|
||||
|
||||
if not file_path or not model_version_id:
|
||||
return web.json_response({"success": False, "error": "Both file_path and model_version_id are required"}, status=400)
|
||||
|
||||
metadata_path = os.path.splitext(file_path)[0] + '.metadata.json'
|
||||
|
||||
# Check if model metadata exists
|
||||
local_metadata = await ModelRouteUtils.load_local_metadata(metadata_path)
|
||||
|
||||
# Create a client for fetching from Civitai
|
||||
client = await CivitaiClient.get_instance()
|
||||
try:
|
||||
# Fetch metadata by model version ID
|
||||
civitai_metadata, error = await client.get_model_version_info(model_version_id)
|
||||
if not civitai_metadata:
|
||||
error_msg = error or "Model version not found on CivitAI"
|
||||
return web.json_response({"success": False, "error": error_msg}, status=404)
|
||||
|
||||
# Find the primary model file to get the correct SHA256 hash
|
||||
primary_model_file = None
|
||||
for file in civitai_metadata.get('files', []):
|
||||
if file.get('primary', False) and file.get('type') == 'Model':
|
||||
primary_model_file = file
|
||||
break
|
||||
|
||||
if not primary_model_file or not primary_model_file.get('hashes', {}).get('SHA256'):
|
||||
return web.json_response({"success": False, "error": "No SHA256 hash found in model metadata"}, status=404)
|
||||
|
||||
# Update the SHA256 hash in local metadata (convert to lowercase)
|
||||
local_metadata['sha256'] = primary_model_file['hashes']['SHA256'].lower()
|
||||
|
||||
# Update metadata with CivitAI information
|
||||
await ModelRouteUtils.update_model_metadata(metadata_path, local_metadata, civitai_metadata, client)
|
||||
|
||||
# Update the cache
|
||||
await scanner.update_single_model_cache(file_path, file_path, local_metadata)
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"message": f"Model successfully re-linked to Civitai version {model_version_id}",
|
||||
"hash": local_metadata['sha256']
|
||||
})
|
||||
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error re-linking to CivitAI: {e}", exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
@@ -274,83 +274,6 @@
|
||||
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 {
|
||||
|
||||
@@ -39,4 +39,81 @@
|
||||
.context-menu-item i {
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
@@ -110,7 +110,7 @@ body.modal-open {
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.cancel-btn, .delete-btn, .exclude-btn {
|
||||
.cancel-btn, .delete-btn, .exclude-btn, .confirm-btn {
|
||||
padding: 8px var(--space-2);
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
@@ -131,7 +131,7 @@ body.modal-open {
|
||||
}
|
||||
|
||||
/* Style for exclude button - different from delete button */
|
||||
.exclude-btn {
|
||||
.exclude-btn, .confirm-btn {
|
||||
background: var(--lora-accent, #4f46e5);
|
||||
color: white;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ body.modal-open {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.exclude-btn:hover {
|
||||
.exclude-btn:hover, .confirm-btn:hover {
|
||||
opacity: 0.9;
|
||||
background: oklch(from var(--lora-accent, #4f46e5) l c h / 85%);
|
||||
}
|
||||
@@ -918,4 +918,58 @@ input:checked + .toggle-slider:before {
|
||||
/* Dark theme adjustments */
|
||||
[data-theme="dark"] .update-date-badge {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Re-link to Civitai Modal styles */
|
||||
.warning-box {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.5);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--space-2);
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.warning-box i {
|
||||
color: var(--lora-warning);
|
||||
margin-right: var(--space-1);
|
||||
}
|
||||
|
||||
.warning-box ul {
|
||||
padding-left: 20px;
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.warning-box li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
margin-bottom: var(--space-1);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.input-error {
|
||||
color: var(--lora-error);
|
||||
font-size: 0.9em;
|
||||
min-height: 20px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .warning-box {
|
||||
background-color: rgba(255, 193, 7, 0.05);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
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 {
|
||||
constructor() {
|
||||
this.menu = document.getElementById('loraContextMenu');
|
||||
this.currentCard = null;
|
||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('click', () => this.hideMenu());
|
||||
document.addEventListener('contextmenu', (e) => {
|
||||
const card = e.target.closest('.lora-card');
|
||||
if (!card) {
|
||||
this.hideMenu();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.showMenu(e.clientX, e.clientY, card);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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('/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';
|
||||
|
||||
// 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) {
|
||||
this.currentCard = card;
|
||||
this.menu.style.display = 'block';
|
||||
|
||||
// 获取菜单尺寸
|
||||
const menuRect = this.menu.getBoundingClientRect();
|
||||
|
||||
// 获取视口尺寸
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
|
||||
// 计算最终位置 - 使用 clientX/Y,不需要考虑滚动偏移
|
||||
let finalX = x;
|
||||
let finalY = y;
|
||||
|
||||
// 确保菜单不会超出右侧边界
|
||||
if (x + menuRect.width > viewportWidth) {
|
||||
finalX = x - menuRect.width;
|
||||
}
|
||||
|
||||
// 确保菜单不会超出底部边界
|
||||
if (y + menuRect.height > viewportHeight) {
|
||||
finalY = y - menuRect.height;
|
||||
}
|
||||
|
||||
// 直接设置位置,因为 position: fixed 是相对于视口定位的
|
||||
this.menu.style.left = `${finalX}px`;
|
||||
this.menu.style.top = `${finalY}px`;
|
||||
}
|
||||
|
||||
hideMenu() {
|
||||
this.menu.style.display = 'none';
|
||||
this.currentCard = null;
|
||||
}
|
||||
}
|
||||
|
||||
// For backward compatibility, re-export the LoraContextMenu class
|
||||
// export { LoraContextMenu } from './ContextMenu/LoraContextMenu.js';
|
||||
@@ -4,6 +4,9 @@ import { showToast, getNSFWLevelName, openExampleImagesFolder } from '../../util
|
||||
import { NSFW_LEVELS } from '../../utils/constants.js';
|
||||
import { getStorageItem } from '../../utils/storageHelpers.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() {
|
||||
@@ -56,6 +59,10 @@ 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);
|
||||
@@ -319,4 +326,87 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { refreshSingleLoraMetadata, saveModelMetadata, replacePreview } from '../../api/loraApi.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 { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { state } from '../../state/index.js';
|
||||
|
||||
export class LoraContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -64,6 +66,9 @@ 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;
|
||||
@@ -93,6 +98,90 @@ 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
|
||||
|
||||
@@ -221,6 +221,19 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add relinkCivitaiModal registration
|
||||
const relinkCivitaiModal = document.getElementById('relinkCivitaiModal');
|
||||
if (relinkCivitaiModal) {
|
||||
this.registerModal('relinkCivitaiModal', {
|
||||
element: relinkCivitaiModal,
|
||||
onClose: () => {
|
||||
this.getModal('relinkCivitaiModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.boundHandleEscape);
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<!-- <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="relink-civitai"><i class="fas fa-link"></i> Re-link to Civitai</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-folder-open"></i> Open Examples Folder</div>
|
||||
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> Replace Preview</div>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<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="relink-civitai">
|
||||
<i class="fas fa-link"></i> Re-link to Civitai
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="copyname">
|
||||
<i class="fas fa-copy"></i> Copy LoRA Syntax
|
||||
</div>
|
||||
|
||||
@@ -571,4 +571,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Re-link to Civitai Modal -->
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<button class="close" onclick="modalManager.closeModal('relinkCivitaiModal')">×</button>
|
||||
<h2>Re-link to Civitai</h2>
|
||||
<div class="warning-box">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<p><strong>Warning:</strong> This is a potentially destructive operation. Re-linking will:</p>
|
||||
<ul>
|
||||
<li>Override existing metadata</li>
|
||||
<li>Potentially modify the model hash</li>
|
||||
<li>May have other unintended consequences</li>
|
||||
</ul>
|
||||
<p>Only proceed if you're sure this is what you want.</p>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="civitaiModelUrl">Civitai Model URL:</label>
|
||||
<input type="text" id="civitaiModelUrl" placeholder="https://civitai.com/models/1098030?modelVersionId=1233411" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<div class="input-help">
|
||||
The URL must include the modelVersionId parameter.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="modalManager.closeModal('relinkCivitaiModal')">Cancel</button>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn">Confirm Re-link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user