diff --git a/static/js/components/CheckpointCard.js b/static/js/components/CheckpointCard.js index 66e2c18b..3a2292c9 100644 --- a/static/js/components/CheckpointCard.js +++ b/static/js/components/CheckpointCard.js @@ -1,334 +1,14 @@ -import { showToast, copyToClipboard, openExampleImagesFolder, openCivitai } from '../utils/uiHelpers.js'; -import { state } from '../state/index.js'; -import { showCheckpointModal } from './checkpointModal/index.js'; -import { NSFW_LEVELS } from '../utils/constants.js'; -import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata } from '../api/checkpointApi.js'; -import { showDeleteModal } from '../utils/modalUtils.js'; - -// Add a global event delegation handler -export function setupCheckpointCardEventDelegation() { - const gridElement = document.getElementById('checkpointGrid'); - if (!gridElement) return; - - // Remove any existing event listener to prevent duplication - gridElement.removeEventListener('click', handleCheckpointCardEvent); - - // Add the event delegation handler - gridElement.addEventListener('click', handleCheckpointCardEvent); -} - -// Event delegation handler for all checkpoint card events -function handleCheckpointCardEvent(event) { - // Find the closest card element - const card = event.target.closest('.lora-card'); - if (!card) return; - - // Handle specific elements within the card - if (event.target.closest('.toggle-blur-btn')) { - event.stopPropagation(); - toggleBlurContent(card); - return; - } - - if (event.target.closest('.show-content-btn')) { - event.stopPropagation(); - showBlurredContent(card); - return; - } - - if (event.target.closest('.fa-star')) { - event.stopPropagation(); - toggleFavorite(card); - return; - } - - if (event.target.closest('.fa-globe')) { - event.stopPropagation(); - if (card.dataset.from_civitai === 'true') { - openCivitai(card.dataset.filepath); - } - return; - } - - if (event.target.closest('.fa-copy')) { - event.stopPropagation(); - copyCheckpointName(card); - return; - } - - if (event.target.closest('.fa-trash')) { - event.stopPropagation(); - showDeleteModal(card.dataset.filepath); - return; - } - - if (event.target.closest('.fa-image')) { - event.stopPropagation(); - replaceCheckpointPreview(card.dataset.filepath); - return; - } - - if (event.target.closest('.fa-folder-open')) { - event.stopPropagation(); - openExampleImagesFolder(card.dataset.sha256); - return; - } - - // If no specific element was clicked, handle the card click (show modal) - showCheckpointModalFromCard(card); -} - -// Helper functions for event handling -function toggleBlurContent(card) { - const preview = card.querySelector('.card-preview'); - const isBlurred = preview.classList.toggle('blurred'); - const icon = card.querySelector('.toggle-blur-btn i'); - - // Update the icon based on blur state - if (isBlurred) { - icon.className = 'fas fa-eye'; - } else { - icon.className = 'fas fa-eye-slash'; - } - - // Toggle the overlay visibility - const overlay = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = isBlurred ? 'flex' : 'none'; - } -} - -function showBlurredContent(card) { - const preview = card.querySelector('.card-preview'); - preview.classList.remove('blurred'); - - // Update the toggle button icon - const toggleBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; - } - - // Hide the overlay - const overlay = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = 'none'; - } -} - -async function toggleFavorite(card) { - const starIcon = card.querySelector('.fa-star'); - const isFavorite = starIcon.classList.contains('fas'); - const newFavoriteState = !isFavorite; - - try { - // Save the new favorite state to the server - await saveModelMetadata(card.dataset.filepath, { - favorite: newFavoriteState - }); - - if (newFavoriteState) { - showToast('Added to favorites', 'success'); - } else { - showToast('Removed from favorites', 'success'); - } - } catch (error) { - console.error('Failed to update favorite status:', error); - showToast('Failed to update favorite status', 'error'); - } -} - -async function copyCheckpointName(card) { - const checkpointName = card.dataset.file_name; - - try { - await copyToClipboard(checkpointName, 'Checkpoint name copied'); - } catch (err) { - console.error('Copy failed:', err); - showToast('Copy failed', 'error'); - } -} - -function showCheckpointModalFromCard(card) { - // Get the page-specific previewVersions map - const previewVersions = state.pages.checkpoints.previewVersions || new Map(); - const version = previewVersions.get(card.dataset.filepath); - const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png'; - const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl; - - // Show checkpoint details modal - const checkpointMeta = { - sha256: card.dataset.sha256, - file_path: card.dataset.filepath, - model_name: card.dataset.name, - file_name: card.dataset.file_name, - folder: card.dataset.folder, - modified: card.dataset.modified, - file_size: parseInt(card.dataset.file_size || '0'), - from_civitai: card.dataset.from_civitai === 'true', - base_model: card.dataset.base_model, - notes: card.dataset.notes || '', - preview_url: versionedPreviewUrl, - // Parse civitai metadata from the card's dataset - civitai: (() => { - try { - return JSON.parse(card.dataset.meta || '{}'); - } catch (e) { - console.error('Failed to parse civitai metadata:', e); - return {}; // Return empty object on error - } - })(), - tags: (() => { - try { - return JSON.parse(card.dataset.tags || '[]'); - } catch (e) { - console.error('Failed to parse tags:', e); - return []; // Return empty array on error - } - })(), - modelDescription: card.dataset.modelDescription || '' - }; - showCheckpointModal(checkpointMeta); -} - -function replaceCheckpointPreview(filePath) { - if (window.replaceCheckpointPreview) { - window.replaceCheckpointPreview(filePath); - } else { - apiReplaceCheckpointPreview(filePath); - } -} +// Legacy CheckpointCard.js - now using shared ModelCard component +import { + createModelCard, + setupModelCardEventDelegation +} from './shared/ModelCard.js'; +// Re-export functions with original names for backwards compatibility export function createCheckpointCard(checkpoint) { - const card = document.createElement('div'); - card.className = 'lora-card'; // Reuse the same class for styling - card.dataset.sha256 = checkpoint.sha256; - card.dataset.filepath = checkpoint.file_path; - card.dataset.name = checkpoint.model_name; - card.dataset.file_name = checkpoint.file_name; - card.dataset.folder = checkpoint.folder; - card.dataset.modified = checkpoint.modified; - card.dataset.file_size = checkpoint.file_size; - card.dataset.from_civitai = checkpoint.from_civitai; - card.dataset.notes = checkpoint.notes || ''; - card.dataset.base_model = checkpoint.base_model || 'Unknown'; - card.dataset.favorite = checkpoint.favorite ? 'true' : 'false'; + return createModelCard(checkpoint, 'checkpoint'); +} - // Store metadata if available - if (checkpoint.civitai) { - card.dataset.meta = JSON.stringify(checkpoint.civitai || {}); - } - - // Store tags if available - if (checkpoint.tags && Array.isArray(checkpoint.tags)) { - card.dataset.tags = JSON.stringify(checkpoint.tags); - } - - if (checkpoint.modelDescription) { - card.dataset.modelDescription = checkpoint.modelDescription; - } - - // Store NSFW level if available - const nsfwLevel = checkpoint.preview_nsfw_level !== undefined ? checkpoint.preview_nsfw_level : 0; - card.dataset.nsfwLevel = nsfwLevel; - - // Determine if the preview should be blurred based on NSFW level and user settings - const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; - if (shouldBlur) { - card.classList.add('nsfw-content'); - } - - // Determine preview URL - const previewUrl = checkpoint.preview_url || '/loras_static/images/no-preview.png'; - - // Get the page-specific previewVersions map - const previewVersions = state.pages.checkpoints.previewVersions || new Map(); - const version = previewVersions.get(checkpoint.file_path); - const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl; - - // Determine NSFW warning text based on level - let nsfwText = "Mature Content"; - if (nsfwLevel >= NSFW_LEVELS.XXX) { - nsfwText = "XXX-rated Content"; - } else if (nsfwLevel >= NSFW_LEVELS.X) { - nsfwText = "X-rated Content"; - } else if (nsfwLevel >= NSFW_LEVELS.R) { - nsfwText = "R-rated Content"; - } - - // Check if autoplayOnHover is enabled for video previews - const autoplayOnHover = state.global?.settings?.autoplayOnHover || false; - const isVideo = previewUrl.endsWith('.mp4'); - const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop'; - - // Get favorite status from checkpoint data - const isFavorite = checkpoint.favorite === true; - - card.innerHTML = ` -
- ${isVideo ? - `` : - `${checkpoint.model_name}` - } -
- ${shouldBlur ? - `` : ''} - - ${checkpoint.base_model} - -
- - - - - - - - -
-
- ${shouldBlur ? ` -
-
-

${nsfwText}

- -
-
- ` : ''} - -
- `; - - // Add video auto-play on hover functionality if needed - const videoElement = card.querySelector('video'); - if (videoElement && autoplayOnHover) { - const cardPreview = card.querySelector('.card-preview'); - - // Remove autoplay attribute and pause initially - videoElement.removeAttribute('autoplay'); - videoElement.pause(); - - // Add mouse events to trigger play/pause using event attributes - cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()'); - cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}'); - } - - return card; +export function setupCheckpointCardEventDelegation() { + setupModelCardEventDelegation('checkpoint'); } \ No newline at end of file diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index fa83e0c6..e200724f 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -1,508 +1,17 @@ -import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../utils/uiHelpers.js'; -import { state, getCurrentPageState } from '../state/index.js'; -import { showLoraModal } from './loraModal/index.js'; -import { bulkManager } from '../managers/BulkManager.js'; -import { NSFW_LEVELS } from '../utils/constants.js'; -import { replacePreview, saveModelMetadata } from '../api/loraApi.js' - -// Add a global event delegation handler -export function setupLoraCardEventDelegation() { - const gridElement = document.getElementById('loraGrid'); - if (!gridElement) return; - - // Remove any existing event listener to prevent duplication - gridElement.removeEventListener('click', handleLoraCardEvent); - - // Add the event delegation handler - gridElement.addEventListener('click', handleLoraCardEvent); -} - -// Event delegation handler for all lora card events -function handleLoraCardEvent(event) { - // Find the closest card element - const card = event.target.closest('.lora-card'); - if (!card) return; - - // Handle specific elements within the card - if (event.target.closest('.toggle-blur-btn')) { - event.stopPropagation(); - toggleBlurContent(card); - return; - } - - if (event.target.closest('.show-content-btn')) { - event.stopPropagation(); - showBlurredContent(card); - return; - } - - if (event.target.closest('.fa-star')) { - event.stopPropagation(); - toggleFavorite(card); - return; - } - - if (event.target.closest('.fa-globe')) { - event.stopPropagation(); - if (card.dataset.from_civitai === 'true') { - openCivitai(card.dataset.filepath); - } - return; - } - - if (event.target.closest('.fa-paper-plane')) { - event.stopPropagation(); - sendLoraToComfyUI(card, event.shiftKey); - return; - } - - if (event.target.closest('.fa-copy')) { - event.stopPropagation(); - copyLoraSyntax(card); - return; - } - - if (event.target.closest('.fa-image')) { - event.stopPropagation(); - replacePreview(card.dataset.filepath); - return; - } - - if (event.target.closest('.fa-folder-open')) { - event.stopPropagation(); - handleExampleImagesAccess(card); - return; - } - - // If no specific element was clicked, handle the card click (show modal or toggle selection) - const pageState = getCurrentPageState(); - if (state.bulkMode) { - // Toggle selection using the bulk manager - bulkManager.toggleCardSelection(card); - } else if (pageState && pageState.duplicatesMode) { - // In duplicates mode, don't open modal when clicking cards - return; - } else { - // Normal behavior - show modal - const loraMeta = { - sha256: card.dataset.sha256, - file_path: card.dataset.filepath, - model_name: card.dataset.name, - file_name: card.dataset.file_name, - folder: card.dataset.folder, - modified: card.dataset.modified, - file_size: card.dataset.file_size, - from_civitai: card.dataset.from_civitai === 'true', - base_model: card.dataset.base_model, - usage_tips: card.dataset.usage_tips, - notes: card.dataset.notes, - favorite: card.dataset.favorite === 'true', - // Parse civitai metadata from the card's dataset - civitai: (() => { - try { - // Attempt to parse the JSON string - return JSON.parse(card.dataset.meta || '{}'); - } catch (e) { - console.error('Failed to parse civitai metadata:', e); - return {}; // Return empty object on error - } - })(), - tags: JSON.parse(card.dataset.tags || '[]'), - modelDescription: card.dataset.modelDescription || '' - }; - showLoraModal(loraMeta); - } -} - -// Helper functions for event handling -function toggleBlurContent(card) { - const preview = card.querySelector('.card-preview'); - const isBlurred = preview.classList.toggle('blurred'); - const icon = card.querySelector('.toggle-blur-btn i'); - - // Update the icon based on blur state - if (isBlurred) { - icon.className = 'fas fa-eye'; - } else { - icon.className = 'fas fa-eye-slash'; - } - - // Toggle the overlay visibility - const overlay = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = isBlurred ? 'flex' : 'none'; - } -} - -function showBlurredContent(card) { - const preview = card.querySelector('.card-preview'); - preview.classList.remove('blurred'); - - // Update the toggle button icon - const toggleBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; - } - - // Hide the overlay - const overlay = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = 'none'; - } -} - -async function toggleFavorite(card) { - const starIcon = card.querySelector('.fa-star'); - const isFavorite = starIcon.classList.contains('fas'); - const newFavoriteState = !isFavorite; - - try { - // Save the new favorite state to the server - await saveModelMetadata(card.dataset.filepath, { - favorite: newFavoriteState - }); - - if (newFavoriteState) { - showToast('Added to favorites', 'success'); - } else { - showToast('Removed from favorites', 'success'); - } - } catch (error) { - console.error('Failed to update favorite status:', error); - showToast('Failed to update favorite status', 'error'); - } -} - -// Function to send LoRA to ComfyUI workflow -async function sendLoraToComfyUI(card, replaceMode) { - const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); - const strength = usageTips.strength || 1; - const loraSyntax = ``; - - sendLoraToWorkflow(loraSyntax, replaceMode, 'lora'); -} - -// Add function to copy lora syntax -function copyLoraSyntax(card) { - const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); - const strength = usageTips.strength || 1; - const loraSyntax = ``; - - copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard'); -} - -// New function to handle example images access -async function handleExampleImagesAccess(card) { - const modelHash = card.dataset.sha256; - - try { - // Check if example images exist - const response = await fetch(`/api/has-example-images?model_hash=${modelHash}`); - const data = await response.json(); - - if (data.has_images) { - // If images exist, open the folder directly (existing behavior) - openExampleImagesFolder(modelHash); - } else { - // If no images exist, show the new modal - showExampleAccessModal(card); - } - } catch (error) { - console.error('Error checking for example images:', error); - showToast('Error checking for example images', 'error'); - } -} - -// Function to show the example access modal -function showExampleAccessModal(card) { - const modal = document.getElementById('exampleAccessModal'); - if (!modal) return; - - // Get download button and determine if download should be enabled - const downloadBtn = modal.querySelector('#downloadExamplesBtn'); - let hasRemoteExamples = false; - - try { - const metaData = JSON.parse(card.dataset.meta || '{}'); - hasRemoteExamples = metaData.images && - Array.isArray(metaData.images) && - metaData.images.length > 0 && - metaData.images[0].url; - } catch (e) { - console.error('Error parsing meta data:', e); - } - - // Enable or disable download button - if (downloadBtn) { - if (hasRemoteExamples) { - downloadBtn.classList.remove('disabled'); - downloadBtn.removeAttribute('title'); // Remove any previous tooltip - downloadBtn.onclick = () => { - modalManager.closeModal('exampleAccessModal'); - // Open settings modal and scroll to example images section - const settingsModal = document.getElementById('settingsModal'); - if (settingsModal) { - modalManager.showModal('settingsModal'); - // Scroll to example images section after modal is visible - setTimeout(() => { - const exampleSection = settingsModal.querySelector('.settings-section:nth-child(5)'); // Example Images section - if (exampleSection) { - exampleSection.scrollIntoView({ behavior: 'smooth' }); - } - }, 300); - } - }; - } else { - downloadBtn.classList.add('disabled'); - downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai'); - downloadBtn.onclick = null; - } - } - - // Set up import button - const importBtn = modal.querySelector('#importExamplesBtn'); - if (importBtn) { - importBtn.onclick = () => { - modalManager.closeModal('exampleAccessModal'); - - // Get the lora data from card dataset - const loraMeta = { - sha256: card.dataset.sha256, - file_path: card.dataset.filepath, - model_name: card.dataset.name, - file_name: card.dataset.file_name, - // Other properties needed for showLoraModal - folder: card.dataset.folder, - modified: card.dataset.modified, - file_size: card.dataset.file_size, - from_civitai: card.dataset.from_civitai === 'true', - base_model: card.dataset.base_model, - usage_tips: card.dataset.usage_tips, - notes: card.dataset.notes, - favorite: card.dataset.favorite === 'true', - civitai: (() => { - try { - return JSON.parse(card.dataset.meta || '{}'); - } catch (e) { - return {}; - } - })(), - tags: JSON.parse(card.dataset.tags || '[]'), - modelDescription: card.dataset.modelDescription || '' - }; - - // Show the lora modal - showLoraModal(loraMeta); - - // Scroll to import area after modal is visible - setTimeout(() => { - const importArea = document.querySelector('.example-import-area'); - if (importArea) { - const showcaseTab = document.getElementById('showcase-tab'); - if (showcaseTab) { - // First make sure showcase tab is visible - const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]'); - if (tabBtn && !tabBtn.classList.contains('active')) { - tabBtn.click(); - } - - // Then toggle showcase if collapsed - const carousel = showcaseTab.querySelector('.carousel'); - if (carousel && carousel.classList.contains('collapsed')) { - const scrollIndicator = showcaseTab.querySelector('.scroll-indicator'); - if (scrollIndicator) { - scrollIndicator.click(); - } - } - - // Finally scroll to the import area - importArea.scrollIntoView({ behavior: 'smooth' }); - } - } - }, 500); - }; - } - - // Show the modal - modalManager.showModal('exampleAccessModal'); -} +// Legacy LoraCard.js - now using shared ModelCard component +import { + createModelCard, + setupModelCardEventDelegation, + updateCardsForBulkMode +} from './shared/ModelCard.js'; +// Re-export functions with original names for backwards compatibility export function createLoraCard(lora) { - const card = document.createElement('div'); - card.className = 'lora-card'; - card.dataset.sha256 = lora.sha256; - card.dataset.filepath = lora.file_path; - card.dataset.name = lora.model_name; - card.dataset.file_name = lora.file_name; - card.dataset.folder = lora.folder; - card.dataset.modified = lora.modified; - card.dataset.file_size = lora.file_size; - card.dataset.from_civitai = lora.from_civitai; - card.dataset.base_model = lora.base_model; - card.dataset.usage_tips = lora.usage_tips; - card.dataset.notes = lora.notes; - card.dataset.meta = JSON.stringify(lora.civitai || {}); - card.dataset.favorite = lora.favorite ? 'true' : 'false'; - - // Store tags and model description - if (lora.tags && Array.isArray(lora.tags)) { - card.dataset.tags = JSON.stringify(lora.tags); - } - if (lora.modelDescription) { - card.dataset.modelDescription = lora.modelDescription; - } - - // Store NSFW level if available - const nsfwLevel = lora.preview_nsfw_level !== undefined ? lora.preview_nsfw_level : 0; - card.dataset.nsfwLevel = nsfwLevel; - - // Determine if the preview should be blurred based on NSFW level and user settings - const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; - if (shouldBlur) { - card.classList.add('nsfw-content'); - } - - // Apply selection state if in bulk mode and this card is in the selected set - if (state.bulkMode && state.selectedLoras.has(lora.file_path)) { - card.classList.add('selected'); - } - - // Get the page-specific previewVersions map - const previewVersions = state.pages.loras.previewVersions || new Map(); - const version = previewVersions.get(lora.file_path); - const previewUrl = lora.preview_url || '/loras_static/images/no-preview.png'; - const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl; - - // Determine NSFW warning text based on level - let nsfwText = "Mature Content"; - if (nsfwLevel >= NSFW_LEVELS.XXX) { - nsfwText = "XXX-rated Content"; - } else if (nsfwLevel >= NSFW_LEVELS.X) { - nsfwText = "X-rated Content"; - } else if (nsfwLevel >= NSFW_LEVELS.R) { - nsfwText = "R-rated Content"; - } - - // Check if autoplayOnHover is enabled for video previews - const autoplayOnHover = state.global.settings.autoplayOnHover || false; - const isVideo = previewUrl.endsWith('.mp4'); - const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop'; - - // Get favorite status from the lora data - const isFavorite = lora.favorite === true; - - card.innerHTML = ` -
- ${isVideo ? - `` : - `${lora.model_name}` - } -
- ${shouldBlur ? - `` : ''} - - ${lora.base_model} - -
- - - - - - - - -
-
- ${shouldBlur ? ` -
-
-

${nsfwText}

- -
-
- ` : ''} - -
- `; - - // Add a special class for virtual scroll positioning if needed - if (state.virtualScroller) { - card.classList.add('virtual-scroll-item'); - } - - // Add video auto-play on hover functionality if needed - const videoElement = card.querySelector('video'); - if (videoElement && autoplayOnHover) { - const cardPreview = card.querySelector('.card-preview'); - - // Remove autoplay attribute and pause initially - videoElement.removeAttribute('autoplay'); - videoElement.pause(); - - // Add mouse events to trigger play/pause using event attributes - // This approach reduces the number of event listeners created - cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()'); - cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}'); - } - - return card; + return createModelCard(lora, 'lora'); } -// Add a method to update card appearance based on bulk mode -export function updateCardsForBulkMode(isBulkMode) { - // Update the state - state.bulkMode = isBulkMode; - - document.body.classList.toggle('bulk-mode', isBulkMode); - - // Get all lora cards - this can now be from the DOM or through the virtual scroller - const loraCards = document.querySelectorAll('.lora-card'); - - loraCards.forEach(card => { - // Get all action containers for this card - const actions = card.querySelectorAll('.card-actions'); - - // Handle display property based on mode - if (isBulkMode) { - // Hide actions when entering bulk mode - actions.forEach(actionGroup => { - actionGroup.style.display = 'none'; - }); - } else { - // Ensure actions are visible when exiting bulk mode - actions.forEach(actionGroup => { - // We need to reset to default display style which is flex - actionGroup.style.display = 'flex'; - }); - } - }); - - // If using virtual scroller, we need to rerender after toggling bulk mode - if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') { - state.virtualScroller.scheduleRender(); - } - - // Apply selection state to cards if entering bulk mode - if (isBulkMode) { - bulkManager.applySelectionState(); - } -} \ No newline at end of file +export function setupLoraCardEventDelegation() { + setupModelCardEventDelegation('lora'); +} + +export { updateCardsForBulkMode }; \ No newline at end of file diff --git a/static/js/components/ModelDuplicatesManager.js b/static/js/components/ModelDuplicatesManager.js index 04cfcc9f..13725a86 100644 --- a/static/js/components/ModelDuplicatesManager.js +++ b/static/js/components/ModelDuplicatesManager.js @@ -184,7 +184,7 @@ export class ModelDuplicatesManager { document.body.classList.remove('duplicate-mode'); // Clear the model grid first - const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid'); + const modelGrid = document.getElementById('modelGrid'); if (modelGrid) { modelGrid.innerHTML = ''; } @@ -241,7 +241,7 @@ export class ModelDuplicatesManager { } renderDuplicateGroups() { - const modelGrid = document.getElementById(this.modelType === 'loras' ? 'loraGrid' : 'checkpointGrid'); + const modelGrid = document.getElementById('modelGrid'); if (!modelGrid) return; // Clear existing content diff --git a/static/js/components/checkpointModal/ModelMetadata.js b/static/js/components/checkpointModal/ModelMetadata.js deleted file mode 100644 index 2db29a74..00000000 --- a/static/js/components/checkpointModal/ModelMetadata.js +++ /dev/null @@ -1,436 +0,0 @@ -/** - * ModelMetadata.js - * Handles checkpoint model metadata editing functionality - */ -import { showToast } from '../../utils/uiHelpers.js'; -import { BASE_MODELS } from '../../utils/constants.js'; -import { state } from '../../state/index.js'; -import { saveModelMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; - -/** - * Set up model name editing functionality - * @param {string} filePath - The full file path of the model. - */ -export function setupModelNameEditing(filePath) { - const modelNameContent = document.querySelector('.model-name-content'); - const editBtn = document.querySelector('.edit-model-name-btn'); - - if (!modelNameContent || !editBtn) return; - - // Store the file path in a data attribute for later use - modelNameContent.dataset.filePath = filePath; - - // Show edit button on hover - const modelNameHeader = document.querySelector('.model-name-header'); - modelNameHeader.addEventListener('mouseenter', () => { - editBtn.classList.add('visible'); - }); - - modelNameHeader.addEventListener('mouseleave', () => { - if (!modelNameHeader.classList.contains('editing')) { - editBtn.classList.remove('visible'); - } - }); - - // Handle edit button click - editBtn.addEventListener('click', () => { - modelNameHeader.classList.add('editing'); - modelNameContent.setAttribute('contenteditable', 'true'); - // Store original value for comparison later - modelNameContent.dataset.originalValue = modelNameContent.textContent.trim(); - modelNameContent.focus(); - - // Place cursor at the end - const range = document.createRange(); - const sel = window.getSelection(); - if (modelNameContent.childNodes.length > 0) { - range.setStart(modelNameContent.childNodes[0], modelNameContent.textContent.length); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - } - - editBtn.classList.add('visible'); - }); - - // Handle keyboard events in edit mode - modelNameContent.addEventListener('keydown', function(e) { - if (!this.getAttribute('contenteditable')) return; - - if (e.key === 'Enter') { - e.preventDefault(); - this.blur(); // Trigger save on Enter - } else if (e.key === 'Escape') { - e.preventDefault(); - // Restore original value - this.textContent = this.dataset.originalValue; - exitEditMode(); - } - }); - - // Limit model name length - modelNameContent.addEventListener('input', function() { - if (!this.getAttribute('contenteditable')) return; - - if (this.textContent.length > 100) { - this.textContent = this.textContent.substring(0, 100); - // Place cursor at the end - const range = document.createRange(); - const sel = window.getSelection(); - range.setStart(this.childNodes[0], 100); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - - showToast('Model name is limited to 100 characters', 'warning'); - } - }); - - // Handle focus out - save changes - modelNameContent.addEventListener('blur', async function() { - if (!this.getAttribute('contenteditable')) return; - - const newModelName = this.textContent.trim(); - const originalValue = this.dataset.originalValue; - - // Basic validation - if (!newModelName) { - // Restore original value if empty - this.textContent = originalValue; - showToast('Model name cannot be empty', 'error'); - exitEditMode(); - return; - } - - if (newModelName === originalValue) { - // No changes, just exit edit mode - exitEditMode(); - return; - } - - try { - // Get the file path from the dataset - const filePath = this.dataset.filePath; - - await saveModelMetadata(filePath, { model_name: newModelName }); - - showToast('Model name updated successfully', 'success'); - } catch (error) { - console.error('Error updating model name:', error); - this.textContent = originalValue; // Restore original model name - showToast('Failed to update model name', 'error'); - } finally { - exitEditMode(); - } - }); - - function exitEditMode() { - modelNameContent.removeAttribute('contenteditable'); - modelNameHeader.classList.remove('editing'); - editBtn.classList.remove('visible'); - } -} - -/** - * Set up base model editing functionality - * @param {string} filePath - The full file path of the model. - */ -export function setupBaseModelEditing(filePath) { - const baseModelContent = document.querySelector('.base-model-content'); - const editBtn = document.querySelector('.edit-base-model-btn'); - - if (!baseModelContent || !editBtn) return; - - // Store the file path in a data attribute for later use - baseModelContent.dataset.filePath = filePath; - - // Show edit button on hover - const baseModelDisplay = document.querySelector('.base-model-display'); - baseModelDisplay.addEventListener('mouseenter', () => { - editBtn.classList.add('visible'); - }); - - baseModelDisplay.addEventListener('mouseleave', () => { - if (!baseModelDisplay.classList.contains('editing')) { - editBtn.classList.remove('visible'); - } - }); - - // Handle edit button click - editBtn.addEventListener('click', () => { - baseModelDisplay.classList.add('editing'); - - // Store the original value to check for changes later - const originalValue = baseModelContent.textContent.trim(); - - // Create dropdown selector to replace the base model content - const currentValue = originalValue; - const dropdown = document.createElement('select'); - dropdown.className = 'base-model-selector'; - - // Flag to track if a change was made - let valueChanged = false; - - // Add options from BASE_MODELS constants - const baseModelCategories = { - 'Stable Diffusion 1.x': [BASE_MODELS.SD_1_4, BASE_MODELS.SD_1_5, BASE_MODELS.SD_1_5_LCM, BASE_MODELS.SD_1_5_HYPER], - 'Stable Diffusion 2.x': [BASE_MODELS.SD_2_0, BASE_MODELS.SD_2_1], - 'Stable Diffusion 3.x': [BASE_MODELS.SD_3, BASE_MODELS.SD_3_5, BASE_MODELS.SD_3_5_MEDIUM, BASE_MODELS.SD_3_5_LARGE, BASE_MODELS.SD_3_5_LARGE_TURBO], - 'SDXL': [BASE_MODELS.SDXL, BASE_MODELS.SDXL_LIGHTNING, BASE_MODELS.SDXL_HYPER], - 'Video Models': [BASE_MODELS.SVD, BASE_MODELS.LTXV, BASE_MODELS.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO], - 'Other Models': [ - BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, BASE_MODELS.FLUX_1_KONTEXT, BASE_MODELS.AURAFLOW, - BASE_MODELS.PIXART_A, BASE_MODELS.PIXART_E, BASE_MODELS.HUNYUAN_1, - BASE_MODELS.LUMINA, BASE_MODELS.KOLORS, BASE_MODELS.NOOBAI, - BASE_MODELS.ILLUSTRIOUS, BASE_MODELS.PONY, BASE_MODELS.HIDREAM, - BASE_MODELS.UNKNOWN - ] - }; - - // Create option groups for better organization - Object.entries(baseModelCategories).forEach(([category, models]) => { - const group = document.createElement('optgroup'); - group.label = category; - - models.forEach(model => { - const option = document.createElement('option'); - option.value = model; - option.textContent = model; - option.selected = model === currentValue; - group.appendChild(option); - }); - - dropdown.appendChild(group); - }); - - // Replace content with dropdown - baseModelContent.style.display = 'none'; - baseModelDisplay.insertBefore(dropdown, editBtn); - - // Hide edit button during editing - editBtn.style.display = 'none'; - - // Focus the dropdown - dropdown.focus(); - - // Handle dropdown change - dropdown.addEventListener('change', function() { - const selectedModel = this.value; - baseModelContent.textContent = selectedModel; - - // Mark that a change was made if the value differs from original - if (selectedModel !== originalValue) { - valueChanged = true; - } else { - valueChanged = false; - } - }); - - // Function to save changes and exit edit mode - const saveAndExit = function() { - // Check if dropdown still exists and remove it - if (dropdown && dropdown.parentNode === baseModelDisplay) { - baseModelDisplay.removeChild(dropdown); - } - - // Show the content and edit button - baseModelContent.style.display = ''; - editBtn.style.display = ''; - - // Remove editing class - baseModelDisplay.classList.remove('editing'); - - // Only save if the value has actually changed - if (valueChanged || baseModelContent.textContent.trim() !== originalValue) { - // Use the passed filePath for saving - saveBaseModel(filePath, originalValue); - } - - // Remove this event listener - document.removeEventListener('click', outsideClickHandler); - }; - - // Handle outside clicks to save and exit - const outsideClickHandler = function(e) { - // If click is outside the dropdown and base model display - if (!baseModelDisplay.contains(e.target)) { - saveAndExit(); - } - }; - - // Add delayed event listener for outside clicks - setTimeout(() => { - document.addEventListener('click', outsideClickHandler); - }, 0); - - // Also handle dropdown blur event - dropdown.addEventListener('blur', function(e) { - // Only save if the related target is not the edit button or inside the baseModelDisplay - if (!baseModelDisplay.contains(e.relatedTarget)) { - saveAndExit(); - } - }); - }); -} - -/** - * Save base model - * @param {string} filePath - File path - * @param {string} originalValue - Original value (for comparison) - */ -async function saveBaseModel(filePath, originalValue) { - const baseModelElement = document.querySelector('.base-model-content'); - const newBaseModel = baseModelElement.textContent.trim(); - - // Only save if the value has actually changed - if (newBaseModel === originalValue) { - return; // No change, no need to save - } - - try { - await saveModelMetadata(filePath, { base_model: newBaseModel }); - - showToast('Base model updated successfully', 'success'); - } catch (error) { - showToast('Failed to update base model', 'error'); - } -} - -/** - * Set up file name editing functionality - * @param {string} filePath - The full file path of the model. - */ -export function setupFileNameEditing(filePath) { - const fileNameContent = document.querySelector('.file-name-content'); - const editBtn = document.querySelector('.edit-file-name-btn'); - - if (!fileNameContent || !editBtn) return; - - // Store the original file path - fileNameContent.dataset.filePath = filePath; - - // Show edit button on hover - const fileNameWrapper = document.querySelector('.file-name-wrapper'); - fileNameWrapper.addEventListener('mouseenter', () => { - editBtn.classList.add('visible'); - }); - - fileNameWrapper.addEventListener('mouseleave', () => { - if (!fileNameWrapper.classList.contains('editing')) { - editBtn.classList.remove('visible'); - } - }); - - // Handle edit button click - editBtn.addEventListener('click', () => { - fileNameWrapper.classList.add('editing'); - fileNameContent.setAttribute('contenteditable', 'true'); - fileNameContent.focus(); - - // Store original value for comparison later - fileNameContent.dataset.originalValue = fileNameContent.textContent.trim(); - - // Place cursor at the end - const range = document.createRange(); - const sel = window.getSelection(); - range.selectNodeContents(fileNameContent); - range.collapse(false); - sel.removeAllRanges(); - sel.addRange(range); - - editBtn.classList.add('visible'); - }); - - // Handle keyboard events in edit mode - fileNameContent.addEventListener('keydown', function(e) { - if (!this.getAttribute('contenteditable')) return; - - if (e.key === 'Enter') { - e.preventDefault(); - this.blur(); // Trigger save on Enter - } else if (e.key === 'Escape') { - e.preventDefault(); - // Restore original value - this.textContent = this.dataset.originalValue; - exitEditMode(); - } - }); - - // Handle input validation - fileNameContent.addEventListener('input', function() { - if (!this.getAttribute('contenteditable')) return; - - // Replace invalid characters for filenames - const invalidChars = /[\\/:*?"<>|]/g; - if (invalidChars.test(this.textContent)) { - const cursorPos = window.getSelection().getRangeAt(0).startOffset; - this.textContent = this.textContent.replace(invalidChars, ''); - - // Restore cursor position - const range = document.createRange(); - const sel = window.getSelection(); - const newPos = Math.min(cursorPos, this.textContent.length); - - if (this.firstChild) { - range.setStart(this.firstChild, newPos); - range.collapse(true); - sel.removeAllRanges(); - sel.addRange(range); - } - - showToast('Invalid characters removed from filename', 'warning'); - } - }); - - // Handle focus out - save changes - fileNameContent.addEventListener('blur', async function() { - if (!this.getAttribute('contenteditable')) return; - - const newFileName = this.textContent.trim(); - const originalValue = this.dataset.originalValue; - - // Basic validation - if (!newFileName) { - // Restore original value if empty - this.textContent = originalValue; - showToast('File name cannot be empty', 'error'); - exitEditMode(); - return; - } - - if (newFileName === originalValue) { - // No changes, just exit edit mode - exitEditMode(); - return; - } - - try { - // Use the passed filePath (which includes the original filename) - // Call API to rename the file using the new function from checkpointApi.js - const result = await renameCheckpointFile(filePath, newFileName); - - if (result.success) { - showToast('File name updated successfully', 'success'); - - const newFilePath = filePath.replace(originalValue, newFileName); - - state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath }); - this.textContent = newFileName; - } else { - throw new Error(result.error || 'Unknown error'); - } - } catch (error) { - console.error('Error renaming file:', error); - this.textContent = originalValue; // Restore original file name - showToast(`Failed to rename file: ${error.message}`, 'error'); - } finally { - exitEditMode(); - } - }); - - function exitEditMode() { - fileNameContent.removeAttribute('contenteditable'); - fileNameWrapper.classList.remove('editing'); - editBtn.classList.remove('visible'); - } -} \ No newline at end of file diff --git a/static/js/components/checkpointModal/ModelTags.js b/static/js/components/checkpointModal/ModelTags.js deleted file mode 100644 index d3256608..00000000 --- a/static/js/components/checkpointModal/ModelTags.js +++ /dev/null @@ -1,471 +0,0 @@ -/** - * ModelTags.js - * Module for handling checkpoint model tag editing functionality - */ -import { showToast } from '../../utils/uiHelpers.js'; -import { saveModelMetadata } from '../../api/checkpointApi.js'; - -// Preset tag suggestions -const PRESET_TAGS = [ - 'character', 'style', 'concept', 'clothing', 'base model', - 'poses', 'background', 'vehicle', 'buildings', - 'objects', 'animal' -]; - -// Create a named function so we can remove it later -let saveTagsHandler = null; - -/** - * Set up tag editing mode - */ -export function setupTagEditMode() { - const editBtn = document.querySelector('.edit-tags-btn'); - if (!editBtn) return; - - // Store original tags for restoring on cancel - let originalTags = []; - - // Remove any previously attached click handler - if (editBtn._hasClickHandler) { - editBtn.removeEventListener('click', editBtn._clickHandler); - } - - // Create new handler and store reference - const editBtnClickHandler = function() { - const tagsSection = document.querySelector('.model-tags-container'); - const isEditMode = tagsSection.classList.toggle('edit-mode'); - const filePath = this.dataset.filePath; - - // Toggle edit mode UI elements - const compactTagsDisplay = tagsSection.querySelector('.model-tags-compact'); - const tagsEditContainer = tagsSection.querySelector('.metadata-edit-container'); - - if (isEditMode) { - // Enter edit mode - this.innerHTML = ''; // Change to cancel icon - this.title = "Cancel editing"; - - // Get all tags from tooltip, not just the visible ones in compact display - originalTags = Array.from( - tagsSection.querySelectorAll('.tooltip-tag') - ).map(tag => tag.textContent); - - // Hide compact display, show edit container - compactTagsDisplay.style.display = 'none'; - - // If edit container doesn't exist yet, create it - if (!tagsEditContainer) { - const editContainer = document.createElement('div'); - editContainer.className = 'metadata-edit-container'; - - // Move the edit button inside the container header for better visibility - const editBtnClone = editBtn.cloneNode(true); - editBtnClone.classList.add('metadata-header-btn'); - - // Create edit UI with edit button in the header - editContainer.innerHTML = createTagEditUI(originalTags, editBtnClone.outerHTML); - tagsSection.appendChild(editContainer); - - // Setup the tag input field behavior - setupTagInput(); - - // Create and add preset suggestions dropdown - const tagForm = editContainer.querySelector('.metadata-add-form'); - const suggestionsDropdown = createSuggestionsDropdown(originalTags); - tagForm.appendChild(suggestionsDropdown); - - // Setup delete buttons for existing tags - setupDeleteButtons(); - - // Transfer click event from original button to the cloned one - const newEditBtn = editContainer.querySelector('.metadata-header-btn'); - if (newEditBtn) { - newEditBtn.addEventListener('click', function() { - editBtn.click(); - }); - } - - // Hide the original button when in edit mode - editBtn.style.display = 'none'; - } else { - // Just show the existing edit container - tagsEditContainer.style.display = 'block'; - editBtn.style.display = 'none'; - } - } else { - // Exit edit mode - this.innerHTML = ''; // Change back to edit icon - this.title = "Edit tags"; - editBtn.style.display = 'block'; - - // Show compact display, hide edit container - compactTagsDisplay.style.display = 'flex'; - if (tagsEditContainer) tagsEditContainer.style.display = 'none'; - - // Check if we're exiting edit mode due to "Save" or "Cancel" - if (!this.dataset.skipRestore) { - // If canceling, restore original tags - restoreOriginalTags(tagsSection, originalTags); - } else { - // Reset the skip restore flag - delete this.dataset.skipRestore; - } - } - }; - - // Store the handler reference on the button itself - editBtn._clickHandler = editBtnClickHandler; - editBtn._hasClickHandler = true; - editBtn.addEventListener('click', editBtnClickHandler); - - // Clean up any previous document click handler - if (saveTagsHandler) { - document.removeEventListener('click', saveTagsHandler); - } - - // Create new save handler and store reference - saveTagsHandler = function(e) { - if (e.target.classList.contains('save-tags-btn') || - e.target.closest('.save-tags-btn')) { - saveTags(); - } - }; - - // Add the new handler - document.addEventListener('click', saveTagsHandler); -} - -/** - * Create the tag editing UI - * @param {Array} currentTags - Current tags - * @param {string} editBtnHTML - HTML for the edit button to include in header - * @returns {string} HTML markup for tag editing UI - */ -function createTagEditUI(currentTags, editBtnHTML = '') { - return ` - - `; -} - -/** - * Create suggestions dropdown with preset tags - * @param {Array} existingTags - Already added tags - * @returns {HTMLElement} - Dropdown element - */ -function createSuggestionsDropdown(existingTags = []) { - const dropdown = document.createElement('div'); - dropdown.className = 'metadata-suggestions-dropdown'; - - // Create header - const header = document.createElement('div'); - header.className = 'metadata-suggestions-header'; - header.innerHTML = ` - Suggested Tags - Click to add - `; - dropdown.appendChild(header); - - // Create tag container - const container = document.createElement('div'); - container.className = 'metadata-suggestions-container'; - - // Add each preset tag as a suggestion - PRESET_TAGS.forEach(tag => { - const isAdded = existingTags.includes(tag); - - const item = document.createElement('div'); - item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`; - item.title = tag; - item.innerHTML = ` - - ${isAdded ? '' : ''} - `; - - if (!isAdded) { - item.addEventListener('click', () => { - addNewTag(tag); - - // Also populate the input field for potential editing - const input = document.querySelector('.metadata-input'); - if (input) input.value = tag; - - // Focus on the input - if (input) input.focus(); - - // Update dropdown without removing it - updateSuggestionsDropdown(); - }); - } - - container.appendChild(item); - }); - - dropdown.appendChild(container); - return dropdown; -} - -/** - * Set up tag input behavior - */ -function setupTagInput() { - const tagInput = document.querySelector('.metadata-input'); - - if (tagInput) { - tagInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - addNewTag(this.value); - this.value = ''; // Clear input after adding - } - }); - } -} - -/** - * Set up delete buttons for tags - */ -function setupDeleteButtons() { - document.querySelectorAll('.metadata-delete-btn').forEach(btn => { - btn.addEventListener('click', function(e) { - e.stopPropagation(); - const tag = this.closest('.metadata-item'); - tag.remove(); - - // Update status of items in the suggestion dropdown - updateSuggestionsDropdown(); - }); - }); -} - -/** - * Add a new tag - * @param {string} tag - Tag to add - */ -function addNewTag(tag) { - tag = tag.trim().toLowerCase(); - if (!tag) return; - - const tagsContainer = document.querySelector('.metadata-items'); - if (!tagsContainer) return; - - // Validation: Check length - if (tag.length > 30) { - showToast('Tag should not exceed 30 characters', 'error'); - return; - } - - // Validation: Check total number - const currentTags = tagsContainer.querySelectorAll('.metadata-item'); - if (currentTags.length >= 30) { - showToast('Maximum 30 tags allowed', 'error'); - return; - } - - // Validation: Check for duplicates - const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag); - if (existingTags.includes(tag)) { - showToast('This tag already exists', 'error'); - return; - } - - // Create new tag - const newTag = document.createElement('div'); - newTag.className = 'metadata-item'; - newTag.dataset.tag = tag; - newTag.innerHTML = ` - - - `; - - // Add event listener to delete button - const deleteBtn = newTag.querySelector('.metadata-delete-btn'); - deleteBtn.addEventListener('click', function(e) { - e.stopPropagation(); - newTag.remove(); - - // Update status of items in the suggestion dropdown - updateSuggestionsDropdown(); - }); - - tagsContainer.appendChild(newTag); - - // Update status of items in the suggestions dropdown - updateSuggestionsDropdown(); -} - -/** - * Update status of items in the suggestions dropdown - */ -function updateSuggestionsDropdown() { - const dropdown = document.querySelector('.metadata-suggestions-dropdown'); - if (!dropdown) return; - - // Get all current tags - const currentTags = document.querySelectorAll('.metadata-item'); - const existingTags = Array.from(currentTags).map(tag => tag.dataset.tag); - - // Update status of each item in dropdown - dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => { - const tagText = item.querySelector('.metadata-suggestion-text').textContent; - const isAdded = existingTags.includes(tagText); - - if (isAdded) { - item.classList.add('already-added'); - - // Add indicator if it doesn't exist - let indicator = item.querySelector('.added-indicator'); - if (!indicator) { - indicator = document.createElement('span'); - indicator.className = 'added-indicator'; - indicator.innerHTML = ''; - item.appendChild(indicator); - } - - // Remove click event - item.onclick = null; - } else { - // Re-enable items that are no longer in the list - item.classList.remove('already-added'); - - // Remove indicator if it exists - const indicator = item.querySelector('.added-indicator'); - if (indicator) indicator.remove(); - - // Restore click event if not already set - if (!item.onclick) { - item.onclick = () => { - const tag = item.querySelector('.metadata-suggestion-text').textContent; - addNewTag(tag); - - // Also populate the input field - const input = document.querySelector('.metadata-input'); - if (input) input.value = tag; - - // Focus the input - if (input) input.focus(); - }; - } - } - }); -} - -/** - * Restore original tags when canceling edit - * @param {HTMLElement} section - The tags section - * @param {Array} originalTags - Original tags array - */ -function restoreOriginalTags(section, originalTags) { - // Nothing to do here as we're just hiding the edit UI - // and showing the original compact tags which weren't modified -} - -/** - * Save tags - */ -async function saveTags() { - const editBtn = document.querySelector('.edit-tags-btn'); - if (!editBtn) return; - - const filePath = editBtn.dataset.filePath; - const tagElements = document.querySelectorAll('.metadata-item'); - const tags = Array.from(tagElements).map(tag => tag.dataset.tag); - - // Get original tags to compare - const originalTagElements = document.querySelectorAll('.tooltip-tag'); - const originalTags = Array.from(originalTagElements).map(tag => tag.textContent); - - // Check if tags have actually changed - const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags); - - if (!tagsChanged) { - // No changes made, just exit edit mode without API call - editBtn.dataset.skipRestore = "true"; - editBtn.click(); - return; - } - - try { - // Save tags metadata - await saveModelMetadata(filePath, { tags: tags }); - - // Set flag to skip restoring original tags when exiting edit mode - editBtn.dataset.skipRestore = "true"; - - // Update the compact tags display - const compactTagsContainer = document.querySelector('.model-tags-container'); - if (compactTagsContainer) { - // Generate new compact tags HTML - const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact'); - - if (compactTagsDisplay) { - // Clear current tags - compactTagsDisplay.innerHTML = ''; - - // Add visible tags (up to 5) - const visibleTags = tags.slice(0, 5); - visibleTags.forEach(tag => { - const span = document.createElement('span'); - span.className = 'model-tag-compact'; - span.textContent = tag; - compactTagsDisplay.appendChild(span); - }); - - // Add more indicator if needed - const remainingCount = Math.max(0, tags.length - 5); - if (remainingCount > 0) { - const more = document.createElement('span'); - more.className = 'model-tag-more'; - more.dataset.count = remainingCount; - more.textContent = `+${remainingCount}`; - compactTagsDisplay.appendChild(more); - } - } - - // Update tooltip content - const tooltipContent = compactTagsContainer.querySelector('.tooltip-content'); - if (tooltipContent) { - tooltipContent.innerHTML = ''; - - tags.forEach(tag => { - const span = document.createElement('span'); - span.className = 'tooltip-tag'; - span.textContent = tag; - tooltipContent.appendChild(span); - }); - } - } - - // Exit edit mode - editBtn.click(); - - showToast('Tags updated successfully', 'success'); - } catch (error) { - console.error('Error saving tags:', error); - showToast('Failed to update tags', 'error'); - } -} diff --git a/static/js/components/checkpointModal/index.js b/static/js/components/checkpointModal/index.js index 56c0f03e..8feaf8a2 100644 --- a/static/js/components/checkpointModal/index.js +++ b/static/js/components/checkpointModal/index.js @@ -1,239 +1,11 @@ /** * CheckpointModal - Main entry point * - * Modularized checkpoint modal component that handles checkpoint model details display + * Legacy CheckpointModal - now using shared ModelModal component */ -import { showToast } from '../../utils/uiHelpers.js'; -import { modalManager } from '../../managers/ModalManager.js'; -import { - toggleShowcase, - setupShowcaseScroll, - scrollToTop, - loadExampleImages -} from '../shared/showcase/ShowcaseView.js'; -import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; -import { - setupModelNameEditing, - setupBaseModelEditing, - setupFileNameEditing -} from './ModelMetadata.js'; -import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing -import { saveModelMetadata } from '../../api/checkpointApi.js'; -import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; +import { showModelModal } from '../shared/ModelModal.js'; -/** - * Display the checkpoint modal with the given checkpoint data - * @param {Object} checkpoint - Checkpoint data object - */ +// Re-export function with original name for backwards compatibility export function showCheckpointModal(checkpoint) { - const content = ` - - `; - - modalManager.showModal('checkpointModal', content); - setupEditableFields(checkpoint.file_path); - setupShowcaseScroll('checkpointModal'); - setupTabSwitching(); - setupTagTooltip(); - setupTagEditMode(); // Initialize tag editing functionality - setupModelNameEditing(checkpoint.file_path); - setupBaseModelEditing(checkpoint.file_path); - setupFileNameEditing(checkpoint.file_path); - - // If we have a model ID but no description, fetch it - if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) { - loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path); - } - - // Load example images asynchronously - merge regular and custom images - const regularImages = checkpoint.civitai?.images || []; - const customImages = checkpoint.civitai?.customImages || []; - // Combine images - regular images first, then custom images - const allImages = [...regularImages, ...customImages]; - loadExampleImages(allImages, checkpoint.sha256); -} - -/** - * Set up editable fields in the checkpoint modal -* @param {string} filePath - The full file path of the model. - */ -function setupEditableFields(filePath) { - const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); - - editableFields.forEach(field => { - field.addEventListener('focus', function() { - if (this.textContent === 'Add your notes here...') { - this.textContent = ''; - } - }); - - field.addEventListener('blur', function() { - if (this.textContent.trim() === '') { - if (this.classList.contains('notes-content')) { - this.textContent = 'Add your notes here...'; - } - } - }); - }); - - // Add keydown event listeners for notes - const notesContent = document.querySelector('.notes-content'); - if (notesContent) { - notesContent.addEventListener('keydown', async function(e) { - if (e.key === 'Enter') { - if (e.shiftKey) { - // Allow shift+enter for new line - return; - } - e.preventDefault(); - await saveNotes(filePath); - } - }); - } -} - -/** - * Save checkpoint notes - * @param {string} filePath - Path to the checkpoint file - */ -async function saveNotes(filePath) { - const content = document.querySelector('.notes-content').textContent; - try { - await saveModelMetadata(filePath, { notes: content }); - - showToast('Notes saved successfully', 'success'); - } catch (error) { - showToast('Failed to save notes', 'error'); - } -} - -// Export the checkpoint modal API -const checkpointModal = { - show: showCheckpointModal, - toggleShowcase, - scrollToTop -}; - -export { checkpointModal }; - -// Define global functions for use in HTML -window.toggleShowcase = function(element) { - toggleShowcase(element); -}; - -window.scrollToTopCheckpoint = function(button) { - scrollToTop(button); -}; - -window.saveCheckpointNotes = function(filePath) { - saveNotes(filePath); -}; \ No newline at end of file + return showModelModal(checkpoint, 'checkpoint'); +} \ No newline at end of file diff --git a/static/js/components/loraModal/ModelDescription.js b/static/js/components/loraModal/ModelDescription.js deleted file mode 100644 index b4a0eb57..00000000 --- a/static/js/components/loraModal/ModelDescription.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * ModelDescription.js - * 处理LoRA模型描述相关的功能模块 - */ -import { showToast } from '../../utils/uiHelpers.js'; - -/** - * 设置标签页切换功能 - */ -export function setupTabSwitching() { - const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - // Remove active class from all tabs - document.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn => - btn.classList.remove('active') - ); - document.querySelectorAll('.tab-content .tab-pane').forEach(tab => - tab.classList.remove('active') - ); - - // Add active class to clicked tab - button.classList.add('active'); - const tabId = `${button.dataset.tab}-tab`; - document.getElementById(tabId).classList.add('active'); - - // If switching to description tab, make sure content is properly sized - if (button.dataset.tab === 'description') { - const descriptionContent = document.querySelector('.model-description-content'); - if (descriptionContent) { - const hasContent = descriptionContent.innerHTML.trim() !== ''; - document.querySelector('.model-description-loading')?.classList.add('hidden'); - - // If no content, show a message - if (!hasContent) { - descriptionContent.innerHTML = '
No model description available
'; - descriptionContent.classList.remove('hidden'); - } - } - } - }); - }); -} - -/** - * 加载模型描述 - * @param {string} modelId - 模型ID - * @param {string} filePath - 文件路径 - */ -export async function loadModelDescription(modelId, filePath) { - try { - const descriptionContainer = document.querySelector('.model-description-content'); - const loadingElement = document.querySelector('.model-description-loading'); - - if (!descriptionContainer || !loadingElement) return; - - // Show loading indicator - loadingElement.classList.remove('hidden'); - descriptionContainer.classList.add('hidden'); - - // Try to get model description from API - const response = await fetch(`/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); - - if (!response.ok) { - throw new Error(`Failed to fetch model description: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success && data.description) { - // Update the description content - descriptionContainer.innerHTML = data.description; - - // Process any links in the description to open in new tab - const links = descriptionContainer.querySelectorAll('a'); - links.forEach(link => { - link.setAttribute('target', '_blank'); - link.setAttribute('rel', 'noopener noreferrer'); - }); - - // Show the description and hide loading indicator - descriptionContainer.classList.remove('hidden'); - loadingElement.classList.add('hidden'); - } else { - throw new Error(data.error || 'No description available'); - } - } catch (error) { - console.error('Error loading model description:', error); - const loadingElement = document.querySelector('.model-description-loading'); - if (loadingElement) { - loadingElement.innerHTML = `
Failed to load model description. ${error.message}
`; - } - - // Show empty state message in the description container - const descriptionContainer = document.querySelector('.model-description-content'); - if (descriptionContainer) { - descriptionContainer.innerHTML = '
No model description available
'; - descriptionContainer.classList.remove('hidden'); - } - } -} \ No newline at end of file diff --git a/static/js/components/loraModal/index.js b/static/js/components/loraModal/index.js index 1fe83ae1..5a2467de 100644 --- a/static/js/components/loraModal/index.js +++ b/static/js/components/loraModal/index.js @@ -1,338 +1,7 @@ -/** - * LoraModal - 主入口点 - * - * 将原始的LoraModal.js拆分成多个功能模块后的主入口文件 - */ -import { showToast } from '../../utils/uiHelpers.js'; -import { modalManager } from '../../managers/ModalManager.js'; -import { - setupShowcaseScroll, - scrollToTop, - loadExampleImages -} from '../shared/showcase/ShowcaseView.js'; -import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; -import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; -import { parsePresets, renderPresetTags } from './PresetTags.js'; -import { loadRecipesForLora } from './RecipeTab.js'; -import { setupTagEditMode } from './ModelTags.js'; // Add import for tag editing -import { - setupModelNameEditing, - setupBaseModelEditing, - setupFileNameEditing -} from './ModelMetadata.js'; -import { saveModelMetadata } from '../../api/loraApi.js'; -import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; +// Legacy LoraModal - now using shared ModelModal component +import { showModelModal } from '../shared/ModelModal.js'; -/** - * 显示LoRA模型弹窗 - * @param {Object} lora - LoRA模型数据 - */ +// Re-export function with original name for backwards compatibility export function showLoraModal(lora) { - const escapedWords = lora.civitai?.trainedWords?.length ? - lora.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; - - const content = ` - - `; - - modalManager.showModal('loraModal', content, null, function() { - // Clean up all handlers when modal closes - const modalElement = document.getElementById('loraModal'); - if (modalElement && modalElement._clickHandler) { - modalElement.removeEventListener('click', modalElement._clickHandler); - delete modalElement._clickHandler; - } - }); - setupEditableFields(lora.file_path); - setupShowcaseScroll('loraModal'); - setupTabSwitching(); - setupTagTooltip(); - setupTriggerWordsEditMode(); - setupModelNameEditing(lora.file_path); - setupBaseModelEditing(lora.file_path); - setupFileNameEditing(lora.file_path); - setupTagEditMode(); // Initialize tag editing functionality - setupEventHandlers(lora.file_path); - - // If we have a model ID but no description, fetch it - if (lora.civitai?.modelId && !lora.modelDescription) { - loadModelDescription(lora.civitai.modelId, lora.file_path); - } - - // Load recipes for this Lora - loadRecipesForLora(lora.model_name, lora.sha256); - - // Load example images asynchronously - merge regular and custom images - const regularImages = lora.civitai?.images || []; - const customImages = lora.civitai?.customImages || []; - // Combine images - regular images first, then custom images - const allImages = [...regularImages, ...customImages]; - loadExampleImages(allImages, lora.sha256); -} - -/** - * Sets up event handlers using event delegation - * @param {string} filePath - Path to the model file - */ -function setupEventHandlers(filePath) { - const modalElement = document.getElementById('loraModal'); - - // Remove existing event listeners first - modalElement.removeEventListener('click', handleModalClick); - - // Create and store the handler function - function handleModalClick(event) { - const target = event.target.closest('[data-action]'); - if (!target) return; - - const action = target.dataset.action; - - switch (action) { - case 'close-modal': - modalManager.closeModal('loraModal'); - break; - case 'scroll-to-top': - scrollToTop(target); - break; - } - } - - // Add the event listener with the named function - modalElement.addEventListener('click', handleModalClick); - - // Store reference to the handler on the element for potential cleanup - modalElement._clickHandler = handleModalClick; -} - -async function saveNotes(filePath) { - const content = document.querySelector('.notes-content').textContent; - try { - await saveModelMetadata(filePath, { notes: content }); - - showToast('Notes saved successfully', 'success'); - } catch (error) { - showToast('Failed to save notes', 'error'); - } -}; - -function setupEditableFields(filePath) { - const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); - - editableFields.forEach(field => { - field.addEventListener('focus', function() { - if (this.textContent === 'Add your notes here...') { - this.textContent = ''; - } - }); - - field.addEventListener('blur', function() { - if (this.textContent.trim() === '') { - if (this.classList.contains('notes-content')) { - this.textContent = 'Add your notes here...'; - } - } - }); - }); - - const presetSelector = document.getElementById('preset-selector'); - const presetValue = document.getElementById('preset-value'); - const addPresetBtn = document.querySelector('.add-preset-btn'); - const presetTags = document.querySelector('.preset-tags'); - - presetSelector.addEventListener('change', function() { - const selected = this.value; - if (selected) { - presetValue.style.display = 'inline-block'; - presetValue.min = selected.includes('strength') ? -10 : 0; - presetValue.max = selected.includes('strength') ? 10 : 10; - presetValue.step = 0.5; - if (selected === 'clip_skip') { - presetValue.type = 'number'; - presetValue.step = 1; - } - // Add auto-focus - setTimeout(() => presetValue.focus(), 0); - } else { - presetValue.style.display = 'none'; - } - }); - - addPresetBtn.addEventListener('click', async function() { - const key = presetSelector.value; - const value = presetValue.value; - - if (!key || !value) return; - - const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); - const currentPresets = parsePresets(loraCard.dataset.usage_tips); - - currentPresets[key] = parseFloat(value); - const newPresetsJson = JSON.stringify(currentPresets); - - await saveModelMetadata(filePath, { - usage_tips: newPresetsJson - }); - - presetTags.innerHTML = renderPresetTags(currentPresets); - - presetSelector.value = ''; - presetValue.value = ''; - presetValue.style.display = 'none'; - }); - - // Add keydown event listeners for notes - const notesContent = document.querySelector('.notes-content'); - if (notesContent) { - notesContent.addEventListener('keydown', async function(e) { - if (e.key === 'Enter') { - if (e.shiftKey) { - // Allow shift+enter for new line - return; - } - e.preventDefault(); - await saveNotes(filePath); - } - }); - } - - // Add keydown event for preset value - presetValue.addEventListener('keydown', function(e) { - if (e.key === 'Enter') { - e.preventDefault(); - addPresetBtn.click(); - } - }); + return showModelModal(lora, 'lora'); } \ No newline at end of file diff --git a/static/js/components/loraModal/utils.js b/static/js/components/loraModal/utils.js deleted file mode 100644 index 909817b5..00000000 --- a/static/js/components/loraModal/utils.js +++ /dev/null @@ -1,81 +0,0 @@ -/** - * utils.js - * LoraModal组件的辅助函数集合 - */ -import { showToast } from '../../utils/uiHelpers.js'; - -/** - * 格式化文件大小 - * @param {number} bytes - 字节数 - * @returns {string} 格式化后的文件大小 - */ -export function formatFileSize(bytes) { - if (!bytes) return 'N/A'; - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - - return `${size.toFixed(1)} ${units[unitIndex]}`; -} - -/** - * 渲染紧凑标签 - * @param {Array} tags - 标签数组 - * @param {string} filePath - 文件路径,用于编辑按钮 - * @returns {string} HTML内容 - */ -export function renderCompactTags(tags, filePath = '') { - // Remove the early return and always render the container - const tagsList = tags || []; - - // Display up to 5 tags, with a tooltip indicator if there are more - const visibleTags = tagsList.slice(0, 5); - const remainingCount = Math.max(0, tagsList.length - 5); - - return ` -
-
-
- ${visibleTags.map(tag => `${tag}`).join('')} - ${remainingCount > 0 ? - `+${remainingCount}` : - ''} - ${tagsList.length === 0 ? `No tags` : ''} -
- -
- ${tagsList.length > 0 ? - `
-
- ${tagsList.map(tag => `${tag}`).join('')} -
-
` : - ''} -
- `; -} - -/** - * 设置标签提示功能 - */ -export function setupTagTooltip() { - const tagsContainer = document.querySelector('.model-tags-container'); - const tooltip = document.querySelector('.model-tags-tooltip'); - - if (tagsContainer && tooltip) { - tagsContainer.addEventListener('mouseenter', () => { - tooltip.classList.add('visible'); - }); - - tagsContainer.addEventListener('mouseleave', () => { - tooltip.classList.remove('visible'); - }); - } -} \ No newline at end of file diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js new file mode 100644 index 00000000..d0f5a125 --- /dev/null +++ b/static/js/components/shared/ModelCard.js @@ -0,0 +1,570 @@ +import { showToast, openCivitai, copyToClipboard, sendLoraToWorkflow, openExampleImagesFolder } from '../../utils/uiHelpers.js'; +import { state, getCurrentPageState } from '../../state/index.js'; +import { showModelModal } from './ModelModal.js'; +import { bulkManager } from '../../managers/BulkManager.js'; +import { modalManager } from '../../managers/ModalManager.js'; +import { NSFW_LEVELS } from '../../utils/constants.js'; +import { replacePreview, saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; +import { replaceCheckpointPreview as apiReplaceCheckpointPreview, saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { showDeleteModal } from '../../utils/modalUtils.js'; + +// Add global event delegation handlers +export function setupModelCardEventDelegation(modelType) { + const gridElement = document.getElementById('modelGrid'); + if (!gridElement) return; + + // Remove any existing event listener to prevent duplication + gridElement.removeEventListener('click', gridElement._handleModelCardEvent); + + // Create event handler with modelType context + const handleModelCardEvent = (event) => handleModelCardEvent_internal(event, modelType); + + // Add the event delegation handler + gridElement.addEventListener('click', handleModelCardEvent); + + // Store reference to the handler for cleanup + gridElement._handleModelCardEvent = handleModelCardEvent; +} + +// Event delegation handler for all model card events +function handleModelCardEvent_internal(event, modelType) { + // Find the closest card element + const card = event.target.closest('.lora-card'); + if (!card) return; + + // Handle specific elements within the card + if (event.target.closest('.toggle-blur-btn')) { + event.stopPropagation(); + toggleBlurContent(card); + return; + } + + if (event.target.closest('.show-content-btn')) { + event.stopPropagation(); + showBlurredContent(card); + return; + } + + if (event.target.closest('.fa-star')) { + event.stopPropagation(); + toggleFavorite(card, modelType); + return; + } + + if (event.target.closest('.fa-globe')) { + event.stopPropagation(); + if (card.dataset.from_civitai === 'true') { + openCivitai(card.dataset.filepath); + } + return; + } + + if (event.target.closest('.fa-paper-plane')) { + event.stopPropagation(); + handleSendToWorkflow(card, event.shiftKey, modelType); + return; + } + + if (event.target.closest('.fa-copy')) { + event.stopPropagation(); + handleCopyAction(card, modelType); + return; + } + + if (event.target.closest('.fa-trash')) { + event.stopPropagation(); + showDeleteModal(card.dataset.filepath, modelType); + return; + } + + if (event.target.closest('.fa-image')) { + event.stopPropagation(); + handleReplacePreview(card.dataset.filepath, modelType); + return; + } + + if (event.target.closest('.fa-folder-open')) { + event.stopPropagation(); + handleExampleImagesAccess(card, modelType); + return; + } + + // If no specific element was clicked, handle the card click (show modal or toggle selection) + handleCardClick(card, modelType); +} + +// Helper functions for event handling +function toggleBlurContent(card) { + const preview = card.querySelector('.card-preview'); + const isBlurred = preview.classList.toggle('blurred'); + const icon = card.querySelector('.toggle-blur-btn i'); + + // Update the icon based on blur state + if (isBlurred) { + icon.className = 'fas fa-eye'; + } else { + icon.className = 'fas fa-eye-slash'; + } + + // Toggle the overlay visibility + const overlay = card.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = isBlurred ? 'flex' : 'none'; + } +} + +function showBlurredContent(card) { + const preview = card.querySelector('.card-preview'); + preview.classList.remove('blurred'); + + // Update the toggle button icon + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + + // Hide the overlay + const overlay = card.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } +} + +async function toggleFavorite(card, modelType) { + const starIcon = card.querySelector('.fa-star'); + const isFavorite = starIcon.classList.contains('fas'); + const newFavoriteState = !isFavorite; + + try { + // Use the appropriate save function based on model type + const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata; + await saveFunction(card.dataset.filepath, { + favorite: newFavoriteState + }); + + if (newFavoriteState) { + showToast('Added to favorites', 'success'); + } else { + showToast('Removed from favorites', 'success'); + } + } catch (error) { + console.error('Failed to update favorite status:', error); + showToast('Failed to update favorite status', 'error'); + } +} + +function handleSendToWorkflow(card, replaceMode, modelType) { + if (modelType === 'lora') { + const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); + const strength = usageTips.strength || 1; + const loraSyntax = ``; + sendLoraToWorkflow(loraSyntax, replaceMode, 'lora'); + } else { + // Checkpoint send functionality - to be implemented + showToast('Send checkpoint to workflow - feature to be implemented', 'info'); + } +} + +function handleCopyAction(card, modelType) { + if (modelType === 'lora') { + const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); + const strength = usageTips.strength || 1; + const loraSyntax = ``; + copyToClipboard(loraSyntax, 'LoRA syntax copied to clipboard'); + } else { + // Checkpoint copy functionality - copy checkpoint name + const checkpointName = card.dataset.file_name; + copyToClipboard(checkpointName, 'Checkpoint name copied'); + } +} + +function handleReplacePreview(filePath, modelType) { + if (modelType === 'lora') { + replacePreview(filePath); + } else { + if (window.replaceCheckpointPreview) { + window.replaceCheckpointPreview(filePath); + } else { + apiReplaceCheckpointPreview(filePath); + } + } +} + +async function handleExampleImagesAccess(card, modelType) { + const modelHash = card.dataset.sha256; + + try { + const response = await fetch(`/api/has-example-images?model_hash=${modelHash}`); + const data = await response.json(); + + if (data.has_images) { + openExampleImagesFolder(modelHash); + } else { + showExampleAccessModal(card, modelType); + } + } catch (error) { + console.error('Error checking for example images:', error); + showToast('Error checking for example images', 'error'); + } +} + +function handleCardClick(card, modelType) { + const pageState = getCurrentPageState(); + + if (state.bulkMode) { + // Toggle selection using the bulk manager + bulkManager.toggleCardSelection(card); + } else if (pageState && pageState.duplicatesMode) { + // In duplicates mode, don't open modal when clicking cards + return; + } else { + // Normal behavior - show modal + showModelModalFromCard(card, modelType); + } +} + +function showModelModalFromCard(card, modelType) { + // Get the appropriate preview versions map + const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints'; + const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map(); + const version = previewVersions.get(card.dataset.filepath); + const previewUrl = card.dataset.preview_url || '/loras_static/images/no-preview.png'; + const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl; + + // Create model metadata object + const modelMeta = { + sha256: card.dataset.sha256, + file_path: card.dataset.filepath, + model_name: card.dataset.name, + file_name: card.dataset.file_name, + folder: card.dataset.folder, + modified: card.dataset.modified, + file_size: parseInt(card.dataset.file_size || '0'), + from_civitai: card.dataset.from_civitai === 'true', + base_model: card.dataset.base_model, + notes: card.dataset.notes || '', + preview_url: versionedPreviewUrl, + favorite: card.dataset.favorite === 'true', + // Parse civitai metadata from the card's dataset + civitai: JSON.parse(card.dataset.meta || '{}'), + tags: JSON.parse(card.dataset.tags || '[]'), + modelDescription: card.dataset.modelDescription || '', + // LoRA specific fields + ...(modelType === 'lora' && { + usage_tips: card.dataset.usage_tips, + }) + }; + + showModelModal(modelMeta, modelType); +} + +// Function to show the example access modal (generalized for lora and checkpoint) +function showExampleAccessModal(card, modelType) { + const modal = document.getElementById('exampleAccessModal'); + if (!modal) return; + + // Get download button and determine if download should be enabled + const downloadBtn = modal.querySelector('#downloadExamplesBtn'); + let hasRemoteExamples = false; + + try { + const metaData = JSON.parse(card.dataset.meta || '{}'); + hasRemoteExamples = metaData.images && + Array.isArray(metaData.images) && + metaData.images.length > 0 && + metaData.images[0].url; + } catch (e) { + console.error('Error parsing meta data:', e); + } + + // Enable or disable download button + if (downloadBtn) { + if (hasRemoteExamples) { + downloadBtn.classList.remove('disabled'); + downloadBtn.removeAttribute('title'); + downloadBtn.onclick = () => { + modalManager.closeModal('exampleAccessModal'); + // Open settings modal and scroll to example images section + const settingsModal = document.getElementById('settingsModal'); + if (settingsModal) { + modalManager.showModal('settingsModal'); + setTimeout(() => { + const exampleSection = settingsModal.querySelector('.settings-section:nth-child(5)'); + if (exampleSection) { + exampleSection.scrollIntoView({ behavior: 'smooth' }); + } + }, 300); + } + }; + } else { + downloadBtn.classList.add('disabled'); + downloadBtn.setAttribute('title', 'No remote example images available for this model on Civitai'); + downloadBtn.onclick = null; + } + } + + // Set up import button + const importBtn = modal.querySelector('#importExamplesBtn'); + if (importBtn) { + importBtn.onclick = () => { + modalManager.closeModal('exampleAccessModal'); + + // Get the model data from card dataset (works for both lora and checkpoint) + const modelMeta = { + sha256: card.dataset.sha256, + file_path: card.dataset.filepath, + model_name: card.dataset.name, + file_name: card.dataset.file_name, + folder: card.dataset.folder, + modified: card.dataset.modified, + file_size: card.dataset.file_size, + from_civitai: card.dataset.from_civitai === 'true', + base_model: card.dataset.base_model, + notes: card.dataset.notes, + favorite: card.dataset.favorite === 'true', + civitai: JSON.parse(card.dataset.meta || '{}'), + tags: JSON.parse(card.dataset.tags || '[]'), + modelDescription: card.dataset.modelDescription || '' + }; + + // Add usage_tips if present (for lora) + if (card.dataset.usage_tips) { + modelMeta.usage_tips = card.dataset.usage_tips; + } + + // Show the model modal + showModelModal(modelMeta, modelType); + + // Scroll to import area after modal is visible + setTimeout(() => { + const importArea = document.querySelector('.example-import-area'); + if (importArea) { + const showcaseTab = document.getElementById('showcase-tab'); + if (showcaseTab) { + // First make sure showcase tab is visible + const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]'); + if (tabBtn && !tabBtn.classList.contains('active')) { + tabBtn.click(); + } + + // Then toggle showcase if collapsed + const carousel = showcaseTab.querySelector('.carousel'); + if (carousel && carousel.classList.contains('collapsed')) { + const scrollIndicator = showcaseTab.querySelector('.scroll-indicator'); + if (scrollIndicator) { + scrollIndicator.click(); + } + } + + // Finally scroll to the import area + importArea.scrollIntoView({ behavior: 'smooth' }); + } + } + }, 500); + }; + } + + // Show the modal + modalManager.showModal('exampleAccessModal'); +} + +export function createModelCard(model, modelType) { + const card = document.createElement('div'); + card.className = 'lora-card'; // Reuse the same class for styling + card.dataset.sha256 = model.sha256; + card.dataset.filepath = model.file_path; + card.dataset.name = model.model_name; + card.dataset.file_name = model.file_name; + card.dataset.folder = model.folder; + card.dataset.modified = model.modified; + card.dataset.file_size = model.file_size; + card.dataset.from_civitai = model.from_civitai; + card.dataset.notes = model.notes || ''; + card.dataset.base_model = model.base_model || (modelType === 'checkpoint' ? 'Unknown' : ''); + card.dataset.favorite = model.favorite ? 'true' : 'false'; + + // LoRA specific data + if (modelType === 'lora') { + card.dataset.usage_tips = model.usage_tips; + } + + // Store metadata if available + if (model.civitai) { + card.dataset.meta = JSON.stringify(model.civitai || {}); + } + + // Store tags if available + if (model.tags && Array.isArray(model.tags)) { + card.dataset.tags = JSON.stringify(model.tags); + } + + if (model.modelDescription) { + card.dataset.modelDescription = model.modelDescription; + } + + // Store NSFW level if available + const nsfwLevel = model.preview_nsfw_level !== undefined ? model.preview_nsfw_level : 0; + card.dataset.nsfwLevel = nsfwLevel; + + // Determine if the preview should be blurred based on NSFW level and user settings + const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; + if (shouldBlur) { + card.classList.add('nsfw-content'); + } + + // Apply selection state if in bulk mode and this card is in the selected set (LoRA only) + if (modelType === 'lora' && state.bulkMode && state.selectedLoras.has(model.file_path)) { + card.classList.add('selected'); + } + + // Get the appropriate preview versions map + const previewVersionsKey = modelType === 'lora' ? 'loras' : 'checkpoints'; + const previewVersions = state.pages[previewVersionsKey]?.previewVersions || new Map(); + const version = previewVersions.get(model.file_path); + const previewUrl = model.preview_url || '/loras_static/images/no-preview.png'; + const versionedPreviewUrl = version ? `${previewUrl}?t=${version}` : previewUrl; + + // Determine NSFW warning text based on level + let nsfwText = "Mature Content"; + if (nsfwLevel >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (nsfwLevel >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (nsfwLevel >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + + // Check if autoplayOnHover is enabled for video previews + const autoplayOnHover = state.global?.settings?.autoplayOnHover || false; + const isVideo = previewUrl.endsWith('.mp4'); + const videoAttrs = autoplayOnHover ? 'controls muted loop' : 'controls autoplay muted loop'; + + // Get favorite status from model data + const isFavorite = model.favorite === true; + + // Generate action icons based on model type + const actionIcons = modelType === 'lora' ? + ` + + + + + + + ` : + ` + + + + + + + `; + + card.innerHTML = ` +
+ ${isVideo ? + `` : + `${model.model_name}` + } +
+ ${shouldBlur ? + `` : ''} + + ${model.base_model} + +
+ ${actionIcons} +
+
+ ${shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''} + +
+ `; + + // Add video auto-play on hover functionality if needed + const videoElement = card.querySelector('video'); + if (videoElement && autoplayOnHover) { + const cardPreview = card.querySelector('.card-preview'); + + // Remove autoplay attribute and pause initially + videoElement.removeAttribute('autoplay'); + videoElement.pause(); + + // Add mouse events to trigger play/pause using event attributes + cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()'); + cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}'); + } + + return card; +} + +// Add a method to update card appearance based on bulk mode (LoRA only) +export function updateCardsForBulkMode(isBulkMode) { + // Update the state + state.bulkMode = isBulkMode; + + document.body.classList.toggle('bulk-mode', isBulkMode); + + // Get all lora cards - this can now be from the DOM or through the virtual scroller + const loraCards = document.querySelectorAll('.lora-card'); + + loraCards.forEach(card => { + // Get all action containers for this card + const actions = card.querySelectorAll('.card-actions'); + + // Handle display property based on mode + if (isBulkMode) { + // Hide actions when entering bulk mode + actions.forEach(actionGroup => { + actionGroup.style.display = 'none'; + }); + } else { + // Ensure actions are visible when exiting bulk mode + actions.forEach(actionGroup => { + // We need to reset to default display style which is flex + actionGroup.style.display = 'flex'; + }); + } + }); + + // If using virtual scroller, we need to rerender after toggling bulk mode + if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') { + state.virtualScroller.scheduleRender(); + } + + // Apply selection state to cards if entering bulk mode + if (isBulkMode) { + bulkManager.applySelectionState(); + } +} \ No newline at end of file diff --git a/static/js/components/checkpointModal/ModelDescription.js b/static/js/components/shared/ModelDescription.js similarity index 81% rename from static/js/components/checkpointModal/ModelDescription.js rename to static/js/components/shared/ModelDescription.js index 0f50fe8a..a7275c20 100644 --- a/static/js/components/checkpointModal/ModelDescription.js +++ b/static/js/components/shared/ModelDescription.js @@ -1,8 +1,7 @@ /** * ModelDescription.js - * Handles checkpoint model descriptions + * Handles model description related functionality - General version */ -import { showToast } from '../../utils/uiHelpers.js'; /** * Set up tab switching functionality @@ -25,7 +24,7 @@ export function setupTabSwitching() { const tabId = `${button.dataset.tab}-tab`; document.getElementById(tabId).classList.add('active'); - // If switching to description tab, make sure content is properly loaded and displayed + // If switching to description tab, make sure content is properly sized if (button.dataset.tab === 'description') { const descriptionContent = document.querySelector('.model-description-content'); if (descriptionContent) { @@ -44,9 +43,9 @@ export function setupTabSwitching() { } /** - * Load model description from API - * @param {string} modelId - The Civitai model ID - * @param {string} filePath - File path for the model + * Load model description - General version supports both LoRA and Checkpoint + * @param {string} modelId - Model ID + * @param {string} filePath - File path */ export async function loadModelDescription(modelId, filePath) { try { @@ -59,8 +58,17 @@ export async function loadModelDescription(modelId, filePath) { loadingElement.classList.remove('hidden'); descriptionContainer.classList.add('hidden'); + // Determine API endpoint based on file path or context + let apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`; + + // If this is a checkpoint (can be determined from file path or other context) + if (filePath.includes('.safetensors') || filePath.includes('.ckpt')) { + // For now, use the same endpoint - can be updated later if checkpoint-specific endpoint is needed + apiEndpoint = `/api/lora-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`; + } + // Try to get model description from API - const response = await fetch(`/api/checkpoint-model-description?model_id=${modelId}&file_path=${encodeURIComponent(filePath)}`); + const response = await fetch(apiEndpoint); if (!response.ok) { throw new Error(`Failed to fetch model description: ${response.statusText}`); diff --git a/static/js/components/loraModal/ModelMetadata.js b/static/js/components/shared/ModelMetadata.js similarity index 85% rename from static/js/components/loraModal/ModelMetadata.js rename to static/js/components/shared/ModelMetadata.js index 7ddc4489..273d7df3 100644 --- a/static/js/components/loraModal/ModelMetadata.js +++ b/static/js/components/shared/ModelMetadata.js @@ -1,15 +1,16 @@ /** * ModelMetadata.js - * 处理LoRA模型元数据编辑相关的功能模块 + * Handles model metadata editing functionality - General version */ import { showToast } from '../../utils/uiHelpers.js'; import { BASE_MODELS } from '../../utils/constants.js'; import { state } from '../../state/index.js'; -import { saveModelMetadata, renameLoraFile } from '../../api/loraApi.js'; +import { saveModelMetadata as saveLoraMetadata, renameLoraFile } from '../../api/loraApi.js'; +import { saveModelMetadata as saveCheckpointMetadata, renameCheckpointFile } from '../../api/checkpointApi.js'; /** - * 设置模型名称编辑功能 - * @param {string} filePath - 文件路径 + * Set up model name editing functionality + * @param {string} filePath - File path */ export function setupModelNameEditing(filePath) { const modelNameContent = document.querySelector('.model-name-content'); @@ -113,7 +114,11 @@ export function setupModelNameEditing(filePath) { // Get the file path from the dataset const filePath = this.dataset.filePath; - await saveModelMetadata(filePath, { model_name: newModelName }); + // Determine model type based on file extension + const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt'); + const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata; + + await saveFunction(filePath, { model_name: newModelName }); showToast('Model name updated successfully', 'success'); } catch (error) { @@ -133,8 +138,8 @@ export function setupModelNameEditing(filePath) { } /** - * 设置基础模型编辑功能 - * @param {string} filePath - 文件路径 + * Set up base model editing functionality + * @param {string} filePath - File path */ export function setupBaseModelEditing(filePath) { const baseModelContent = document.querySelector('.base-model-content'); @@ -278,9 +283,9 @@ export function setupBaseModelEditing(filePath) { } /** - * 保存基础模型 - * @param {string} filePath - 文件路径 - * @param {string} originalValue - 原始值(用于比较) + * Save base model + * @param {string} filePath - File path + * @param {string} originalValue - Original value (for comparison) */ async function saveBaseModel(filePath, originalValue) { const baseModelElement = document.querySelector('.base-model-content'); @@ -292,7 +297,11 @@ async function saveBaseModel(filePath, originalValue) { } try { - await saveModelMetadata(filePath, { base_model: newBaseModel }); + // Determine model type based on file extension + const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt'); + const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata; + + await saveFunction(filePath, { base_model: newBaseModel }); showToast('Base model updated successfully', 'success'); } catch (error) { @@ -301,8 +310,8 @@ async function saveBaseModel(filePath, originalValue) { } /** - * 设置文件名编辑功能 - * @param {string} filePath - 文件路径 + * Set up file name editing functionality + * @param {string} filePath - File path */ export function setupFileNameEditing(filePath) { const fileNameContent = document.querySelector('.file-name-content'); @@ -411,17 +420,36 @@ export function setupFileNameEditing(filePath) { try { // Get the file path from the dataset const filePath = this.dataset.filePath; - - // Call API to rename the file using the new function from loraApi.js - const result = await renameLoraFile(filePath, newFileName); + + // Determine model type and use appropriate rename function + const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt'); + let result; + + if (isCheckpoint) { + // Use checkpoint rename function if it exists, otherwise fallback to generic approach + if (typeof renameCheckpointFile === 'function') { + result = await renameCheckpointFile(filePath, newFileName); + } else { + // Fallback: use checkpoint metadata save function + await saveCheckpointMetadata(filePath, { file_name: newFileName }); + result = { success: true }; + } + } else { + // Use LoRA rename function + result = await renameLoraFile(filePath, newFileName); + } if (result.success) { showToast('File name updated successfully', 'success'); - // Get the new file path and update the card - const newFilePath = filePath.replace(originalValue, newFileName); -; - state.virtualScroller.updateSingleItem(filePath, { file_name: newFileName, file_path: newFilePath }); + // Update virtual scroller if available (mainly for LoRAs) + if (state.virtualScroller && typeof state.virtualScroller.updateSingleItem === 'function') { + const newFilePath = filePath.replace(originalValue, newFileName); + state.virtualScroller.updateSingleItem(filePath, { + file_name: newFileName, + file_path: newFilePath + }); + } } else { throw new Error(result.error || 'Unknown error'); } diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js new file mode 100644 index 00000000..6951f281 --- /dev/null +++ b/static/js/components/shared/ModelModal.js @@ -0,0 +1,443 @@ +import { showToast } from '../../utils/uiHelpers.js'; +import { modalManager } from '../../managers/ModalManager.js'; +import { + toggleShowcase, + setupShowcaseScroll, + scrollToTop, + loadExampleImages +} from './showcase/ShowcaseView.js'; +import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; +import { + setupModelNameEditing, + setupBaseModelEditing, + setupFileNameEditing +} from './ModelMetadata.js'; +import { setupTagEditMode } from './ModelTags.js'; +import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; +import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; +import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; +import { parsePresets, renderPresetTags } from './PresetTags.js'; +import { loadRecipesForLora } from './RecipeTab.js'; + +/** + * Display the model modal with the given model data + * @param {Object} model - Model data object + * @param {string} modelType - Type of model ('lora' or 'checkpoint') + */ +export function showModelModal(model, modelType) { + const modalId = 'modelModal'; + const modalTitle = model.model_name; + + // Prepare LoRA specific data + const escapedWords = modelType === 'lora' && model.civitai?.trainedWords?.length ? + model.civitai.trainedWords.map(word => word.replace(/'/g, '\\\'')) : []; + + // Generate model type specific content + const typeSpecificContent = modelType === 'lora' ? renderLoraSpecificContent(model, escapedWords) : ''; + + // Generate tabs based on model type + const tabsContent = modelType === 'lora' ? + ` + + ` : + ` + `; + + const tabPanesContent = modelType === 'lora' ? + `
+
+ Loading example images... +
+
+ +
+
+
+ Loading model description... +
+
+ ${model.modelDescription || ''} +
+
+
+ +
+
+ Loading recipes... +
+
` : + `
+
+ Loading examples... +
+
+ +
+
+
+ Loading model description... +
+
+ ${model.modelDescription || ''} +
+
+
`; + + const content = ` + + `; + + const onCloseCallback = function() { + // Clean up all handlers when modal closes for LoRA + const modalElement = document.getElementById(modalId); + if (modalElement && modalElement._clickHandler) { + modalElement.removeEventListener('click', modalElement._clickHandler); + delete modalElement._clickHandler; + } + }; + + modalManager.showModal(modalId, content, null, onCloseCallback); + setupEditableFields(model.file_path, modelType); + setupShowcaseScroll(modalId); + setupTabSwitching(); + setupTagTooltip(); + setupTagEditMode(); + setupModelNameEditing(model.file_path); + setupBaseModelEditing(model.file_path); + setupFileNameEditing(model.file_path); + setupEventHandlers(model.file_path); + + // LoRA specific setup + if (modelType === 'lora') { + setupTriggerWordsEditMode(); + + // Load recipes for this LoRA + loadRecipesForLora(model.model_name, model.sha256); + } + + // If we have a model ID but no description, fetch it + if (model.civitai?.modelId && !model.modelDescription) { + loadModelDescription(model.civitai.modelId, model.file_path); + } + + // Load example images asynchronously - merge regular and custom images + const regularImages = model.civitai?.images || []; + const customImages = model.civitai?.customImages || []; + // Combine images - regular images first, then custom images + const allImages = [...regularImages, ...customImages]; + loadExampleImages(allImages, model.sha256); +} + +function renderLoraSpecificContent(lora, escapedWords) { + return ` +
+ +
+
+ + + +
+
+ ${renderPresetTags(parsePresets(lora.usage_tips))} +
+
+
+ ${renderTriggerWords(escapedWords, lora.file_path)} + `; +} + +/** + * Sets up event handlers using event delegation for LoRA modal + * @param {string} filePath - Path to the model file + */ +function setupEventHandlers(filePath) { + const modalElement = document.getElementById('modelModal'); + + // Remove existing event listeners first + modalElement.removeEventListener('click', handleModalClick); + + // Create and store the handler function + function handleModalClick(event) { + const target = event.target.closest('[data-action]'); + if (!target) return; + + const action = target.dataset.action; + + switch (action) { + case 'close-modal': + modalManager.closeModal('modelModal'); + break; + case 'scroll-to-top': + scrollToTop(target); + break; + } + } + + // Add the event listener with the named function + modalElement.addEventListener('click', handleModalClick); + + // Store reference to the handler on the element for potential cleanup + modalElement._clickHandler = handleModalClick; +} + +/** + * Set up editable fields in the model modal + * @param {string} filePath - The full file path of the model + * @param {string} modelType - Type of model ('lora' or 'checkpoint') + */ +function setupEditableFields(filePath, modelType) { + const editableFields = document.querySelectorAll('.editable-field [contenteditable]'); + + editableFields.forEach(field => { + field.addEventListener('focus', function() { + if (this.textContent === 'Add your notes here...') { + this.textContent = ''; + } + }); + + field.addEventListener('blur', function() { + if (this.textContent.trim() === '') { + if (this.classList.contains('notes-content')) { + this.textContent = 'Add your notes here...'; + } + } + }); + }); + + // Add keydown event listeners for notes + const notesContent = document.querySelector('.notes-content'); + if (notesContent) { + notesContent.addEventListener('keydown', async function(e) { + if (e.key === 'Enter') { + if (e.shiftKey) { + // Allow shift+enter for new line + return; + } + e.preventDefault(); + await saveNotes(filePath, modelType); + } + }); + } + + // LoRA specific field setup + if (modelType === 'lora') { + setupLoraSpecificFields(filePath); + } +} + +function setupLoraSpecificFields(filePath) { + const presetSelector = document.getElementById('preset-selector'); + const presetValue = document.getElementById('preset-value'); + const addPresetBtn = document.querySelector('.add-preset-btn'); + const presetTags = document.querySelector('.preset-tags'); + + if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return; + + presetSelector.addEventListener('change', function() { + const selected = this.value; + if (selected) { + presetValue.style.display = 'inline-block'; + presetValue.min = selected.includes('strength') ? -10 : 0; + presetValue.max = selected.includes('strength') ? 10 : 10; + presetValue.step = 0.5; + if (selected === 'clip_skip') { + presetValue.type = 'number'; + presetValue.step = 1; + } + // Add auto-focus + setTimeout(() => presetValue.focus(), 0); + } else { + presetValue.style.display = 'none'; + } + }); + + addPresetBtn.addEventListener('click', async function() { + const key = presetSelector.value; + const value = presetValue.value; + + if (!key || !value) return; + + const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + const currentPresets = parsePresets(loraCard?.dataset.usage_tips); + + currentPresets[key] = parseFloat(value); + const newPresetsJson = JSON.stringify(currentPresets); + + await saveLoraMetadata(filePath, { + usage_tips: newPresetsJson + }); + + presetTags.innerHTML = renderPresetTags(currentPresets); + + presetSelector.value = ''; + presetValue.value = ''; + presetValue.style.display = 'none'; + }); + + // Add keydown event for preset value + presetValue.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + addPresetBtn.click(); + } + }); +} + +/** + * Save model notes + * @param {string} filePath - Path to the model file + * @param {string} modelType - Type of model ('lora' or 'checkpoint') + */ +async function saveNotes(filePath, modelType) { + const content = document.querySelector('.notes-content').textContent; + try { + const saveFunction = modelType === 'lora' ? saveLoraMetadata : saveCheckpointMetadata; + await saveFunction(filePath, { notes: content }); + + showToast('Notes saved successfully', 'success'); + } catch (error) { + showToast('Failed to save notes', 'error'); + } +} + +// Export the model modal API +const modelModal = { + show: showModelModal, + toggleShowcase, + scrollToTop +}; + +export { modelModal }; + +// Define global functions for use in HTML +window.toggleShowcase = function(element) { + toggleShowcase(element); +}; + +window.scrollToTopModel = function(button) { + scrollToTop(button); +}; + +// Legacy global functions for backward compatibility +window.scrollToTopLora = function(button) { + scrollToTop(button); +}; + +window.scrollToTopCheckpoint = function(button) { + scrollToTop(button); +}; + +window.saveModelNotes = function(filePath, modelType) { + saveNotes(filePath, modelType); +}; + +// Legacy functions +window.saveLoraNotes = function(filePath) { + saveNotes(filePath, 'lora'); +}; + +window.saveCheckpointNotes = function(filePath) { + saveNotes(filePath, 'checkpoint'); +}; \ No newline at end of file diff --git a/static/js/components/loraModal/ModelTags.js b/static/js/components/shared/ModelTags.js similarity index 96% rename from static/js/components/loraModal/ModelTags.js rename to static/js/components/shared/ModelTags.js index 97ef62b7..22f47464 100644 --- a/static/js/components/loraModal/ModelTags.js +++ b/static/js/components/shared/ModelTags.js @@ -1,9 +1,10 @@ /** * ModelTags.js - * Module for handling model tag editing functionality + * Module for handling model tag editing functionality - 共享版本 */ import { showToast } from '../../utils/uiHelpers.js'; -import { saveModelMetadata } from '../../api/loraApi.js'; +import { saveModelMetadata as saveLoraMetadata } from '../../api/loraApi.js'; +import { saveModelMetadata as saveCheckpointMetadata } from '../../api/checkpointApi.js'; // Preset tag suggestions const PRESET_TAGS = [ @@ -135,6 +136,98 @@ export function setupTagEditMode() { document.addEventListener('click', saveTagsHandler); } +// ...existing helper functions... + +/** + * Save tags - 支持LoRA和Checkpoint + */ +async function saveTags() { + const editBtn = document.querySelector('.edit-tags-btn'); + if (!editBtn) return; + + const filePath = editBtn.dataset.filePath; + const tagElements = document.querySelectorAll('.metadata-item'); + const tags = Array.from(tagElements).map(tag => tag.dataset.tag); + + // Get original tags to compare + const originalTagElements = document.querySelectorAll('.tooltip-tag'); + const originalTags = Array.from(originalTagElements).map(tag => tag.textContent); + + // Check if tags have actually changed + const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags); + + if (!tagsChanged) { + // No changes made, just exit edit mode without API call + editBtn.dataset.skipRestore = "true"; + editBtn.click(); + return; + } + + try { + // Determine model type and use appropriate save function + const isCheckpoint = filePath.includes('.safetensors') || filePath.includes('.ckpt'); + const saveFunction = isCheckpoint ? saveCheckpointMetadata : saveLoraMetadata; + + // Save tags metadata + await saveFunction(filePath, { tags: tags }); + + // Set flag to skip restoring original tags when exiting edit mode + editBtn.dataset.skipRestore = "true"; + + // Update the compact tags display + const compactTagsContainer = document.querySelector('.model-tags-container'); + if (compactTagsContainer) { + // Generate new compact tags HTML + const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact'); + + if (compactTagsDisplay) { + // Clear current tags + compactTagsDisplay.innerHTML = ''; + + // Add visible tags (up to 5) + const visibleTags = tags.slice(0, 5); + visibleTags.forEach(tag => { + const span = document.createElement('span'); + span.className = 'model-tag-compact'; + span.textContent = tag; + compactTagsDisplay.appendChild(span); + }); + + // Add more indicator if needed + const remainingCount = Math.max(0, tags.length - 5); + if (remainingCount > 0) { + const more = document.createElement('span'); + more.className = 'model-tag-more'; + more.dataset.count = remainingCount; + more.textContent = `+${remainingCount}`; + compactTagsDisplay.appendChild(more); + } + } + + // Update tooltip content + const tooltipContent = compactTagsContainer.querySelector('.tooltip-content'); + if (tooltipContent) { + tooltipContent.innerHTML = ''; + + tags.forEach(tag => { + const span = document.createElement('span'); + span.className = 'tooltip-tag'; + span.textContent = tag; + tooltipContent.appendChild(span); + }); + } + } + + // Exit edit mode + editBtn.click(); + + showToast('Tags updated successfully', 'success'); + } catch (error) { + console.error('Error saving tags:', error); + showToast('Failed to update tags', 'error'); + } +} + /** * Create the tag editing UI * @param {Array} currentTags - Current tags @@ -382,90 +475,4 @@ function updateSuggestionsDropdown() { function restoreOriginalTags(section, originalTags) { // Nothing to do here as we're just hiding the edit UI // and showing the original compact tags which weren't modified -} - -/** - * Save tags - */ -async function saveTags() { - const editBtn = document.querySelector('.edit-tags-btn'); - if (!editBtn) return; - - const filePath = editBtn.dataset.filePath; - const tagElements = document.querySelectorAll('.metadata-item'); - const tags = Array.from(tagElements).map(tag => tag.dataset.tag); - - // Get original tags to compare - const originalTagElements = document.querySelectorAll('.tooltip-tag'); - const originalTags = Array.from(originalTagElements).map(tag => tag.textContent); - - // Check if tags have actually changed - const tagsChanged = JSON.stringify(tags) !== JSON.stringify(originalTags); - - if (!tagsChanged) { - // No changes made, just exit edit mode without API call - editBtn.dataset.skipRestore = "true"; - editBtn.click(); - return; - } - - try { - // Save tags metadata - await saveModelMetadata(filePath, { tags: tags }); - - // Set flag to skip restoring original tags when exiting edit mode - editBtn.dataset.skipRestore = "true"; - - // Update the compact tags display - const compactTagsContainer = document.querySelector('.model-tags-container'); - if (compactTagsContainer) { - // Generate new compact tags HTML - const compactTagsDisplay = compactTagsContainer.querySelector('.model-tags-compact'); - - if (compactTagsDisplay) { - // Clear current tags - compactTagsDisplay.innerHTML = ''; - - // Add visible tags (up to 5) - const visibleTags = tags.slice(0, 5); - visibleTags.forEach(tag => { - const span = document.createElement('span'); - span.className = 'model-tag-compact'; - span.textContent = tag; - compactTagsDisplay.appendChild(span); - }); - - // Add more indicator if needed - const remainingCount = Math.max(0, tags.length - 5); - if (remainingCount > 0) { - const more = document.createElement('span'); - more.className = 'model-tag-more'; - more.dataset.count = remainingCount; - more.textContent = `+${remainingCount}`; - compactTagsDisplay.appendChild(more); - } - } - - // Update tooltip content - const tooltipContent = compactTagsContainer.querySelector('.tooltip-content'); - if (tooltipContent) { - tooltipContent.innerHTML = ''; - - tags.forEach(tag => { - const span = document.createElement('span'); - span.className = 'tooltip-tag'; - span.textContent = tag; - tooltipContent.appendChild(span); - }); - } - } - - // Exit edit mode - editBtn.click(); - - showToast('Tags updated successfully', 'success'); - } catch (error) { - console.error('Error saving tags:', error); - showToast('Failed to update tags', 'error'); - } -} +} \ No newline at end of file diff --git a/static/js/components/loraModal/PresetTags.js b/static/js/components/shared/PresetTags.js similarity index 67% rename from static/js/components/loraModal/PresetTags.js rename to static/js/components/shared/PresetTags.js index dc08dd20..12d75c1f 100644 --- a/static/js/components/loraModal/PresetTags.js +++ b/static/js/components/shared/PresetTags.js @@ -1,13 +1,13 @@ /** * PresetTags.js - * 处理LoRA模型预设参数标签相关的功能模块 + * Handles LoRA model preset parameter tags - Shared version */ import { saveModelMetadata } from '../../api/loraApi.js'; /** - * 解析预设参数 - * @param {string} usageTips - 包含预设参数的JSON字符串 - * @returns {Object} 解析后的预设参数对象 + * Parse preset parameters + * @param {string} usageTips - JSON string containing preset parameters + * @returns {Object} Parsed preset parameters object */ export function parsePresets(usageTips) { if (!usageTips) return {}; @@ -19,9 +19,9 @@ export function parsePresets(usageTips) { } /** - * 渲染预设标签 - * @param {Object} presets - 预设参数对象 - * @returns {string} HTML内容 + * Render preset tags + * @param {Object} presets - Preset parameters object + * @returns {string} HTML content */ export function renderPresetTags(presets) { return Object.entries(presets).map(([key, value]) => ` @@ -33,9 +33,9 @@ export function renderPresetTags(presets) { } /** - * 格式化预设键名 - * @param {string} key - 预设键名 - * @returns {string} 格式化后的键名 + * Format preset key name + * @param {string} key - Preset key name + * @returns {string} Formatted key name */ function formatPresetKey(key) { return key.split('_').map(word => @@ -44,13 +44,13 @@ function formatPresetKey(key) { } /** - * 移除预设参数 - * @param {string} key - 要移除的预设键名 + * Remove preset parameter + * @param {string} key - Preset key name to remove */ window.removePreset = async function(key) { - const filePath = document.querySelector('#loraModal .modal-content') + const filePath = document.querySelector('#modelModal .modal-content') .querySelector('.file-path').textContent + - document.querySelector('#loraModal .modal-content') + document.querySelector('#modelModal .modal-content') .querySelector('#file-name').textContent + '.safetensors'; const loraCard = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); const currentPresets = parsePresets(loraCard.dataset.usage_tips); diff --git a/static/js/components/loraModal/RecipeTab.js b/static/js/components/shared/RecipeTab.js similarity index 97% rename from static/js/components/loraModal/RecipeTab.js rename to static/js/components/shared/RecipeTab.js index 1ccc4c8e..661d4169 100644 --- a/static/js/components/loraModal/RecipeTab.js +++ b/static/js/components/shared/RecipeTab.js @@ -1,5 +1,6 @@ /** - * RecipeTab - Handles the recipes tab in the Lora Modal + * RecipeTab - Handles the recipes tab in model modals (LoRA specific functionality) + * Moved to shared directory for consistency */ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; @@ -189,7 +190,7 @@ function copyRecipeSyntax(recipeId) { function navigateToRecipesPage(loraName, loraHash) { // Close the current modal if (window.modalManager) { - modalManager.closeModal('loraModal'); + modalManager.closeModal('modelModal'); } // Clear any previous filters first @@ -212,7 +213,7 @@ function navigateToRecipesPage(loraName, loraHash) { function navigateToRecipeDetails(recipeId) { // Close the current modal if (window.modalManager) { - modalManager.closeModal('loraModal'); + modalManager.closeModal('modelModal'); } // Clear any previous filters first diff --git a/static/js/components/loraModal/TriggerWords.js b/static/js/components/shared/TriggerWords.js similarity index 99% rename from static/js/components/loraModal/TriggerWords.js rename to static/js/components/shared/TriggerWords.js index 3607c649..e011f700 100644 --- a/static/js/components/loraModal/TriggerWords.js +++ b/static/js/components/shared/TriggerWords.js @@ -1,6 +1,7 @@ /** * TriggerWords.js * Module that handles trigger word functionality for LoRA models + * Moved to shared directory for consistency */ import { showToast, copyToClipboard } from '../../utils/uiHelpers.js'; import { saveModelMetadata } from '../../api/loraApi.js'; diff --git a/static/js/components/checkpointModal/utils.js b/static/js/components/shared/utils.js similarity index 89% rename from static/js/components/checkpointModal/utils.js rename to static/js/components/shared/utils.js index fdf71ea3..0e5a5c1f 100644 --- a/static/js/components/checkpointModal/utils.js +++ b/static/js/components/shared/utils.js @@ -1,18 +1,16 @@ /** * utils.js - * CheckpointModal component utility functions + * Helper functions for the Model Modal component - General version */ -import { showToast } from '../../utils/uiHelpers.js'; /** - * Format file size for display - * @param {number} bytes - File size in bytes - * @returns {string} - Formatted file size + * Format file size + * @param {number} bytes - Number of bytes + * @returns {string} Formatted file size */ export function formatFileSize(bytes) { if (!bytes) return 'N/A'; - - const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const units = ['B', 'KB', 'MB', 'GB']; let size = bytes; let unitIndex = 0; diff --git a/static/js/core.js b/static/js/core.js index c5415094..674b484a 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -10,7 +10,6 @@ import { helpManager } from './managers/HelpManager.js'; import { showToast, initTheme, initBackToTop } from './utils/uiHelpers.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { migrateStorageItems } from './utils/storageHelpers.js'; -import { setupLoraCardEventDelegation } from './components/LoraCard.js'; // Core application class export class AppCore { @@ -68,11 +67,6 @@ export class AppCore { initializePageFeatures() { const pageType = this.getPageType(); - // Setup event delegation for lora cards if on the loras page - if (pageType === 'loras') { - setupLoraCardEventDelegation(); - } - // Initialize virtual scroll for pages that need it if (['loras', 'recipes', 'checkpoints'].includes(pageType)) { initializeInfiniteScroll(pageType); diff --git a/static/js/loras.js b/static/js/loras.js index 131abed8..c5e2f77a 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -1,6 +1,5 @@ import { appCore } from './core.js'; import { state } from './state/index.js'; -import { showLoraModal } from './components/loraModal/index.js'; import { loadMoreLoras } from './api/loraApi.js'; import { updateCardsForBulkMode } from './components/LoraCard.js'; import { bulkManager } from './managers/BulkManager.js'; @@ -36,7 +35,6 @@ class LoraPageManager { // Only expose what's still needed globally // Most functionality is now handled by the PageControls component window.loadMoreLoras = loadMoreLoras; - window.showLoraModal = showLoraModal; window.confirmDelete = confirmDelete; window.closeDeleteModal = closeDeleteModal; window.confirmExclude = confirmExclude; @@ -70,12 +68,6 @@ class LoraPageManager { // Initialize common page features (virtual scroll) appCore.initializePageFeatures(); - - // Add virtual scroll class to grid for CSS adjustments - const loraGrid = document.getElementById('loraGrid'); - if (loraGrid && state.virtualScroller) { - loraGrid.classList.add('virtual-scroll'); - } } } diff --git a/static/js/managers/ModalManager.js b/static/js/managers/ModalManager.js index 356f1272..9f56c269 100644 --- a/static/js/managers/ModalManager.js +++ b/static/js/managers/ModalManager.js @@ -12,25 +12,12 @@ export class ModalManager { this.boundHandleEscape = this.handleEscape.bind(this); // Register all modals - only if they exist in the current page - const loraModal = document.getElementById('loraModal'); - if (loraModal) { - this.registerModal('loraModal', { - element: loraModal, + const modelModal = document.getElementById('modelModal'); + if (modelModal) { + this.registerModal('modelModal', { + element: modelModal, onClose: () => { - this.getModal('loraModal').element.style.display = 'none'; - document.body.classList.remove('modal-open'); - }, - closeOnOutsideClick: true - }); - } - - // Add checkpointModal registration - const checkpointModal = document.getElementById('checkpointModal'); - if (checkpointModal) { - this.registerModal('checkpointModal', { - element: checkpointModal, - onClose: () => { - this.getModal('checkpointModal').element.style.display = 'none'; + this.getModal('modelModal').element.style.display = 'none'; document.body.classList.remove('modal-open'); }, closeOnOutsideClick: true diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index f92f5849..48876487 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -82,11 +82,9 @@ async function initializeVirtualScroll(pageType) { gridId = 'recipeGrid'; break; case 'checkpoints': - gridId = 'checkpointGrid'; - break; case 'loras': default: - gridId = 'loraGrid'; + gridId = 'modelGrid'; break; } diff --git a/templates/checkpoints.html b/templates/checkpoints.html index 115399d4..47f8295a 100644 --- a/templates/checkpoints.html +++ b/templates/checkpoints.html @@ -55,7 +55,7 @@ -
+
{% endblock %} diff --git a/templates/components/checkpoint_modals.html b/templates/components/checkpoint_modals.html index 603ba2b3..b2e2be49 100644 --- a/templates/components/checkpoint_modals.html +++ b/templates/components/checkpoint_modals.html @@ -1,7 +1,7 @@ - +