From d1e21fa34586e902c0db4d66e0d5add347e29222 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 14 Apr 2025 15:37:36 +0800 Subject: [PATCH 1/2] feat: Implement context menus for checkpoints and recipes, including metadata refresh and NSFW level management --- static/js/api/checkpointApi.js | 28 +- static/js/checkpoints.js | 4 + static/js/components/ContextMenu.js | 5 +- .../components/ContextMenu/BaseContextMenu.js | 84 +++++ .../ContextMenu/CheckpointContextMenu.js | 315 +++++++++++++++++ .../components/ContextMenu/LoraContextMenu.js | 324 ++++++++++++++++++ .../ContextMenu/RecipeContextMenu.js | 41 +++ static/js/components/ContextMenu/index.js | 3 + static/js/loras.js | 2 +- static/js/recipes.js | 4 + templates/checkpoints.html | 12 + templates/recipes.html | 9 + 12 files changed, 828 insertions(+), 3 deletions(-) create mode 100644 static/js/components/ContextMenu/BaseContextMenu.js create mode 100644 static/js/components/ContextMenu/CheckpointContextMenu.js create mode 100644 static/js/components/ContextMenu/LoraContextMenu.js create mode 100644 static/js/components/ContextMenu/RecipeContextMenu.js create mode 100644 static/js/components/ContextMenu/index.js diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index 8b243be9..cbf6a66a 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -5,7 +5,8 @@ import { refreshModels as baseRefreshModels, deleteModel as baseDeleteModel, replaceModelPreview, - fetchCivitaiMetadata + fetchCivitaiMetadata, + refreshSingleModelMetadata } from './baseModelApi.js'; // Load more checkpoints with pagination @@ -54,4 +55,29 @@ export async function fetchCivitai() { fetchEndpoint: '/api/checkpoints/fetch-all-civitai', resetAndReloadFunction: resetAndReload }); +} + +// Refresh single checkpoint metadata +export async function refreshSingleCheckpointMetadata(filePath) { + return refreshSingleModelMetadata(filePath, 'checkpoint'); +} + +// Save checkpoint metadata (similar to the Lora version) +export async function saveCheckpointMetadata(filePath, data) { + const response = await fetch('/api/checkpoints/save-metadata', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + return await response.json(); } \ No newline at end of file diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 2f1d316f..14036ff3 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -4,6 +4,7 @@ import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js'; import { createPageControls } from './components/controls/index.js'; import { loadMoreCheckpoints } from './api/checkpointApi.js'; import { CheckpointDownloadManager } from './managers/CheckpointDownloadManager.js'; +import { CheckpointContextMenu } from './components/ContextMenu/index.js'; // Initialize the Checkpoints page class CheckpointsPageManager { @@ -34,6 +35,9 @@ class CheckpointsPageManager { this.pageControls.restoreFolderFilter(); this.pageControls.initFolderTagsVisibility(); + // Initialize context menu + new CheckpointContextMenu(); + // Initialize infinite scroll initializeInfiniteScroll('checkpoints'); diff --git a/static/js/components/ContextMenu.js b/static/js/components/ContextMenu.js index cce09f61..d45e6216 100644 --- a/static/js/components/ContextMenu.js +++ b/static/js/components/ContextMenu.js @@ -366,4 +366,7 @@ export class LoraContextMenu { this.menu.style.display = 'none'; this.currentCard = null; } -} \ No newline at end of file +} + +// For backward compatibility, re-export the LoraContextMenu class +// export { LoraContextMenu } from './ContextMenu/LoraContextMenu.js'; \ No newline at end of file diff --git a/static/js/components/ContextMenu/BaseContextMenu.js b/static/js/components/ContextMenu/BaseContextMenu.js new file mode 100644 index 00000000..e2f9edfc --- /dev/null +++ b/static/js/components/ContextMenu/BaseContextMenu.js @@ -0,0 +1,84 @@ +export class BaseContextMenu { + constructor(menuId, cardSelector) { + this.menu = document.getElementById(menuId); + this.cardSelector = cardSelector; + this.currentCard = null; + + if (!this.menu) { + console.error(`Context menu element with ID ${menuId} not found`); + return; + } + + this.init(); + } + + init() { + // Hide menu on regular clicks + document.addEventListener('click', () => this.hideMenu()); + + // Show menu on right-click on cards + document.addEventListener('contextmenu', (e) => { + const card = e.target.closest(this.cardSelector); + if (!card) { + this.hideMenu(); + return; + } + e.preventDefault(); + this.showMenu(e.clientX, e.clientY, card); + }); + + // Handle menu item clicks + this.menu.addEventListener('click', (e) => { + const menuItem = e.target.closest('.context-menu-item'); + if (!menuItem || !this.currentCard) return; + + const action = menuItem.dataset.action; + if (!action) return; + + this.handleMenuAction(action, menuItem); + this.hideMenu(); + }); + } + + handleMenuAction(action, menuItem) { + // Override in subclass + console.warn('handleMenuAction not implemented'); + } + + showMenu(x, y, card) { + this.currentCard = card; + this.menu.style.display = 'block'; + + // Get menu dimensions + const menuRect = this.menu.getBoundingClientRect(); + + // Get viewport dimensions + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + + // Calculate position + let finalX = x; + let finalY = y; + + // Ensure menu doesn't go offscreen right + if (x + menuRect.width > viewportWidth) { + finalX = x - menuRect.width; + } + + // Ensure menu doesn't go offscreen bottom + if (y + menuRect.height > viewportHeight) { + finalY = y - menuRect.height; + } + + // Position menu + this.menu.style.left = `${finalX}px`; + this.menu.style.top = `${finalY}px`; + } + + hideMenu() { + if (this.menu) { + this.menu.style.display = 'none'; + } + this.currentCard = null; + } +} \ No newline at end of file diff --git a/static/js/components/ContextMenu/CheckpointContextMenu.js b/static/js/components/ContextMenu/CheckpointContextMenu.js new file mode 100644 index 00000000..975ac986 --- /dev/null +++ b/static/js/components/ContextMenu/CheckpointContextMenu.js @@ -0,0 +1,315 @@ +import { BaseContextMenu } from './BaseContextMenu.js'; +import { refreshSingleCheckpointMetadata, saveCheckpointMetadata } from '../../api/checkpointApi.js'; +import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js'; +import { NSFW_LEVELS } from '../../utils/constants.js'; +import { getStorageItem } from '../../utils/storageHelpers.js'; + +export class CheckpointContextMenu extends BaseContextMenu { + constructor() { + super('checkpointContextMenu', '.lora-card'); + this.nsfwSelector = document.getElementById('nsfwLevelSelector'); + + // Initialize NSFW Level Selector events + if (this.nsfwSelector) { + this.initNSFWSelector(); + } + } + + handleMenuAction(action) { + switch(action) { + case 'details': + // Show checkpoint details + this.currentCard.click(); + break; + case 'preview': + // Replace checkpoint preview + if (this.currentCard.querySelector('.fa-image')) { + this.currentCard.querySelector('.fa-image').click(); + } + break; + case 'civitai': + // Open civitai page + if (this.currentCard.dataset.from_civitai === 'true') { + if (this.currentCard.querySelector('.fa-globe')) { + this.currentCard.querySelector('.fa-globe').click(); + } + } else { + showToast('No CivitAI information available', 'info'); + } + break; + case 'delete': + // Delete checkpoint + if (this.currentCard.querySelector('.fa-trash')) { + this.currentCard.querySelector('.fa-trash').click(); + } + break; + case 'copyname': + // Copy checkpoint name + if (this.currentCard.querySelector('.fa-copy')) { + this.currentCard.querySelector('.fa-copy').click(); + } + break; + case 'refresh-metadata': + // Refresh metadata from CivitAI + refreshSingleCheckpointMetadata(this.currentCard.dataset.filepath); + break; + case 'set-nsfw': + // Set NSFW level + this.showNSFWLevelSelector(null, null, this.currentCard); + break; + case 'move': + // Move to folder (placeholder) + showToast('Move to folder feature coming soon', 'info'); + break; + } + } + + // NSFW Selector methods + initNSFWSelector() { + // Close button + const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector'); + closeBtn.addEventListener('click', () => { + this.nsfwSelector.style.display = 'none'; + }); + + // Level buttons + const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn'); + levelButtons.forEach(btn => { + btn.addEventListener('click', async () => { + const level = parseInt(btn.dataset.level); + const filePath = this.nsfwSelector.dataset.cardPath; + + if (!filePath) return; + + try { + await saveCheckpointMetadata(filePath, { preview_nsfw_level: level }); + + // Update card data + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (card) { + let metaData = {}; + try { + metaData = JSON.parse(card.dataset.meta || '{}'); + } catch (err) { + console.error('Error parsing metadata:', err); + } + + metaData.preview_nsfw_level = level; + card.dataset.meta = JSON.stringify(metaData); + card.dataset.nsfwLevel = level.toString(); + + // Apply blur effect immediately + this.updateCardBlurEffect(card, level); + } + + showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success'); + this.nsfwSelector.style.display = 'none'; + } catch (error) { + showToast(`Failed to set content rating: ${error.message}`, 'error'); + } + }); + }); + + // Close when clicking outside + document.addEventListener('click', (e) => { + if (this.nsfwSelector.style.display === 'block' && + !this.nsfwSelector.contains(e.target) && + !e.target.closest('.context-menu-item[data-action="set-nsfw"]')) { + this.nsfwSelector.style.display = 'none'; + } + }); + } + + updateCardBlurEffect(card, level) { + // Get user settings for blur threshold + const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4'); + + // Get card preview container + const previewContainer = card.querySelector('.card-preview'); + if (!previewContainer) return; + + // Get preview media element + const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video'); + if (!previewMedia) return; + + // Check if blur should be applied + if (level >= blurThreshold) { + // Add blur class to the preview container + previewContainer.classList.add('blurred'); + + // Get or create the NSFW overlay + let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay'); + if (!nsfwOverlay) { + // Create new overlay + nsfwOverlay = document.createElement('div'); + nsfwOverlay.className = 'nsfw-overlay'; + + // Create and configure the warning content + const warningContent = document.createElement('div'); + warningContent.className = 'nsfw-warning'; + + // Determine NSFW warning text based on level + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + + // Add warning text and show button + warningContent.innerHTML = ` +

