From 129ca9da81e5930b66fe8d8d610349df553c9d07 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Thu, 10 Apr 2025 22:59:09 +0800 Subject: [PATCH] feat: Implement checkpoint modal functionality with metadata editing, showcase display, and utility functions - Added ModelMetadata.js for handling model metadata editing, including model name, base model, and file name. - Introduced ShowcaseView.js to manage the display of images and videos in the checkpoint modal, including NSFW filtering and lazy loading. - Created index.js as the main entry point for the checkpoint modal, integrating various components and functionalities. - Developed utils.js for utility functions related to file size formatting and tag rendering. - Enhanced user experience with editable fields, toast notifications, and improved showcase scrolling. --- static/js/components/CheckpointCard.js | 14 +- static/js/components/CheckpointModal.js | 990 ------------------ .../checkpointModal/ModelDescription.js | 102 ++ .../checkpointModal/ModelMetadata.js | 492 +++++++++ .../checkpointModal/ShowcaseView.js | 489 +++++++++ static/js/components/checkpointModal/index.js | 219 ++++ static/js/components/checkpointModal/utils.js | 74 ++ 7 files changed, 1384 insertions(+), 996 deletions(-) delete mode 100644 static/js/components/CheckpointModal.js create mode 100644 static/js/components/checkpointModal/ModelDescription.js create mode 100644 static/js/components/checkpointModal/ModelMetadata.js create mode 100644 static/js/components/checkpointModal/ShowcaseView.js create mode 100644 static/js/components/checkpointModal/index.js create mode 100644 static/js/components/checkpointModal/utils.js diff --git a/static/js/components/CheckpointCard.js b/static/js/components/CheckpointCard.js index b8925b52..f7031c15 100644 --- a/static/js/components/CheckpointCard.js +++ b/static/js/components/CheckpointCard.js @@ -1,11 +1,8 @@ import { showToast } from '../utils/uiHelpers.js'; import { state } from '../state/index.js'; -import { CheckpointModal } from './CheckpointModal.js'; +import { showCheckpointModal } from './checkpointModal/index.js'; import { NSFW_LEVELS } from '../utils/constants.js'; -// Create an instance of the modal -const checkpointModal = new CheckpointModal(); - export function createCheckpointCard(checkpoint) { const card = document.createElement('div'); card.className = 'lora-card'; // Reuse the same class for styling @@ -29,6 +26,10 @@ export function createCheckpointCard(checkpoint) { 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; @@ -139,9 +140,10 @@ export function createCheckpointCard(checkpoint) { console.error('Failed to parse tags:', e); return []; // Return empty array on error } - })() + })(), + modelDescription: card.dataset.modelDescription || '' }; - checkpointModal.showCheckpointDetails(checkpointMeta); + showCheckpointModal(checkpointMeta); }); // Toggle blur button functionality diff --git a/static/js/components/CheckpointModal.js b/static/js/components/CheckpointModal.js deleted file mode 100644 index 160b274d..00000000 --- a/static/js/components/CheckpointModal.js +++ /dev/null @@ -1,990 +0,0 @@ -import { showToast } from '../utils/uiHelpers.js'; -import { BASE_MODELS } from '../utils/constants.js'; - -/** - * CheckpointModal - Component for displaying checkpoint details - * Similar to LoraModal but customized for checkpoint models - */ -export class CheckpointModal { - constructor() { - this.modal = document.getElementById('checkpointModal'); - this.modalTitle = document.getElementById('checkpointModalTitle'); - this.modalContent = document.getElementById('checkpointModalContent'); - this.currentCheckpoint = null; - - // Initialize close events - this._initCloseEvents(); - } - - _initCloseEvents() { - if (!this.modal) return; - - // Close button - const closeBtn = this.modal.querySelector('.close'); - if (closeBtn) { - closeBtn.addEventListener('click', () => this.close()); - } - - // Click outside to close - this.modal.addEventListener('click', (e) => { - if (e.target === this.modal) { - this.close(); - } - }); - } - - /** - * Format file size for display - * @param {number} bytes - File size in bytes - * @returns {string} - Formatted file size - */ - _formatFileSize(bytes) { - if (!bytes) return 'Unknown'; - - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - if (bytes === 0) return '0 Bytes'; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - if (i === 0) return `${bytes} ${sizes[i]}`; - return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`; - } - - /** - * Render compact tags for the checkpoint - * @param {Array} tags - Array of tags - * @returns {string} - HTML for tags - */ - _renderCompactTags(tags) { - if (!tags || tags.length === 0) return ''; - - // Display up to 5 tags, with a count if there are more - const visibleTags = tags.slice(0, 5); - const remainingCount = Math.max(0, tags.length - 5); - - return ` -
-
- ${visibleTags.map(tag => `${tag}`).join('')} - ${remainingCount > 0 ? - `+${remainingCount}` : - ''} -
- ${tags.length > 0 ? - `
-
- ${tags.map(tag => `${tag}`).join('')} -
-
` : - ''} -
- `; - } - - /** - * Set up tag tooltip functionality - */ - _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'); - }); - } - } - - /** - * Render showcase content (example images) - * @param {Array} images - Array of image data - * @returns {string} - HTML content - */ - _renderShowcaseContent(images) { - if (!images?.length) return '
No example images available
'; - - return ` -
- - Scroll or click to show ${images.length} examples -
- - `; - } - - /** - * Show checkpoint details in the modal - * @param {Object} checkpoint - Checkpoint data - */ - showCheckpointDetails(checkpoint) { - if (!this.modal) { - console.error('Checkpoint modal element not found'); - return; - } - - this.currentCheckpoint = checkpoint; - - const content = ` - - `; - - this.modal.innerHTML = content; - this.modal.style.display = 'block'; - - this._setupEditableFields(); - this._setupShowcaseScroll(); - this._setupTabSwitching(); - this._setupTagTooltip(); - this._setupModelNameEditing(); - this._setupBaseModelEditing(); - this._setupFileNameEditing(); - - // If we have a model ID but no description, fetch it - if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) { - this._loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path); - } - } - - /** - * Close the checkpoint modal - */ - close() { - if (this.modal) { - this.modal.style.display = 'none'; - this.currentCheckpoint = null; - } - } - - /** - * Set up editable fields in the modal - */ - _setupEditableFields() { - const editableFields = this.modal.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 = this.modal.querySelector('.notes-content'); - if (notesContent) { - notesContent.addEventListener('keydown', async (e) => { - if (e.key === 'Enter') { - if (e.shiftKey) { - // Allow shift+enter for new line - return; - } - e.preventDefault(); - const filePath = this.modal.querySelector('.file-path').textContent + - this.modal.querySelector('#file-name').textContent; - await this._saveNotes(filePath); - } - }); - } - } - - /** - * Save notes for the checkpoint - * @param {string} filePath - Path to the checkpoint file - */ - async _saveNotes(filePath) { - const content = this.modal.querySelector('.notes-content').textContent; - try { - // This would typically call an API endpoint to save the notes - // For now we'll just show a success message - console.log('Would save notes:', content, 'for file:', filePath); - - showToast('Notes saved successfully', 'success'); - } catch (error) { - showToast('Failed to save notes', 'error'); - } - } - - /** - * Set up model name editing functionality - */ - _setupModelNameEditing() { - const modelNameContent = this.modal.querySelector('.model-name-content'); - const editBtn = this.modal.querySelector('.edit-model-name-btn'); - - if (!modelNameContent || !editBtn) return; - - // Show edit button on hover - const modelNameHeader = this.modal.querySelector('.model-name-header'); - modelNameHeader.addEventListener('mouseenter', () => { - editBtn.classList.add('visible'); - }); - - modelNameHeader.addEventListener('mouseleave', () => { - if (!modelNameContent.getAttribute('data-editing')) { - editBtn.classList.remove('visible'); - } - }); - - // Handle edit button click - editBtn.addEventListener('click', () => { - modelNameContent.setAttribute('data-editing', 'true'); - 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 focus out - modelNameContent.addEventListener('blur', function() { - this.removeAttribute('data-editing'); - editBtn.classList.remove('visible'); - - if (this.textContent.trim() === '') { - // Restore original model name if empty - this.textContent = 'Checkpoint Details'; - } - }); - - // Handle enter key - modelNameContent.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - modelNameContent.blur(); - // Save model name here (would call an API endpoint) - showToast('Model name updated', 'success'); - } - }); - - // Limit model name length - modelNameContent.addEventListener('input', function() { - 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'); - } - }); - } - - /** - * Set up base model editing functionality - */ - _setupBaseModelEditing() { - const baseModelContent = this.modal.querySelector('.base-model-content'); - const editBtn = this.modal.querySelector('.edit-base-model-btn'); - - if (!baseModelContent || !editBtn) return; - - // Show edit button on hover - const baseModelDisplay = this.modal.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.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO], - 'Other Models': [ - BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, 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.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) { - // Get file path for saving - const filePath = document.querySelector('#checkpointModal .modal-content') - .querySelector('.file-path').textContent + - document.querySelector('#checkpointModal .modal-content') - .querySelector('#file-name').textContent + '.safetensors'; - - // Save the changes (would call API to save model base change) - showToast('Base model updated successfully', 'success'); - } - - // 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(); - } - }); - }); - } - - /** - * Set up file name editing functionality - */ - _setupFileNameEditing() { - const fileNameContent = this.modal.querySelector('.file-name-content'); - const editBtn = this.modal.querySelector('.edit-file-name-btn'); - - if (!fileNameContent || !editBtn) return; - - // Show edit button on hover - const fileNameWrapper = this.modal.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 - 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 - fileNameContent.addEventListener('keydown', function(e) { - if (!this.getAttribute('contenteditable')) return; - - if (e.key === 'Enter') { - e.preventDefault(); - this.blur(); - } 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', function() { - if (!this.getAttribute('contenteditable')) return; - - const newFileName = this.textContent.trim(); - const originalValue = this.dataset.originalValue; - - // Validation - if (!newFileName) { - this.textContent = originalValue; - showToast('File name cannot be empty', 'error'); - exitEditMode(); - return; - } - - if (newFileName !== originalValue) { - // Would call API to rename file - showToast(`File would be renamed to: ${newFileName}`, 'success'); - } - - exitEditMode(); - }); - - function exitEditMode() { - fileNameContent.removeAttribute('contenteditable'); - fileNameWrapper.classList.remove('editing'); - editBtn.classList.remove('visible'); - } - } - - /** - * Set up showcase scroll functionality - */ - _setupShowcaseScroll() { - // Initialize scroll listeners for showcase section - const showcaseSection = this.modal.querySelector('.showcase-section'); - if (!showcaseSection) return; - - // Set up back-to-top button - const backToTopBtn = showcaseSection.querySelector('.back-to-top'); - const modalContent = this.modal.querySelector('.modal-content'); - - if (backToTopBtn && modalContent) { - modalContent.addEventListener('scroll', () => { - if (modalContent.scrollTop > 300) { - backToTopBtn.classList.add('visible'); - } else { - backToTopBtn.classList.remove('visible'); - } - }); - } - - // Set up scroll to toggle showcase - document.addEventListener('wheel', (event) => { - if (this.modal.style.display !== 'block') return; - - const showcase = this.modal.querySelector('.showcase-section'); - if (!showcase) return; - - const carousel = showcase.querySelector('.carousel'); - const scrollIndicator = showcase.querySelector('.scroll-indicator'); - - if (carousel?.classList.contains('collapsed') && event.deltaY > 0) { - const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100; - - if (isNearBottom) { - this._toggleShowcase(scrollIndicator); - event.preventDefault(); - } - } - }, { passive: false }); - } - - /** - * Toggle showcase expansion - * @param {HTMLElement} element - The scroll indicator element - */ - _toggleShowcase(element) { - const carousel = element.nextElementSibling; - const isCollapsed = carousel.classList.contains('collapsed'); - const indicator = element.querySelector('span'); - const icon = element.querySelector('i'); - - carousel.classList.toggle('collapsed'); - - if (isCollapsed) { - const count = carousel.querySelectorAll('.media-wrapper').length; - indicator.textContent = `Scroll or click to hide examples`; - icon.classList.replace('fa-chevron-down', 'fa-chevron-up'); - this._initLazyLoading(carousel); - this._initMetadataPanelHandlers(carousel); - } else { - const count = carousel.querySelectorAll('.media-wrapper').length; - indicator.textContent = `Scroll or click to show ${count} examples`; - icon.classList.replace('fa-chevron-up', 'fa-chevron-down'); - } - } - - /** - * Initialize lazy loading for images - * @param {HTMLElement} container - Container with lazy-load images - */ - _initLazyLoading(container) { - const lazyImages = container.querySelectorAll('img.lazy'); - - const lazyLoad = (image) => { - image.src = image.dataset.src; - image.classList.remove('lazy'); - }; - - const observer = new IntersectionObserver((entries) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - lazyLoad(entry.target); - observer.unobserve(entry.target); - } - }); - }); - - lazyImages.forEach(image => observer.observe(image)); - } - - /** - * Initialize metadata panel handlers - * @param {HTMLElement} container - Container with metadata panels - */ - _initMetadataPanelHandlers(container) { - const mediaWrappers = container.querySelectorAll('.media-wrapper'); - - mediaWrappers.forEach(wrapper => { - const metadataPanel = wrapper.querySelector('.image-metadata-panel'); - if (!metadataPanel) return; - - // Prevent events from bubbling - metadataPanel.addEventListener('click', (e) => { - e.stopPropagation(); - }); - - // Handle copy prompt buttons - const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); - copyBtns.forEach(copyBtn => { - const promptIndex = copyBtn.dataset.promptIndex; - const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`); - - copyBtn.addEventListener('click', async (e) => { - e.stopPropagation(); - - if (!promptElement) return; - - try { - await navigator.clipboard.writeText(promptElement.textContent); - showToast('Prompt copied to clipboard', 'success'); - } catch (err) { - console.error('Copy failed:', err); - showToast('Copy failed', 'error'); - } - }); - }); - - // Prevent panel scroll from causing modal scroll - metadataPanel.addEventListener('wheel', (e) => { - e.stopPropagation(); - }); - }); - } - - /** - * Set up tab switching functionality - */ - _setupTabSwitching() { - const tabButtons = this.modal.querySelectorAll('.showcase-tabs .tab-btn'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - // Remove active class from all tabs - this.modal.querySelectorAll('.showcase-tabs .tab-btn').forEach(btn => - btn.classList.remove('active') - ); - this.modal.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`; - this.modal.querySelector(`#${tabId}`).classList.add('active'); - - // If switching to description tab, handle content - if (button.dataset.tab === 'description') { - const descriptionContent = this.modal.querySelector('.model-description-content'); - if (descriptionContent) { - const hasContent = descriptionContent.innerHTML.trim() !== ''; - this.modal.querySelector('.model-description-loading')?.classList.add('hidden'); - - if (!hasContent) { - descriptionContent.innerHTML = '
No model description available
'; - descriptionContent.classList.remove('hidden'); - } - } - } - }); - }); - } - - /** - * Load model description from API - * @param {string} modelId - Model ID - * @param {string} filePath - File path - */ - async _loadModelDescription(modelId, filePath) { - try { - const descriptionContainer = this.modal.querySelector('.model-description-content'); - const loadingElement = this.modal.querySelector('.model-description-loading'); - - if (!descriptionContainer || !loadingElement) return; - - // Show loading indicator - loadingElement.classList.remove('hidden'); - descriptionContainer.classList.add('hidden'); - - // In production, this would fetch from the API - // For now, just simulate loading - setTimeout(() => { - descriptionContainer.innerHTML = '

