diff --git a/py/routes/api_routes.py b/py/routes/api_routes.py index 2f21ade2..9c929a0d 100644 --- a/py/routes/api_routes.py +++ b/py/routes/api_routes.py @@ -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) diff --git a/py/routes/checkpoints_routes.py b/py/routes/checkpoints_routes.py index 712b73bd..9bde3002 100644 --- a/py/routes/checkpoints_routes.py +++ b/py/routes/checkpoints_routes.py @@ -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) diff --git a/py/utils/routes_common.py b/py/utils/routes_common.py index f5b7fe3d..4489d51b 100644 --- a/py/utils/routes_common.py +++ b/py/utils/routes_common.py @@ -613,4 +613,68 @@ class ModelRouteUtils: 'success': False, 'error': str(e) }, status=500) - \ No newline at end of file + + @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) diff --git a/static/css/components/bulk.css b/static/css/components/bulk.css index 6800ef26..18f04001 100644 --- a/static/css/components/bulk.css +++ b/static/css/components/bulk.css @@ -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 { diff --git a/static/css/components/menu.css b/static/css/components/menu.css index 819cfe72..a1f8ae8a 100644 --- a/static/css/components/menu.css +++ b/static/css/components/menu.css @@ -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); } \ No newline at end of file diff --git a/static/css/components/modal.css b/static/css/components/modal.css index 78e42b89..9ef6b5a9 100644 --- a/static/css/components/modal.css +++ b/static/css/components/modal.css @@ -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); } \ No newline at end of file diff --git a/static/js/components/ContextMenu.js b/static/js/components/ContextMenu.js deleted file mode 100644 index d45e6216..00000000 --- a/static/js/components/ContextMenu.js +++ /dev/null @@ -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 = ` -

${nsfwText}

- - `; - - // Add click event to the show button - const showBtn = warningContent.querySelector('.show-content-btn'); - showBtn.addEventListener('click', (e) => { - e.stopPropagation(); - previewContainer.classList.remove('blurred'); - nsfwOverlay.style.display = 'none'; - - // Update toggle button icon if it exists - const toggleBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; - } - }); - - nsfwOverlay.appendChild(warningContent); - previewContainer.appendChild(nsfwOverlay); - } else { - // Update existing overlay - const warningText = nsfwOverlay.querySelector('p'); - if (warningText) { - let nsfwText = "Mature Content"; - if (level >= NSFW_LEVELS.XXX) { - nsfwText = "XXX-rated Content"; - } else if (level >= NSFW_LEVELS.X) { - nsfwText = "X-rated Content"; - } else if (level >= NSFW_LEVELS.R) { - nsfwText = "R-rated Content"; - } - warningText.textContent = nsfwText; - } - nsfwOverlay.style.display = 'flex'; - } - - // Get or create the toggle button in the header - const cardHeader = previewContainer.querySelector('.card-header'); - if (cardHeader) { - let toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); - - if (!toggleBtn) { - toggleBtn = document.createElement('button'); - toggleBtn.className = 'toggle-blur-btn'; - toggleBtn.title = 'Toggle blur'; - toggleBtn.innerHTML = ''; - - // Add click event to toggle button - toggleBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const isBlurred = previewContainer.classList.toggle('blurred'); - const icon = toggleBtn.querySelector('i'); - - // Update icon and overlay visibility - if (isBlurred) { - icon.className = 'fas fa-eye'; - nsfwOverlay.style.display = 'flex'; - } else { - icon.className = 'fas fa-eye-slash'; - nsfwOverlay.style.display = 'none'; - } - }); - - // Add to the beginning of header - cardHeader.insertBefore(toggleBtn, cardHeader.firstChild); - - // Update base model label class - const baseModelLabel = cardHeader.querySelector('.base-model-label'); - if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) { - baseModelLabel.classList.add('with-toggle'); - } - } else { - // Update existing toggle button - toggleBtn.querySelector('i').className = 'fas fa-eye'; - } - } - } else { - // Remove blur - previewContainer.classList.remove('blurred'); - - // Hide overlay if it exists - const overlay = previewContainer.querySelector('.nsfw-overlay'); - if (overlay) overlay.style.display = 'none'; - - // Update or remove toggle button - const toggleBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - // We'll leave the button but update the icon - toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; - } - } - } - - showNSFWLevelSelector(x, y, card) { - const selector = document.getElementById('nsfwLevelSelector'); - const currentLevelEl = document.getElementById('currentNSFWLevel'); - - // Get current NSFW level - let currentLevel = 0; - try { - const metaData = JSON.parse(card.dataset.meta || '{}'); - currentLevel = metaData.preview_nsfw_level || 0; - - // Update if we have no recorded level but have a dataset attribute - if (!currentLevel && card.dataset.nsfwLevel) { - currentLevel = parseInt(card.dataset.nsfwLevel) || 0; - } - } catch (err) { - console.error('Error parsing metadata:', err); - } - - currentLevelEl.textContent = getNSFWLevelName(currentLevel); - - // Position the selector - if (x && y) { - const viewportWidth = document.documentElement.clientWidth; - const viewportHeight = document.documentElement.clientHeight; - const selectorRect = selector.getBoundingClientRect(); - - // Center the selector if no coordinates provided - let finalX = (viewportWidth - selectorRect.width) / 2; - let finalY = (viewportHeight - selectorRect.height) / 2; - - selector.style.left = `${finalX}px`; - selector.style.top = `${finalY}px`; - } - - // Highlight current level button - document.querySelectorAll('.nsfw-level-btn').forEach(btn => { - if (parseInt(btn.dataset.level) === currentLevel) { - btn.classList.add('active'); - } else { - btn.classList.remove('active'); - } - }); - - // Store reference to current card - selector.dataset.cardPath = card.dataset.filepath; - - // Show selector - selector.style.display = 'block'; - } - - showMenu(x, y, card) { - 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'; \ No newline at end of file diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js index 0945901f..3ed3af0f 100644 --- a/static/js/components/ContextMenu/CheckpointContextMenu.js +++ b/static/js/components/ContextMenu/CheckpointContextMenu.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; + } + } } \ No newline at end of file diff --git a/static/js/components/ContextMenu/LoraContextMenu.js b/static/js/components/ContextMenu/LoraContextMenu.js index c2cdbc8e..540b6c7f 100644 --- a/static/js/components/ContextMenu/LoraContextMenu.js +++ b/static/js/components/ContextMenu/LoraContextMenu.js @@ -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 diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index d4ed36af..2785d558 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -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; } diff --git a/templates/checkpoints.html b/templates/checkpoints.html index e8ebce2d..547bd09b 100644 --- a/templates/checkpoints.html +++ b/templates/checkpoints.html @@ -18,6 +18,7 @@
View on CivitAI
Refresh Civitai Data
+
Re-link to Civitai
Copy Model Filename
Open Examples Folder
Replace Preview
diff --git a/templates/components/context_menu.html b/templates/components/context_menu.html index 8f600a4d..8c74758d 100644 --- a/templates/components/context_menu.html +++ b/templates/components/context_menu.html @@ -8,6 +8,9 @@
Refresh Civitai Data
+
+ Re-link to Civitai +
Copy LoRA Syntax
diff --git a/templates/components/modals.html b/templates/components/modals.html index 69fee159..c9600ab1 100644 --- a/templates/components/modals.html +++ b/templates/components/modals.html @@ -571,4 +571,34 @@ + + + + \ No newline at end of file