${nsfwText}

+ + `; + + // Add click event to the show button + const showBtn = warningContent.querySelector('.show-content-btn'); + showBtn.addEventListener('click', (e) => { + e.stopPropagation(); + previewContainer.classList.remove('blurred'); + nsfwOverlay.style.display = 'none'; + + // Update toggle button icon if it exists + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + }); + + nsfwOverlay.appendChild(warningContent); + previewContainer.appendChild(nsfwOverlay); + } else { + // Update existing overlay + const warningText = nsfwOverlay.querySelector('p'); + if (warningText) { + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + warningText.textContent = nsfwText; + } + nsfwOverlay.style.display = 'flex'; + } + + // Get or create the toggle button in the header + const cardHeader = previewContainer.querySelector('.card-header'); + if (cardHeader) { + let toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); + + if (!toggleBtn) { + toggleBtn = document.createElement('button'); + toggleBtn.className = 'toggle-blur-btn'; + toggleBtn.title = 'Toggle blur'; + toggleBtn.innerHTML = ''; + + // Add click event to toggle button + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isBlurred = previewContainer.classList.toggle('blurred'); + const icon = toggleBtn.querySelector('i'); + + // Update icon and overlay visibility + if (isBlurred) { + icon.className = 'fas fa-eye'; + nsfwOverlay.style.display = 'flex'; + } else { + icon.className = 'fas fa-eye-slash'; + nsfwOverlay.style.display = 'none'; + } + }); + + // Add to the beginning of header + cardHeader.insertBefore(toggleBtn, cardHeader.firstChild); + + // Update base model label class + const baseModelLabel = cardHeader.querySelector('.base-model-label'); + if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) { + baseModelLabel.classList.add('with-toggle'); + } + } else { + // Update existing toggle button + toggleBtn.querySelector('i').className = 'fas fa-eye'; + } + } + } else { + // Remove blur + previewContainer.classList.remove('blurred'); + + // Hide overlay if it exists + const overlay = previewContainer.querySelector('.nsfw-overlay'); + if (overlay) overlay.style.display = 'none'; + + // Remove toggle button when content is set to PG or PG13 + const cardHeader = previewContainer.querySelector('.card-header'); + if (cardHeader) { + const toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + // Remove the toggle button completely + toggleBtn.remove(); + + // Update base model label class if it exists + const baseModelLabel = cardHeader.querySelector('.base-model-label'); + if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) { + baseModelLabel.classList.remove('with-toggle'); + } + } + } + } + } + + showNSFWLevelSelector(x, y, card) { + const selector = document.getElementById('nsfwLevelSelector'); + const currentLevelEl = document.getElementById('currentNSFWLevel'); + + // Get current NSFW level + let currentLevel = 0; + try { + const metaData = JSON.parse(card.dataset.meta || '{}'); + currentLevel = metaData.preview_nsfw_level || 0; + + // Update if we have no recorded level but have a dataset attribute + if (!currentLevel && card.dataset.nsfwLevel) { + currentLevel = parseInt(card.dataset.nsfwLevel) || 0; + } + } catch (err) { + console.error('Error parsing metadata:', err); + } + + currentLevelEl.textContent = getNSFWLevelName(currentLevel); + + // Position the selector + if (x && y) { + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const selectorRect = selector.getBoundingClientRect(); + + // Center the selector if no coordinates provided + let finalX = (viewportWidth - selectorRect.width) / 2; + let finalY = (viewportHeight - selectorRect.height) / 2; + + selector.style.left = `${finalX}px`; + selector.style.top = `${finalY}px`; + } + + // Highlight current level button + document.querySelectorAll('.nsfw-level-btn').forEach(btn => { + if (parseInt(btn.dataset.level) === currentLevel) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Store reference to current card + selector.dataset.cardPath = card.dataset.filepath; + + // Show selector + selector.style.display = 'block'; + } +} \ No newline at end of file diff --git a/static/js/components/ContextMenu/LoraContextMenu.js b/static/js/components/ContextMenu/LoraContextMenu.js new file mode 100644 index 00000000..64382d0e --- /dev/null +++ b/static/js/components/ContextMenu/LoraContextMenu.js @@ -0,0 +1,324 @@ +import { BaseContextMenu } from './BaseContextMenu.js'; +import { refreshSingleLoraMetadata } from '../../api/loraApi.js'; +import { showToast, getNSFWLevelName } from '../../utils/uiHelpers.js'; +import { NSFW_LEVELS } from '../../utils/constants.js'; +import { getStorageItem } from '../../utils/storageHelpers.js'; + +export class LoraContextMenu extends BaseContextMenu { + constructor() { + super('loraContextMenu', '.lora-card'); + this.nsfwSelector = document.getElementById('nsfwLevelSelector'); + + // Initialize NSFW Level Selector events + if (this.nsfwSelector) { + this.initNSFWSelector(); + } + } + + handleMenuAction(action, menuItem) { + switch(action) { + case 'detail': + // Trigger the main card click which shows the modal + this.currentCard.click(); + break; + case 'civitai': + // Only trigger if the card is from civitai + if (this.currentCard.dataset.from_civitai === 'true') { + if (this.currentCard.dataset.meta === '{}') { + showToast('Please fetch metadata from CivitAI first', 'info'); + } else { + this.currentCard.querySelector('.fa-globe')?.click(); + } + } else { + showToast('No CivitAI information available', 'info'); + } + break; + case 'copyname': + this.currentCard.querySelector('.fa-copy')?.click(); + break; + case 'preview': + this.currentCard.querySelector('.fa-image')?.click(); + break; + case 'delete': + this.currentCard.querySelector('.fa-trash')?.click(); + break; + case 'move': + moveManager.showMoveModal(this.currentCard.dataset.filepath); + break; + case 'refresh-metadata': + refreshSingleLoraMetadata(this.currentCard.dataset.filepath); + break; + case 'set-nsfw': + this.showNSFWLevelSelector(null, null, this.currentCard); + break; + } + } + + // NSFW Selector methods from the original context menu + initNSFWSelector() { + // Close button + const closeBtn = this.nsfwSelector.querySelector('.close-nsfw-selector'); + closeBtn.addEventListener('click', () => { + this.nsfwSelector.style.display = 'none'; + }); + + // Level buttons + const levelButtons = this.nsfwSelector.querySelectorAll('.nsfw-level-btn'); + levelButtons.forEach(btn => { + btn.addEventListener('click', async () => { + const level = parseInt(btn.dataset.level); + const filePath = this.nsfwSelector.dataset.cardPath; + + if (!filePath) return; + + try { + await this.saveModelMetadata(filePath, { preview_nsfw_level: level }); + + // Update card data + const card = document.querySelector(`.lora-card[data-filepath="${filePath}"]`); + if (card) { + let metaData = {}; + try { + metaData = JSON.parse(card.dataset.meta || '{}'); + } catch (err) { + console.error('Error parsing metadata:', err); + } + + metaData.preview_nsfw_level = level; + card.dataset.meta = JSON.stringify(metaData); + card.dataset.nsfwLevel = level.toString(); + + // Apply blur effect immediately + this.updateCardBlurEffect(card, level); + } + + showToast(`Content rating set to ${getNSFWLevelName(level)}`, 'success'); + this.nsfwSelector.style.display = 'none'; + } catch (error) { + showToast(`Failed to set content rating: ${error.message}`, 'error'); + } + }); + }); + + // Close when clicking outside + document.addEventListener('click', (e) => { + if (this.nsfwSelector.style.display === 'block' && + !this.nsfwSelector.contains(e.target) && + !e.target.closest('.context-menu-item[data-action="set-nsfw"]')) { + this.nsfwSelector.style.display = 'none'; + } + }); + } + + async saveModelMetadata(filePath, data) { + const response = await fetch('/api/loras/save-metadata', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_path: filePath, + ...data + }) + }); + + if (!response.ok) { + throw new Error('Failed to save metadata'); + } + + return await response.json(); + } + + updateCardBlurEffect(card, level) { + // Get user settings for blur threshold + const blurThreshold = parseInt(getStorageItem('nsfwBlurLevel') || '4'); + + // Get card preview container + const previewContainer = card.querySelector('.card-preview'); + if (!previewContainer) return; + + // Get preview media element + const previewMedia = previewContainer.querySelector('img') || previewContainer.querySelector('video'); + if (!previewMedia) return; + + // Check if blur should be applied + if (level >= blurThreshold) { + // Add blur class to the preview container + previewContainer.classList.add('blurred'); + + // Get or create the NSFW overlay + let nsfwOverlay = previewContainer.querySelector('.nsfw-overlay'); + if (!nsfwOverlay) { + // Create new overlay + nsfwOverlay = document.createElement('div'); + nsfwOverlay.className = 'nsfw-overlay'; + + // Create and configure the warning content + const warningContent = document.createElement('div'); + warningContent.className = 'nsfw-warning'; + + // Determine NSFW warning text based on level + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + + // Add warning text and show button + warningContent.innerHTML = ` +