This is a placeholder for the checkpoint model description.

'; - - // Show the description and hide loading indicator - descriptionContainer.classList.remove('hidden'); - loadingElement.classList.add('hidden'); - }, 500); - } catch (error) { - console.error('Error loading model description:', error); - const loadingElement = this.modal.querySelector('.model-description-loading'); - if (loadingElement) { - loadingElement.innerHTML = `
Failed to load model description. ${error.message}
`; - } - - // Show empty state message - const descriptionContainer = this.modal.querySelector('.model-description-content'); - if (descriptionContainer) { - descriptionContainer.innerHTML = '
No model description available
'; - descriptionContainer.classList.remove('hidden'); - } - } - } - - /** - * Scroll to top of modal content - * @param {HTMLElement} button - The back to top button - */ - scrollToTop(button) { - const modalContent = button.closest('.modal-content'); - if (modalContent) { - modalContent.scrollTo({ - top: 0, - behavior: 'smooth' - }); - } - } -} - -// Create and export global instance -export const checkpointModal = new CheckpointModal(); - -// Add global functions for use in HTML -window.toggleShowcase = function(element) { - checkpointModal._toggleShowcase(element); -}; - -window.scrollToTopCheckpoint = function(button) { - checkpointModal.scrollToTop(button); -}; - -window.saveCheckpointNotes = function(filePath) { - checkpointModal._saveNotes(filePath); -}; \ No newline at end of file diff --git a/static/js/components/checkpointModal/ModelDescription.js b/static/js/components/checkpointModal/ModelDescription.js new file mode 100644 index 00000000..0f50fe8a --- /dev/null +++ b/static/js/components/checkpointModal/ModelDescription.js @@ -0,0 +1,102 @@ +/** + * ModelDescription.js + * Handles checkpoint model descriptions + */ +import { showToast } from '../../utils/uiHelpers.js'; + +/** + * Set up tab switching functionality + */ +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 loaded and displayed + 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'); + } + } + } + }); + }); +} + +/** + * Load model description from API + * @param {string} modelId - The Civitai model ID + * @param {string} filePath - File path for the model + */ +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/checkpoint-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/checkpointModal/ModelMetadata.js b/static/js/components/checkpointModal/ModelMetadata.js new file mode 100644 index 00000000..56e84266 --- /dev/null +++ b/static/js/components/checkpointModal/ModelMetadata.js @@ -0,0 +1,492 @@ +/** + * ModelMetadata.js + * Handles checkpoint model metadata editing functionality + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { BASE_MODELS } from '../../utils/constants.js'; + +/** + * Save model metadata to the server + * @param {string} filePath - Path to the model file + * @param {Object} data - Metadata to save + * @returns {Promise} - Promise that resolves with the server response + */ +export async function saveModelMetadata(filePath, data) { + const response = await fetch('/checkpoints/api/save-metadata', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + return response.json(); +} + +/** + * Set up model name editing functionality + */ +export function setupModelNameEditing() { + const modelNameContent = document.querySelector('.model-name-content'); + const editBtn = document.querySelector('.edit-model-name-btn'); + + if (!modelNameContent || !editBtn) return; + + // Show edit button on hover + const modelNameHeader = document.querySelector('.model-name-header'); + modelNameHeader.addEventListener('mouseenter', () => { + editBtn.classList.add('visible'); + }); + + modelNameHeader.addEventListener('mouseleave', () => { + if (!modelNameContent.getAttribute('data-editing')) { + editBtn.classList.remove('visible'); + } + }); + + // Handle edit button click + editBtn.addEventListener('click', () => { + modelNameContent.setAttribute('data-editing', 'true'); + 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 focus out + modelNameContent.addEventListener('blur', function() { + this.removeAttribute('data-editing'); + editBtn.classList.remove('visible'); + + if (this.textContent.trim() === '') { + // Restore original model name if empty + const filePath = document.querySelector('#checkpointModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#checkpointModal .modal-content') + .querySelector('#file-name').textContent; + const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); + if (checkpointCard) { + this.textContent = checkpointCard.dataset.model_name; + } + } + }); + + // Handle enter key + modelNameContent.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + const filePath = document.querySelector('#checkpointModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#checkpointModal .modal-content') + .querySelector('#file-name').textContent; + saveModelName(filePath); + this.blur(); + } + }); + + // Limit model name length + modelNameContent.addEventListener('input', function() { + 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'); + } + }); +} + +/** + * Save model name + * @param {string} filePath - File path + */ +async function saveModelName(filePath) { + const modelNameElement = document.querySelector('.model-name-content'); + const newModelName = modelNameElement.textContent.trim(); + + // Validate model name + if (!newModelName) { + showToast('Model name cannot be empty', 'error'); + return; + } + + // Check if model name is too long + if (newModelName.length > 100) { + showToast('Model name is too long (maximum 100 characters)', 'error'); + // Truncate the displayed text + modelNameElement.textContent = newModelName.substring(0, 100); + return; + } + + try { + await saveModelMetadata(filePath, { model_name: newModelName }); + + // Update the corresponding checkpoint card's dataset and display + const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); + if (checkpointCard) { + checkpointCard.dataset.model_name = newModelName; + const titleElement = checkpointCard.querySelector('.card-title'); + if (titleElement) { + titleElement.textContent = newModelName; + } + } + + showToast('Model name updated successfully', 'success'); + + // Reload the page to reflect the sorted order + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (error) { + showToast('Failed to update model name', 'error'); + } +} + +/** + * Set up base model editing functionality + */ +export function setupBaseModelEditing() { + const baseModelContent = document.querySelector('.base-model-content'); + const editBtn = document.querySelector('.edit-base-model-btn'); + + if (!baseModelContent || !editBtn) return; + + // 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.WAN_VIDEO, BASE_MODELS.HUNYUAN_VIDEO], + 'Other Models': [ + BASE_MODELS.FLUX_1_D, BASE_MODELS.FLUX_1_S, 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.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) { + // Get file path for saving + const filePath = document.querySelector('#checkpointModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#checkpointModal .modal-content') + .querySelector('#file-name').textContent; + + // Save the changes, passing the original value for comparison + 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 }); + + // Update the corresponding checkpoint card's dataset + const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); + if (checkpointCard) { + checkpointCard.dataset.base_model = newBaseModel; + } + + showToast('Base model updated successfully', 'success'); + } catch (error) { + showToast('Failed to update base model', 'error'); + } +} + +/** + * Set up file name editing functionality + */ +export function setupFileNameEditing() { + const fileNameContent = document.querySelector('.file-name-content'); + const editBtn = document.querySelector('.edit-file-name-btn'); + + if (!fileNameContent || !editBtn) return; + + // 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 { + // Get the full file path + const filePath = document.querySelector('#checkpointModal .modal-content') + .querySelector('.file-path').textContent + originalValue; + + // Call API to rename the file + const response = await fetch('/api/rename_checkpoint', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + new_file_name: newFileName + }) + }); + + const result = await response.json(); + + if (result.success) { + showToast('File name updated successfully', 'success'); + + // Update the checkpoint card with new file path + const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); + if (checkpointCard) { + const newFilePath = filePath.replace(originalValue, newFileName); + checkpointCard.dataset.filepath = newFilePath; + } + + // Reload the page after a short delay to reflect changes + setTimeout(() => { + window.location.reload(); + }, 1500); + } 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/ShowcaseView.js b/static/js/components/checkpointModal/ShowcaseView.js new file mode 100644 index 00000000..d9843fc3 --- /dev/null +++ b/static/js/components/checkpointModal/ShowcaseView.js @@ -0,0 +1,489 @@ +/** + * ShowcaseView.js + * Handles showcase content (images, videos) display for checkpoint modal + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { state } from '../../state/index.js'; +import { NSFW_LEVELS } from '../../utils/constants.js'; + +/** + * Render showcase content + * @param {Array} images - Array of images/videos to show + * @returns {string} HTML content + */ +export function renderShowcaseContent(images) { + if (!images?.length) return '
No example images available
'; + + // Filter images based on SFW setting + const showOnlySFW = state.settings.show_only_sfw; + let filteredImages = images; + let hiddenCount = 0; + + if (showOnlySFW) { + filteredImages = images.filter(img => { + const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0; + const isSfw = nsfwLevel < NSFW_LEVELS.R; + if (!isSfw) hiddenCount++; + return isSfw; + }); + } + + // Show message if no images are available after filtering + if (filteredImages.length === 0) { + return ` +
+

