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 `
-
- `;
- }
-
- /**
- * 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
-
-
-
- ${images.map(img => {
- // Calculate appropriate aspect ratio
- const aspectRatio = (img.height / img.width) * 100;
- const containerWidth = 800; // modal content max width
- const minHeightPercent = 40;
- const maxHeightPercent = (window.innerHeight * 0.6 / containerWidth) * 100;
- const heightPercent = Math.max(
- minHeightPercent,
- Math.min(maxHeightPercent, aspectRatio)
- );
-
- // Extract metadata
- const meta = img.meta || {};
- const prompt = meta.prompt || '';
- const negativePrompt = meta.negative_prompt || meta.negativePrompt || '';
- const size = meta.Size || `${img.width}x${img.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 generation parameters
- const hasParams = seed || model || steps || sampler || cfgScale || clipSkip;
- const hasPrompts = prompt || negativePrompt;
-
- // Generate metadata panel
- let metadataPanel = '
';
-
- return `
-
- `;
- }).join('')}
-
-
- `;
- }
-
- /**
- * 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 = `
-
-
-
-
-
-
-
-
-
- ${checkpoint.civitai?.name || 'N/A'}
-
-
-
-
- ${checkpoint.file_name || 'N/A'}
-
-
-
-
-
-
- ${checkpoint.folder || 'N/A'}
-
-
-
-
-
-
- ${checkpoint.base_model || 'Unknown'}
-
-
-
-
-
- ${this._formatFileSize(checkpoint.file_size)}
-
-
-
-
-
-
${checkpoint.notes || 'Add your notes here...'}
-
-
-
-
-
-
${checkpoint.description || 'N/A'}
-
-
-
-
-
-
-
-
-
-
-
-
- ${this._renderShowcaseContent(checkpoint.civitai?.images || [])}
-
-
-
-
-
- Loading model description...
-
-
- ${checkpoint.modelDescription || ''}
-
-
-
-
-
-
-
-
-
- `;
-
- 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
+
+
+ ${hiddenNotification}
+
+ ${filteredImages.map(img => generateMediaWrapper(img)).join('')}
+
+
+ `;
+}
+
+/**
+ * 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 = '';
+ return content;
+}
+
+/**
+ * Generate video wrapper HTML
+ */
+function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel) {
+ return `
+
+ `;
+}
+
+/**
+ * Generate image wrapper HTML
+ */
+function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel) {
+ return `
+
+ `;
+}
+
+/**
+ * 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 = `
+
+
+
+
+
+
+
+
+
+ ${checkpoint.civitai?.name || 'N/A'}
+
+
+
+
+ ${checkpoint.file_name || 'N/A'}
+
+
+
+
+
+
+ ${checkpoint.file_path.replace(/[^/]+$/, '')}
+
+
+
+
+
+
+ ${checkpoint.base_model || 'Unknown'}
+
+
+
+
+
+ ${formatFileSize(checkpoint.file_size)}
+
+
+
+
+
+
${checkpoint.notes || 'Add your notes here...'}
+
+
+
+
+
+
${checkpoint.description || 'N/A'}
+
+
+
+
+
+
+
+
+
+
+
+
+ ${renderShowcaseContent(checkpoint.civitai?.images || [])}
+
+
+
+
+
+ Loading model description...
+
+
+ ${checkpoint.modelDescription || ''}
+
+
+
+
+
+
+
+
+
+ `;
+
+ 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 `
+
+ `;
+}
+
+/**
+ * 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