${nsfwText}

+ + `; + + // Add click event to the show button + const showBtn = warningContent.querySelector('.show-content-btn'); + showBtn.addEventListener('click', (e) => { + e.stopPropagation(); + previewContainer.classList.remove('blurred'); + nsfwOverlay.style.display = 'none'; + + // Update toggle button icon if it exists + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + }); + + nsfwOverlay.appendChild(warningContent); + previewContainer.appendChild(nsfwOverlay); + } else { + // Update existing overlay + const warningText = nsfwOverlay.querySelector('p'); + if (warningText) { + let nsfwText = "Mature Content"; + if (level >= NSFW_LEVELS.XXX) { + nsfwText = "XXX-rated Content"; + } else if (level >= NSFW_LEVELS.X) { + nsfwText = "X-rated Content"; + } else if (level >= NSFW_LEVELS.R) { + nsfwText = "R-rated Content"; + } + warningText.textContent = nsfwText; + } + nsfwOverlay.style.display = 'flex'; + } + + // Get or create the toggle button in the header + const cardHeader = previewContainer.querySelector('.card-header'); + if (cardHeader) { + let toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); + + if (!toggleBtn) { + toggleBtn = document.createElement('button'); + toggleBtn.className = 'toggle-blur-btn'; + toggleBtn.title = 'Toggle blur'; + toggleBtn.innerHTML = ''; + + // Add click event to toggle button + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + const isBlurred = previewContainer.classList.toggle('blurred'); + const icon = toggleBtn.querySelector('i'); + + // Update icon and overlay visibility + if (isBlurred) { + icon.className = 'fas fa-eye'; + nsfwOverlay.style.display = 'flex'; + } else { + icon.className = 'fas fa-eye-slash'; + nsfwOverlay.style.display = 'none'; + } + }); + + // Add to the beginning of header + cardHeader.insertBefore(toggleBtn, cardHeader.firstChild); + + // Update base model label class + const baseModelLabel = cardHeader.querySelector('.base-model-label'); + if (baseModelLabel && !baseModelLabel.classList.contains('with-toggle')) { + baseModelLabel.classList.add('with-toggle'); + } + } else { + // Update existing toggle button + toggleBtn.querySelector('i').className = 'fas fa-eye'; + } + } + } else { + // Remove blur + previewContainer.classList.remove('blurred'); + + // Hide overlay if it exists + const overlay = previewContainer.querySelector('.nsfw-overlay'); + if (overlay) overlay.style.display = 'none'; + + // Remove toggle button when content is set to PG or PG13 + const cardHeader = previewContainer.querySelector('.card-header'); + if (cardHeader) { + const toggleBtn = cardHeader.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + // Remove the toggle button completely + toggleBtn.remove(); + + // Update base model label class if it exists + const baseModelLabel = cardHeader.querySelector('.base-model-label'); + if (baseModelLabel && baseModelLabel.classList.contains('with-toggle')) { + baseModelLabel.classList.remove('with-toggle'); + } + } + } + } + } + + showNSFWLevelSelector(x, y, card) { + const selector = document.getElementById('nsfwLevelSelector'); + const currentLevelEl = document.getElementById('currentNSFWLevel'); + + // Get current NSFW level + let currentLevel = 0; + try { + const metaData = JSON.parse(card.dataset.meta || '{}'); + currentLevel = metaData.preview_nsfw_level || 0; + + // Update if we have no recorded level but have a dataset attribute + if (!currentLevel && card.dataset.nsfwLevel) { + currentLevel = parseInt(card.dataset.nsfwLevel) || 0; + } + } catch (err) { + console.error('Error parsing metadata:', err); + } + + currentLevelEl.textContent = getNSFWLevelName(currentLevel); + + // Position the selector + if (x && y) { + const viewportWidth = document.documentElement.clientWidth; + const viewportHeight = document.documentElement.clientHeight; + const selectorRect = selector.getBoundingClientRect(); + + // Center the selector if no coordinates provided + let finalX = (viewportWidth - selectorRect.width) / 2; + let finalY = (viewportHeight - selectorRect.height) / 2; + + selector.style.left = `${finalX}px`; + selector.style.top = `${finalY}px`; + } + + // Highlight current level button + document.querySelectorAll('.nsfw-level-btn').forEach(btn => { + if (parseInt(btn.dataset.level) === currentLevel) { + btn.classList.add('active'); + } else { + btn.classList.remove('active'); + } + }); + + // Store reference to current card + selector.dataset.cardPath = card.dataset.filepath; + + // Show selector + selector.style.display = 'block'; + } +} \ No newline at end of file diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js new file mode 100644 index 00000000..ca5c54d8 --- /dev/null +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -0,0 +1,41 @@ +import { BaseContextMenu } from './BaseContextMenu.js'; +import { showToast } from '../../utils/uiHelpers.js'; + +export class RecipeContextMenu extends BaseContextMenu { + constructor() { + super('recipeContextMenu', '.lora-card'); + } + + handleMenuAction(action) { + switch(action) { + case 'details': + // Show recipe details + this.currentCard.click(); + break; + case 'copy': + // Copy recipe to clipboard + if (window.recipeManager) { + window.recipeManager.copyRecipe(this.currentCard.dataset.id); + } + break; + case 'share': + // Share recipe + if (window.recipeManager) { + window.recipeManager.shareRecipe(this.currentCard.dataset.id); + } + break; + case 'delete': + // Delete recipe + if (this.currentCard.querySelector('.fa-trash')) { + this.currentCard.querySelector('.fa-trash').click(); + } + break; + case 'edit': + // Edit recipe + if (window.recipeManager && window.recipeManager.editRecipe) { + window.recipeManager.editRecipe(this.currentCard.dataset.id); + } + break; + } + } +} \ No newline at end of file diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js new file mode 100644 index 00000000..092afbb7 --- /dev/null +++ b/static/js/components/ContextMenu/index.js @@ -0,0 +1,3 @@ +export { LoraContextMenu } from './LoraContextMenu.js'; +export { RecipeContextMenu } from './RecipeContextMenu.js'; +export { CheckpointContextMenu } from './CheckpointContextMenu.js'; \ No newline at end of file diff --git a/static/js/loras.js b/static/js/loras.js index 24543725..96f0af37 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -6,7 +6,7 @@ import { updateCardsForBulkMode } from './components/LoraCard.js'; import { bulkManager } from './managers/BulkManager.js'; import { DownloadManager } from './managers/DownloadManager.js'; import { moveManager } from './managers/MoveManager.js'; -import { LoraContextMenu } from './components/ContextMenu.js'; +import { LoraContextMenu } from './components/ContextMenu/index.js'; import { createPageControls } from './components/controls/index.js'; import { confirmDelete, closeDeleteModal } from './utils/modalUtils.js'; diff --git a/static/js/recipes.js b/static/js/recipes.js index be87a914..875c928e 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -5,6 +5,7 @@ import { RecipeCard } from './components/RecipeCard.js'; import { RecipeModal } from './components/RecipeModal.js'; import { getCurrentPageState } from './state/index.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; +import { RecipeContextMenu } from './components/ContextMenu/index.js'; class RecipeManager { constructor() { @@ -37,6 +38,9 @@ class RecipeManager { // Set default search options if not already defined this._initSearchOptions(); + // Initialize context menu + new RecipeContextMenu(); + // Check for custom filter parameters in session storage this._checkCustomFilter(); diff --git a/templates/checkpoints.html b/templates/checkpoints.html index 4ff483df..1644d8d5 100644 --- a/templates/checkpoints.html +++ b/templates/checkpoints.html @@ -13,6 +13,18 @@ {% block additional_components %} {% include 'components/checkpoint_modals.html' %} + + {% endblock %} {% block content %} diff --git a/templates/recipes.html b/templates/recipes.html index 3e9dc76a..c0202876 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -16,6 +16,15 @@ {% block additional_components %} {% include 'components/import_modal.html' %} {% include 'components/recipe_modal.html' %} + + {% endblock %} {% block init_title %}Initializing Recipe Manager{% endblock %} From 46700e5ad009babd7525722a42c05c0b72c5284e Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 14 Apr 2025 20:25:44 +0800 Subject: [PATCH 2/2] feat: Refactor infinite scroll initialization for improved observer handling and sentinel management --- static/js/utils/infiniteScroll.js | 98 ++++++++++++++----------------- 1 file changed, 45 insertions(+), 53 deletions(-) diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 65035168..57290ebd 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -4,6 +4,7 @@ import { loadMoreCheckpoints } from '../api/checkpointApi.js'; import { debounce } from './debounce.js'; export function initializeInfiniteScroll(pageType = 'loras') { + // Clean up any existing observer if (state.observer) { state.observer.disconnect(); } @@ -47,53 +48,53 @@ export function initializeInfiniteScroll(pageType = 'loras') { } const debouncedLoadMore = debounce(loadMoreFunction, 100); - - // Create a more robust observer with lower threshold and root margin - state.observer = new IntersectionObserver( - (entries) => { - const target = entries[0]; - if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) { - debouncedLoadMore(); - } - }, - { - threshold: 0.01, // Lower threshold to detect even minimal visibility - rootMargin: '0px 0px 300px 0px' // Increase bottom margin to trigger earlier - } - ); - + const grid = document.getElementById(gridId); if (!grid) { console.warn(`Grid with ID "${gridId}" not found for infinite scroll`); return; } - + + // Remove any existing sentinel const existingSentinel = document.getElementById('scroll-sentinel'); if (existingSentinel) { - state.observer.observe(existingSentinel); - } else { - // Create a wrapper div that will be placed after the grid - const sentinelWrapper = document.createElement('div'); - sentinelWrapper.style.width = '100%'; - sentinelWrapper.style.height = '30px'; // Increased height for better visibility - sentinelWrapper.style.margin = '0'; - sentinelWrapper.style.padding = '0'; - - // Create the actual sentinel element - const sentinel = document.createElement('div'); - sentinel.id = 'scroll-sentinel'; - sentinel.style.height = '30px'; // Match wrapper height - - // Add the sentinel to the wrapper - sentinelWrapper.appendChild(sentinel); - - // Insert the wrapper after the grid instead of inside it - grid.parentNode.insertBefore(sentinelWrapper, grid.nextSibling); - - state.observer.observe(sentinel); + existingSentinel.remove(); } - // Add a scroll event backup to handle edge cases + // Create a sentinel element after the grid (not inside it) + const sentinel = document.createElement('div'); + sentinel.id = 'scroll-sentinel'; + sentinel.style.width = '100%'; + sentinel.style.height = '20px'; + sentinel.style.visibility = 'hidden'; // Make it invisible but still affect layout + + // Insert after grid instead of inside + grid.parentNode.insertBefore(sentinel, grid.nextSibling); + + // Create observer with appropriate settings, slightly different for checkpoints page + const observerOptions = { + threshold: 0.1, + rootMargin: pageType === 'checkpoints' ? '0px 0px 200px 0px' : '0px 0px 100px 0px' + }; + + // Initialize the observer + state.observer = new IntersectionObserver((entries) => { + const target = entries[0]; + if (target.isIntersecting && !pageState.isLoading && pageState.hasMore) { + debouncedLoadMore(); + } + }, observerOptions); + + // Start observing + state.observer.observe(sentinel); + + // Clean up any existing scroll event listener + if (state.scrollHandler) { + window.removeEventListener('scroll', state.scrollHandler); + state.scrollHandler = null; + } + + // Add a simple backup scroll handler const handleScroll = debounce(() => { if (pageState.isLoading || !pageState.hasMore) return; @@ -103,26 +104,17 @@ export function initializeInfiniteScroll(pageType = 'loras') { const rect = sentinel.getBoundingClientRect(); const windowHeight = window.innerHeight; - // If sentinel is within 500px of viewport bottom, load more - if (rect.top < windowHeight + 500) { + if (rect.top < windowHeight + 200) { debouncedLoadMore(); } }, 200); - // Clean up existing scroll listener if any - if (state.scrollHandler) { - window.removeEventListener('scroll', state.scrollHandler); - } - - // Save reference to the handler for cleanup state.scrollHandler = handleScroll; window.addEventListener('scroll', state.scrollHandler); - // Check position immediately in case content is already visible - setTimeout(() => { - const sentinel = document.getElementById('scroll-sentinel'); - if (sentinel && sentinel.getBoundingClientRect().top < window.innerHeight) { - debouncedLoadMore(); - } - }, 100); + // Clear any existing interval + if (state.scrollCheckInterval) { + clearInterval(state.scrollCheckInterval); + state.scrollCheckInterval = null; + } } \ No newline at end of file