All example images are filtered due to NSFW content settings

+

Your settings are currently set to show only safe-for-work content

+

You can change this in Settings

+
+ `; + } + + // Show hidden content notification if applicable + const hiddenNotification = hiddenCount > 0 ? + `
+ ${hiddenCount} ${hiddenCount === 1 ? 'image' : 'images'} hidden due to SFW-only setting +
` : ''; + + return ` +
+ + Scroll or click to show ${filteredImages.length} examples +
+ + `; +} + +/** + * Generate media wrapper HTML for an image or video + * @param {Object} media - Media object with image or video data + * @returns {string} HTML content + */ +function generateMediaWrapper(media) { + // Calculate appropriate aspect ratio: + // 1. Keep original aspect ratio + // 2. Limit maximum height to 60% of viewport height + // 3. Ensure minimum height is 40% of container width + const aspectRatio = (media.height / media.width) * 100; + const containerWidth = 800; // modal content maximum width + const minHeightPercent = 40; + const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100; + const heightPercent = Math.max( + minHeightPercent, + Math.min(maxHeightPercent, aspectRatio) + ); + + // Check if media should be blurred + const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0; + const shouldBlur = state.settings.blurMatureContent && nsfwLevel > NSFW_LEVELS.PG13; + + // 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"; + } + + // Extract metadata from the media + const meta = media.meta || {}; + const prompt = meta.prompt || ''; + const negativePrompt = meta.negative_prompt || meta.negativePrompt || ''; + const size = meta.Size || `${media.width}x${media.height}`; + const seed = meta.seed || ''; + const model = meta.Model || ''; + const steps = meta.steps || ''; + const sampler = meta.sampler || ''; + const cfgScale = meta.cfgScale || ''; + const clipSkip = meta.clipSkip || ''; + + // Check if we have any meaningful generation parameters + const hasParams = seed || model || steps || sampler || cfgScale || clipSkip; + const hasPrompts = prompt || negativePrompt; + + // Create metadata panel content + const metadataPanel = generateMetadataPanel( + hasParams, hasPrompts, + prompt, negativePrompt, + size, seed, model, steps, sampler, cfgScale, clipSkip + ); + + // Check if this is a video or image + if (media.type === 'video') { + return generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel); + } + + return generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel); +} + +/** + * Generate metadata panel HTML + */ +function generateMetadataPanel(hasParams, hasPrompts, prompt, negativePrompt, size, seed, model, steps, sampler, cfgScale, clipSkip) { + // Create unique IDs for prompt copying + const promptIndex = Math.random().toString(36).substring(2, 15); + const negPromptIndex = Math.random().toString(36).substring(2, 15); + + let content = '
'; + + if (hasParams) { + content += ` +
+ ${size ? `
Size:${size}
` : ''} + ${seed ? `
Seed:${seed}
` : ''} + ${model ? `
Model:${model}
` : ''} + ${steps ? `
Steps:${steps}
` : ''} + ${sampler ? `
Sampler:${sampler}
` : ''} + ${cfgScale ? `
CFG:${cfgScale}
` : ''} + ${clipSkip ? `
Clip Skip:${clipSkip}
` : ''} +
+ `; + } + + if (!hasParams && !hasPrompts) { + content += ` + + `; + } + + if (prompt) { + content += ` + + + `; + } + + if (negativePrompt) { + content += ` + + + `; + } + + content += '
'; + return content; +} + +/** + * Generate video wrapper HTML + */ +function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel) { + return ` +
+ ${shouldBlur ? ` + + ` : ''} + + ${shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''} + ${metadataPanel} +
+ `; +} + +/** + * Generate image wrapper HTML + */ +function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel) { + return ` +
+ ${shouldBlur ? ` + + ` : ''} + Preview + ${shouldBlur ? ` +
+
+

${nsfwText}

+ +
+
+ ` : ''} + ${metadataPanel} +
+ `; +} + +/** + * Toggle showcase expansion + */ +export function toggleShowcase(element) { + const carousel = element.nextElementSibling; + const isCollapsed = carousel.classList.contains('collapsed'); + const indicator = element.querySelector('span'); + const icon = element.querySelector('i'); + + carousel.classList.toggle('collapsed'); + + if (isCollapsed) { + const count = carousel.querySelectorAll('.media-wrapper').length; + indicator.textContent = `Scroll or click to hide examples`; + icon.classList.replace('fa-chevron-down', 'fa-chevron-up'); + initLazyLoading(carousel); + + // Initialize NSFW content blur toggle handlers + initNsfwBlurHandlers(carousel); + + // Initialize metadata panel interaction handlers + initMetadataPanelHandlers(carousel); + } else { + const count = carousel.querySelectorAll('.media-wrapper').length; + indicator.textContent = `Scroll or click to show ${count} examples`; + icon.classList.replace('fa-chevron-up', 'fa-chevron-down'); + } +} + +/** + * Initialize metadata panel interaction handlers + */ +function initMetadataPanelHandlers(container) { + const mediaWrappers = container.querySelectorAll('.media-wrapper'); + + mediaWrappers.forEach(wrapper => { + const metadataPanel = wrapper.querySelector('.image-metadata-panel'); + if (!metadataPanel) return; + + // Prevent events from bubbling + metadataPanel.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + // Handle copy prompt buttons + const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn'); + copyBtns.forEach(copyBtn => { + const promptIndex = copyBtn.dataset.promptIndex; + const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`); + + copyBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + + if (!promptElement) return; + + try { + await navigator.clipboard.writeText(promptElement.textContent); + showToast('Prompt copied to clipboard', 'success'); + } catch (err) { + console.error('Copy failed:', err); + showToast('Copy failed', 'error'); + } + }); + }); + + // Prevent panel scroll from causing modal scroll + metadataPanel.addEventListener('wheel', (e) => { + e.stopPropagation(); + }); + }); +} + +/** + * Initialize blur toggle handlers + */ +function initNsfwBlurHandlers(container) { + // Handle toggle blur buttons + const toggleButtons = container.querySelectorAll('.toggle-blur-btn'); + toggleButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const wrapper = btn.closest('.media-wrapper'); + const media = wrapper.querySelector('img, video'); + const isBlurred = media.classList.toggle('blurred'); + const icon = btn.querySelector('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 = wrapper.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = isBlurred ? 'flex' : 'none'; + } + }); + }); + + // Handle "Show" buttons in overlays + const showButtons = container.querySelectorAll('.show-content-btn'); + showButtons.forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const wrapper = btn.closest('.media-wrapper'); + const media = wrapper.querySelector('img, video'); + media.classList.remove('blurred'); + + // Update the toggle button icon + const toggleBtn = wrapper.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + + // Hide the overlay + const overlay = wrapper.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } + }); + }); +} + +/** + * Initialize lazy loading for images and videos + */ +function initLazyLoading(container) { + const lazyElements = container.querySelectorAll('.lazy'); + + const lazyLoad = (element) => { + if (element.tagName.toLowerCase() === 'video') { + element.src = element.dataset.src; + element.querySelector('source').src = element.dataset.src; + element.load(); + } else { + element.src = element.dataset.src; + } + element.classList.remove('lazy'); + }; + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + lazyLoad(entry.target); + observer.unobserve(entry.target); + } + }); + }); + + lazyElements.forEach(element => observer.observe(element)); +} + +/** + * Set up showcase scroll functionality + */ +export function setupShowcaseScroll() { + // Listen for wheel events + document.addEventListener('wheel', (event) => { + const modalContent = document.querySelector('#checkpointModal .modal-content'); + if (!modalContent) return; + + const showcase = modalContent.querySelector('.showcase-section'); + if (!showcase) return; + + const carousel = showcase.querySelector('.carousel'); + const scrollIndicator = showcase.querySelector('.scroll-indicator'); + + if (carousel?.classList.contains('collapsed') && event.deltaY > 0) { + const isNearBottom = modalContent.scrollHeight - modalContent.scrollTop - modalContent.clientHeight < 100; + + if (isNearBottom) { + toggleShowcase(scrollIndicator); + event.preventDefault(); + } + } + }, { passive: false }); + + // Use MutationObserver to set up back-to-top button when modal content is added + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + if (mutation.type === 'childList' && mutation.addedNodes.length) { + const checkpointModal = document.getElementById('checkpointModal'); + if (checkpointModal && checkpointModal.querySelector('.modal-content')) { + setupBackToTopButton(checkpointModal.querySelector('.modal-content')); + } + } + } + }); + + // Start observing the document body for changes + observer.observe(document.body, { childList: true, subtree: true }); + + // Also try to set up the button immediately in case the modal is already open + const modalContent = document.querySelector('#checkpointModal .modal-content'); + if (modalContent) { + setupBackToTopButton(modalContent); + } +} + +/** + * Set up back-to-top button + */ +function setupBackToTopButton(modalContent) { + // Remove any existing scroll listeners to avoid duplicates + modalContent.onscroll = null; + + // Add new scroll listener + modalContent.addEventListener('scroll', () => { + const backToTopBtn = modalContent.querySelector('.back-to-top'); + if (backToTopBtn) { + if (modalContent.scrollTop > 300) { + backToTopBtn.classList.add('visible'); + } else { + backToTopBtn.classList.remove('visible'); + } + } + }); + + // Trigger a scroll event to check initial position + modalContent.dispatchEvent(new Event('scroll')); +} + +/** + * Scroll to top of modal content + */ +export function scrollToTop(button) { + const modalContent = button.closest('.modal-content'); + if (modalContent) { + modalContent.scrollTo({ + top: 0, + behavior: 'smooth' + }); + } +} \ No newline at end of file diff --git a/static/js/components/checkpointModal/index.js b/static/js/components/checkpointModal/index.js new file mode 100644 index 00000000..479b0c9b --- /dev/null +++ b/static/js/components/checkpointModal/index.js @@ -0,0 +1,219 @@ +/** + * CheckpointModal - Main entry point + * + * Modularized checkpoint modal component that handles checkpoint model details display + */ +import { showToast } from '../../utils/uiHelpers.js'; +import { state } from '../../state/index.js'; +import { modalManager } from '../../managers/ModalManager.js'; +import { renderShowcaseContent, toggleShowcase, setupShowcaseScroll, scrollToTop } from './ShowcaseView.js'; +import { setupTabSwitching, loadModelDescription } from './ModelDescription.js'; +import { + setupModelNameEditing, + setupBaseModelEditing, + setupFileNameEditing, + saveModelMetadata +} from './ModelMetadata.js'; +import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; + +/** + * Display the checkpoint modal with the given checkpoint data + * @param {Object} checkpoint - Checkpoint data object + */ +export function showCheckpointModal(checkpoint) { + const content = ` + + `; + + modalManager.showModal('checkpointModal', content); + setupEditableFields(); + setupShowcaseScroll(); + setupTabSwitching(); + setupTagTooltip(); + setupModelNameEditing(); + setupBaseModelEditing(); + setupFileNameEditing(); + + // If we have a model ID but no description, fetch it + if (checkpoint.civitai?.modelId && !checkpoint.modelDescription) { + loadModelDescription(checkpoint.civitai.modelId, checkpoint.file_path); + } +} + +/** + * Set up editable fields in the checkpoint modal + */ +function setupEditableFields() { + 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(); + const filePath = document.querySelector('#checkpointModal .modal-content') + .querySelector('.file-path').textContent + + document.querySelector('#checkpointModal .modal-content') + .querySelector('#file-name').textContent; + 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 }); + + // Update the corresponding checkpoint card's dataset + const checkpointCard = document.querySelector(`.checkpoint-card[data-filepath="${filePath}"]`); + if (checkpointCard) { + checkpointCard.dataset.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 diff --git a/static/js/components/checkpointModal/utils.js b/static/js/components/checkpointModal/utils.js new file mode 100644 index 00000000..62bc59fe --- /dev/null +++ b/static/js/components/checkpointModal/utils.js @@ -0,0 +1,74 @@ +/** + * utils.js + * CheckpointModal component utility functions + */ +import { showToast } from '../../utils/uiHelpers.js'; + +/** + * Format file size for display + * @param {number} bytes - File size in bytes + * @returns {string} - Formatted file size + */ +export function formatFileSize(bytes) { + if (!bytes) return 'N/A'; + + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; + } + + return `${size.toFixed(1)} ${units[unitIndex]}`; +} + +/** + * Render compact tags + * @param {Array} tags - Array of tags + * @returns {string} HTML content + */ +export function renderCompactTags(tags) { + if (!tags || tags.length === 0) return ''; + + // Display up to 5 tags, with a tooltip indicator if there are more + const visibleTags = tags.slice(0, 5); + const remainingCount = Math.max(0, tags.length - 5); + + return ` +
+
+ ${visibleTags.map(tag => `${tag}`).join('')} + ${remainingCount > 0 ? + `+${remainingCount}` : + ''} +
+ ${tags.length > 0 ? + `
+
+ ${tags.map(tag => `${tag}`).join('')} +
+
` : + ''} +
+ `; +} + +/** + * Set up tag tooltip functionality + */ +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