From 8546cfe7143fd36bba21a47d40e2189808242eb0 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 10:25:58 +0800 Subject: [PATCH 1/8] checkpoint --- static/css/components/card.css | 80 ++++- static/css/layout.css | 13 + static/js/api/baseModelApi.js | 119 +++++++ static/js/api/loraApi.js | 17 + static/js/components/LoraCard.js | 5 + static/js/components/controls/index.js | 14 + static/js/core.js | 2 +- static/js/loras.js | 8 +- static/js/utils/VirtualScroller.js | 411 +++++++++++++++++++++++++ static/js/utils/infiniteScroll.js | 194 +++++++----- 10 files changed, 770 insertions(+), 93 deletions(-) create mode 100644 static/js/utils/VirtualScroller.js diff --git a/static/css/components/card.css b/static/css/components/card.css index 8c172872..bf07d0e5 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -1,12 +1,12 @@ /* 卡片网格布局 */ .card-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Adjusted from 320px */ + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */ gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */ margin-top: var(--space-2); padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */ padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */ - max-width: 1400px; /* Container width control */ + max-width: 1400px; /* Base container width */ margin-left: auto; margin-right: auto; } @@ -17,13 +17,14 @@ border-radius: var(--border-radius-base); backdrop-filter: blur(16px); transition: transform 160ms ease-out; - aspect-ratio: 896/1152; - max-width: 260px; /* Adjusted from 320px to fit 5 cards */ + aspect-ratio: 896/1152; /* Preserve aspect ratio */ + max-width: 260px; /* Base size */ + width: 100%; margin: 0 auto; - cursor: pointer; /* Added from recipe-card */ - display: flex; /* Added from recipe-card */ - flex-direction: column; /* Added from recipe-card */ - overflow: hidden; /* Add overflow hidden to contain children */ + cursor: pointer; + display: flex; + flex-direction: column; + overflow: hidden; } .lora-card:hover { @@ -36,6 +37,30 @@ outline-offset: 2px; } +/* Responsive adjustments for 1440p screens (2K) */ +@media (min-width: 2000px) { + .card-grid { + max-width: 1800px; /* Increased for 2K screens */ + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + } + + .lora-card { + max-width: 270px; + } +} + +/* Responsive adjustments for 4K screens */ +@media (min-width: 3000px) { + .card-grid { + max-width: 2400px; /* Increased for 4K screens */ + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + } + + .lora-card { + max-width: 280px; + } +} + /* Responsive adjustments */ @media (max-width: 1400px) { .card-grid { @@ -362,4 +387,43 @@ padding: 2rem; background: var(--lora-surface-alt); border-radius: var(--border-radius-base); +} + +/* Virtual scrolling specific styles - updated */ +.virtual-scroll-item { + position: absolute; + box-sizing: border-box; + transition: transform 160ms ease-out; + margin: 0; /* Remove margins, positioning is handled by VirtualScroller */ + padding: 6px; /* Add consistent padding on all sides */ + width: 100%; /* Allow width to be set by the VirtualScroller */ +} + +.virtual-scroll-item:hover { + transform: translateY(-2px); /* Keep hover effect */ + z-index: 1; /* Ensure hovered items appear above others */ +} + +/* When using virtual scroll, adjust container */ +.card-grid.virtual-scroll { + display: block; + position: relative; + margin: 0 auto; + padding: 6px 0; /* Add top/bottom padding equivalent to card padding */ + height: auto; + width: 100%; + max-width: 1400px; /* Keep the max-width from original grid */ +} + +/* For larger screens, allow more space for the cards */ +@media (min-width: 2000px) { + .card-grid.virtual-scroll { + max-width: 1800px; + } +} + +@media (min-width: 3000px) { + .card-grid.virtual-scroll { + max-width: 2400px; + } } \ No newline at end of file diff --git a/static/css/layout.css b/static/css/layout.css index 69a5375e..195ff113 100644 --- a/static/css/layout.css +++ b/static/css/layout.css @@ -15,6 +15,19 @@ z-index: var(--z-base); } +/* Responsive container for larger screens */ +@media (min-width: 2000px) { + .container { + max-width: 1800px; + } +} + +@media (min-width: 3000px) { + .container { + max-width: 2400px; + } +} + .controls { display: flex; flex-direction: column; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 46c13a42..5a282594 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -160,6 +160,125 @@ export async function loadMoreModels(options = {}) { } } +// New method for virtual scrolling fetch +export async function fetchModelsPage(options = {}) { + const { + modelType = 'lora', + page = 1, + pageSize = 100, + endpoint = '/api/loras' + } = options; + + const pageState = getCurrentPageState(); + + try { + const params = new URLSearchParams({ + page: page, + page_size: pageSize || pageState.pageSize || 20, + sort_by: pageState.sortBy + }); + + if (pageState.activeFolder !== null) { + params.append('folder', pageState.activeFolder); + } + + // Add favorites filter parameter if enabled + if (pageState.showFavoritesOnly) { + params.append('favorites_only', 'true'); + } + + // Add active letter filter if set + if (pageState.activeLetterFilter) { + params.append('first_letter', pageState.activeLetterFilter); + } + + // Add search parameters if there's a search term + if (pageState.filters?.search) { + params.append('search', pageState.filters.search); + params.append('fuzzy', 'true'); + + // Add search option parameters if available + if (pageState.searchOptions) { + params.append('search_filename', pageState.searchOptions.filename.toString()); + params.append('search_modelname', pageState.searchOptions.modelname.toString()); + if (pageState.searchOptions.tags !== undefined) { + params.append('search_tags', pageState.searchOptions.tags.toString()); + } + params.append('recursive', (pageState.searchOptions?.recursive ?? false).toString()); + } + } + + // Add filter parameters if active + if (pageState.filters) { + // Handle tags filters + if (pageState.filters.tags && pageState.filters.tags.length > 0) { + // Checkpoints API expects individual 'tag' parameters, Loras API expects comma-separated 'tags' + if (modelType === 'checkpoint') { + pageState.filters.tags.forEach(tag => { + params.append('tag', tag); + }); + } else { + params.append('tags', pageState.filters.tags.join(',')); + } + } + + // Handle base model filters + if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) { + if (modelType === 'checkpoint') { + pageState.filters.baseModel.forEach(model => { + params.append('base_model', model); + }); + } else { + params.append('base_models', pageState.filters.baseModel.join(',')); + } + } + } + + // Add model-specific parameters + if (modelType === 'lora') { + // Check for recipe-based filtering parameters from session storage + const filterLoraHash = getSessionItem('recipe_to_lora_filterLoraHash'); + const filterLoraHashes = getSessionItem('recipe_to_lora_filterLoraHashes'); + + // Add hash filter parameter if present + if (filterLoraHash) { + params.append('lora_hash', filterLoraHash); + } + // Add multiple hashes filter if present + else if (filterLoraHashes) { + try { + if (Array.isArray(filterLoraHashes) && filterLoraHashes.length > 0) { + params.append('lora_hashes', filterLoraHashes.join(',')); + } + } catch (error) { + console.error('Error parsing lora hashes from session storage:', error); + } + } + } + + const response = await fetch(`${endpoint}?${params}`); + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.statusText}`); + } + + const data = await response.json(); + + return { + items: data.items, + totalItems: data.total, + totalPages: data.total_pages, + currentPage: page, + hasMore: page < data.total_pages, + folders: data.folders + }; + + } catch (error) { + console.error(`Error fetching ${modelType}s:`, error); + showToast(`Failed to fetch ${modelType}s: ${error.message}`, 'error'); + throw error; + } +} + // Update folder tags in the UI export function updateFolderTags(folders) { const folderTagsContainer = document.querySelector('.folder-tags'); diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 80fa3fb7..5010e948 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -1,6 +1,7 @@ import { createLoraCard } from '../components/LoraCard.js'; import { loadMoreModels, + fetchModelsPage, resetAndReload as baseResetAndReload, refreshModels as baseRefreshModels, deleteModel as baseDeleteModel, @@ -54,6 +55,22 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) { }); } +/** + * Fetch loras with pagination for virtual scrolling + * @param {number} page - Page number to fetch + * @param {number} pageSize - Number of items per page + * @returns {Promise} Object containing items, total count, and pagination info + */ +export async function fetchLorasPage(page = 1, pageSize = 50) { + console.log('Fetching loras page:', page, pageSize); + return fetchModelsPage({ + modelType: 'lora', + page, + pageSize, + endpoint: '/api/loras' + }); +} + export async function fetchCivitai() { return fetchCivitaiMetadata({ modelType: 'lora', diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index 26c54458..aef11567 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -298,6 +298,11 @@ export function createLoraCard(lora) { }); } + // Add a special class for virtual scroll positioning if needed + if (state.virtualScroller) { + card.classList.add('virtual-scroll-item'); + } + return card; } diff --git a/static/js/components/controls/index.js b/static/js/components/controls/index.js index c767c62f..e139fd5d 100644 --- a/static/js/components/controls/index.js +++ b/static/js/components/controls/index.js @@ -2,6 +2,7 @@ import { PageControls } from './PageControls.js'; import { LorasControls } from './LorasControls.js'; import { CheckpointsControls } from './CheckpointsControls.js'; +import { refreshVirtualScroll } from '../../utils/infiniteScroll.js'; // Export the classes export { PageControls, LorasControls, CheckpointsControls }; @@ -20,4 +21,17 @@ export function createPageControls(pageType) { console.error(`Unknown page type: ${pageType}`); return null; } +} + +// Example for a filter method: +function applyFilter(filterType, value) { + // ...existing filter logic... + + // After filters are applied, refresh the virtual scroll if it exists + if (state.virtualScroller) { + refreshVirtualScroll(); + } else { + // Fall back to existing reset and reload logic + resetAndReload(true); + } } \ No newline at end of file diff --git a/static/js/core.js b/static/js/core.js index 337674d9..fd045944 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -63,7 +63,7 @@ export class AppCore { // Initialize lazy loading for images on all pages lazyLoadImages(); - // Initialize infinite scroll for pages that need it + // Initialize virtual scroll for pages that need it if (['loras', 'recipes', 'checkpoints'].includes(pageType)) { initializeInfiniteScroll(pageType); } diff --git a/static/js/loras.js b/static/js/loras.js index 5dcc9638..b2d08eba 100644 --- a/static/js/loras.js +++ b/static/js/loras.js @@ -63,8 +63,14 @@ class LoraPageManager { // Initialize the bulk manager bulkManager.initialize(); - // Initialize common page features (lazy loading, infinite scroll) + // Initialize common page features (virtual scroll) appCore.initializePageFeatures(); + + // Add virtual scroll class to grid for CSS adjustments + const loraGrid = document.getElementById('loraGrid'); + if (loraGrid && state.virtualScroller) { + loraGrid.classList.add('virtual-scroll'); + } } } diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js new file mode 100644 index 00000000..eb4cd6cc --- /dev/null +++ b/static/js/utils/VirtualScroller.js @@ -0,0 +1,411 @@ +import { state, getCurrentPageState } from '../state/index.js'; +import { showToast } from './uiHelpers.js'; + +export class VirtualScroller { + constructor(options) { + // Configuration + this.gridElement = options.gridElement; + this.createItemFn = options.createItemFn; + this.fetchItemsFn = options.fetchItemsFn; + this.overscan = options.overscan || 5; // Extra items to render above/below viewport + this.containerElement = options.containerElement || this.gridElement.parentElement; + this.batchSize = options.batchSize || 50; + this.pageSize = options.pageSize || 100; + this.itemAspectRatio = 896/1152; // Aspect ratio of cards + + // State + this.items = []; // All items metadata + this.renderedItems = new Map(); // Map of rendered DOM elements by index + this.totalItems = 0; + this.isLoading = false; + this.hasMore = true; + this.lastScrollTop = 0; + this.scrollDirection = 'down'; + this.lastRenderRange = { start: 0, end: 0 }; + this.pendingScroll = null; + this.resizeObserver = null; + + // Responsive layout state + this.itemWidth = 0; + this.itemHeight = 0; + this.columnsCount = 0; + this.gridPadding = 12; // Gap between cards + this.columnGap = 12; // Horizontal gap + + // Initialize + this.initializeContainer(); + this.setupEventListeners(); + this.calculateLayout(); + } + + initializeContainer() { + // Add virtual scroll class to grid + this.gridElement.classList.add('virtual-scroll'); + + // Set the container to have relative positioning + if (getComputedStyle(this.containerElement).position === 'static') { + this.containerElement.style.position = 'relative'; + } + + // Create a spacer element with the total height + this.spacerElement = document.createElement('div'); + this.spacerElement.className = 'virtual-scroll-spacer'; + this.spacerElement.style.width = '100%'; + this.spacerElement.style.height = '0px'; // Will be updated as items are loaded + this.spacerElement.style.pointerEvents = 'none'; + + // The grid will be used for the actual visible items + this.gridElement.style.position = 'relative'; + this.gridElement.style.minHeight = '0'; + + // Place the spacer inside the grid container + this.gridElement.appendChild(this.spacerElement); + } + + calculateLayout() { + // Get container width + const containerWidth = this.containerElement.clientWidth; + + // Calculate ideal card width based on breakpoints + let baseCardWidth = 260; // Default for 1080p + + // Adjust card width based on screen width + if (window.innerWidth >= 3000) { // 4K + baseCardWidth = 280; + } else if (window.innerWidth >= 2000) { // 2K/1440p + baseCardWidth = 270; + } + + // Calculate how many columns can fit + const availableWidth = Math.min( + containerWidth, + window.innerWidth >= 3000 ? 2400 : // 4K + window.innerWidth >= 2000 ? 1800 : // 2K + 1400 // 1080p + ); + + // Calculate column count based on available width and card width + this.columnsCount = Math.max(1, Math.floor((availableWidth + this.columnGap) / (baseCardWidth + this.columnGap))); + + // Calculate actual item width based on container and column count + this.itemWidth = (availableWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount; + + // Calculate height based on aspect ratio + this.itemHeight = this.itemWidth / this.itemAspectRatio; + + // Calculate the left offset to center the grid + this.leftOffset = Math.max(0, (containerWidth - availableWidth) / 2); + + // Log layout info + console.log('Virtual Scroll Layout:', { + containerWidth, + availableWidth, + columnsCount: this.columnsCount, + itemWidth: this.itemWidth, + itemHeight: this.itemHeight, + leftOffset: this.leftOffset + }); + + // Update grid element max-width to match available width + this.gridElement.style.maxWidth = `${availableWidth}px`; + + // Update spacer height + this.updateSpacerHeight(); + + // Re-render with new layout + this.clearRenderedItems(); + this.scheduleRender(); + + return true; + } + + setupEventListeners() { + // Debounced scroll handler + this.scrollHandler = this.debounce(() => this.handleScroll(), 10); + this.containerElement.addEventListener('scroll', this.scrollHandler); + + // Window resize handler for layout recalculation + this.resizeHandler = this.debounce(() => { + this.calculateLayout(); + }, 150); + + window.addEventListener('resize', this.resizeHandler); + + // Use ResizeObserver for more accurate container size detection + if (typeof ResizeObserver !== 'undefined') { + this.resizeObserver = new ResizeObserver(this.debounce(() => { + this.calculateLayout(); + }, 150)); + + this.resizeObserver.observe(this.containerElement); + } + } + + async initialize() { + try { + await this.loadInitialBatch(); + this.scheduleRender(); + } catch (err) { + console.error('Failed to initialize virtual scroller:', err); + showToast('Failed to load items', 'error'); + } + } + + async loadInitialBatch() { + const pageState = getCurrentPageState(); + if (this.isLoading) return; + + this.isLoading = true; + try { + const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); + this.items = items || []; + this.totalItems = totalItems || 0; + this.hasMore = hasMore; + + // Update the spacer height based on the total number of items + this.updateSpacerHeight(); + + // Reset page state to sync with our virtual scroller + pageState.currentPage = 2; // Next page to load would be 2 + pageState.hasMore = this.hasMore; + pageState.isLoading = false; + + return { items, totalItems, hasMore }; + } catch (err) { + console.error('Failed to load initial batch:', err); + this.isLoading = false; + throw err; + } + } + + async loadMoreItems() { + const pageState = getCurrentPageState(); + if (this.isLoading || !this.hasMore) return; + + this.isLoading = true; + pageState.isLoading = true; + + try { + const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize); + + if (items && items.length > 0) { + this.items = [...this.items, ...items]; + this.hasMore = hasMore; + pageState.hasMore = hasMore; + + // Update page for next request + pageState.currentPage++; + + // Update the spacer height + this.updateSpacerHeight(); + + // Render the newly loaded items if they're in view + this.scheduleRender(); + } else { + this.hasMore = false; + pageState.hasMore = false; + } + + return items; + } catch (err) { + console.error('Failed to load more items:', err); + showToast('Failed to load more items', 'error'); + } finally { + this.isLoading = false; + pageState.isLoading = false; + } + } + + updateSpacerHeight() { + if (this.columnsCount === 0) return; + + // Calculate total rows needed based on total items and columns + const totalRows = Math.ceil(this.totalItems / this.columnsCount); + const totalHeight = totalRows * this.itemHeight; + + // Update spacer height to represent all items + this.spacerElement.style.height = `${totalHeight}px`; + } + + getVisibleRange() { + const scrollTop = this.containerElement.scrollTop; + const viewportHeight = this.containerElement.clientHeight; + + // Calculate the visible row range + const startRow = Math.floor(scrollTop / this.itemHeight); + const endRow = Math.ceil((scrollTop + viewportHeight) / this.itemHeight); + + // Add overscan for smoother scrolling + const overscanRows = this.overscan; + const firstRow = Math.max(0, startRow - overscanRows); + const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows); + + // Calculate item indices + const firstIndex = firstRow * this.columnsCount; + const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount); + + return { start: firstIndex, end: lastIndex }; + } + + scheduleRender() { + if (this.renderScheduled) return; + + this.renderScheduled = true; + requestAnimationFrame(() => { + this.renderItems(); + this.renderScheduled = false; + }); + } + + renderItems() { + if (this.items.length === 0 || this.columnsCount === 0) return; + + const { start, end } = this.getVisibleRange(); + + // Check if render range has significantly changed + const isSameRange = + start >= this.lastRenderRange.start && + end <= this.lastRenderRange.end && + Math.abs(start - this.lastRenderRange.start) < 10; + + if (isSameRange) return; + + this.lastRenderRange = { start, end }; + + // Determine which items need to be added and removed + const currentIndices = new Set(); + for (let i = start; i < end && i < this.items.length; i++) { + currentIndices.add(i); + } + + // Remove items that are no longer visible + for (const [index, element] of this.renderedItems.entries()) { + if (!currentIndices.has(index)) { + element.remove(); + this.renderedItems.delete(index); + } + } + + // Add new visible items + for (let i = start; i < end && i < this.items.length; i++) { + if (!this.renderedItems.has(i)) { + const item = this.items[i]; + const element = this.createItemElement(item, i); + this.gridElement.appendChild(element); + this.renderedItems.set(i, element); + } + } + + // If we're close to the end and have more items to load, fetch them + if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) { + this.loadMoreItems(); + } + } + + clearRenderedItems() { + this.renderedItems.forEach(element => element.remove()); + this.renderedItems.clear(); + this.lastRenderRange = { start: 0, end: 0 }; + } + + refreshWithData(items, totalItems, hasMore) { + this.items = items || []; + this.totalItems = totalItems || 0; + this.hasMore = hasMore; + this.updateSpacerHeight(); + + // Clear all rendered items and redraw + this.clearRenderedItems(); + this.scheduleRender(); + } + + createItemElement(item, index) { + // Create the DOM element + const element = this.createItemFn(item); + + // Add virtual scroll item class + element.classList.add('virtual-scroll-item'); + + // Calculate the position + const row = Math.floor(index / this.columnsCount); + const col = index % this.columnsCount; + + // Calculate precise positions + const topPos = row * this.itemHeight; + const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap)); + + // Position the element with absolute positioning + element.style.position = 'absolute'; + element.style.left = `${leftPos}px`; + element.style.top = `${topPos}px`; + element.style.width = `${this.itemWidth}px`; + element.style.height = `${this.itemHeight}px`; + + return element; + } + + handleScroll() { + // Determine scroll direction + const scrollTop = this.containerElement.scrollTop; + this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; + this.lastScrollTop = scrollTop; + + // Render visible items + this.scheduleRender(); + + // If we're near the bottom and have more items, load them + const { clientHeight, scrollHeight } = this.containerElement; + const scrollBottom = scrollTop + clientHeight; + const scrollThreshold = scrollHeight - (this.itemHeight * this.overscan); + + if (scrollBottom >= scrollThreshold && this.hasMore && !this.isLoading) { + this.loadMoreItems(); + } + } + + reset() { + // Remove all rendered items + this.clearRenderedItems(); + + // Reset state + this.items = []; + this.totalItems = 0; + this.hasMore = true; + + // Reset spacer height + this.spacerElement.style.height = '0px'; + + // Schedule a re-render + this.scheduleRender(); + } + + dispose() { + // Remove event listeners + this.containerElement.removeEventListener('scroll', this.scrollHandler); + window.removeEventListener('resize', this.resizeHandler); + + // Clean up the resize observer if present + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + + // Remove rendered elements + this.clearRenderedItems(); + + // Remove spacer + this.spacerElement.remove(); + + // Remove virtual scroll class + this.gridElement.classList.remove('virtual-scroll'); + } + + // Utility method for debouncing + debounce(func, wait) { + let timeout; + return function(...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), wait); + }; + } +} diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 40ca85af..4b035d60 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -1,12 +1,63 @@ import { state, getCurrentPageState } from '../state/index.js'; -import { loadMoreLoras } from '../api/loraApi.js'; -import { loadMoreCheckpoints } from '../api/checkpointApi.js'; import { debounce } from './debounce.js'; +import { VirtualScroller } from './VirtualScroller.js'; +import { createLoraCard } from '../components/LoraCard.js'; +import { fetchLorasPage } from '../api/loraApi.js'; +import { showToast } from './uiHelpers.js'; -export function initializeInfiniteScroll(pageType = 'loras') { - // Clean up any existing observer - if (state.observer) { - state.observer.disconnect(); +// Function to dynamically import the appropriate card creator based on page type +async function getCardCreator(pageType) { + if (pageType === 'loras') { + return createLoraCard; + } else if (pageType === 'recipes') { + try { + const { createRecipeCard } = await import('../components/RecipeCard.js'); + return createRecipeCard; + } catch (err) { + console.error('Failed to load recipe card creator:', err); + return null; + } + } else if (pageType === 'checkpoints') { + try { + const { createCheckpointCard } = await import('../components/CheckpointCard.js'); + return createCheckpointCard; + } catch (err) { + console.error('Failed to load checkpoint card creator:', err); + return null; + } + } + return null; +} + +// Function to get the appropriate data fetcher based on page type +async function getDataFetcher(pageType) { + if (pageType === 'loras') { + return fetchLorasPage; + } else if (pageType === 'recipes') { + try { + const { fetchRecipesPage } = await import('../api/recipeApi.js'); + return fetchRecipesPage; + } catch (err) { + console.error('Failed to load recipe data fetcher:', err); + return null; + } + } else if (pageType === 'checkpoints') { + try { + const { fetchCheckpointsPage } = await import('../api/checkpointApi.js'); + return fetchCheckpointsPage; + } catch (err) { + console.error('Failed to load checkpoint data fetcher:', err); + return null; + } + } + return null; +} + +export async function initializeInfiniteScroll(pageType = 'loras') { + // Clean up any existing virtual scroller + if (state.virtualScroller) { + state.virtualScroller.dispose(); + state.virtualScroller = null; } // Set the current page type @@ -20,106 +71,83 @@ export function initializeInfiniteScroll(pageType = 'loras') { return; } - // Determine the load more function and grid ID based on page type - let loadMoreFunction; + // Use virtual scrolling for all page types + await initializeVirtualScroll(pageType); +} + +async function initializeVirtualScroll(pageType) { + // Determine the grid ID based on page type let gridId; switch (pageType) { case 'recipes': - loadMoreFunction = () => { - if (!pageState.isLoading && pageState.hasMore) { - window.recipeManager.loadRecipes(false); // false to not reset pagination - } - }; gridId = 'recipeGrid'; break; case 'checkpoints': - loadMoreFunction = () => { - if (!pageState.isLoading && pageState.hasMore) { - loadMoreCheckpoints(false); // false to not reset - } - }; gridId = 'checkpointGrid'; break; case 'loras': default: - loadMoreFunction = () => { - if (!pageState.isLoading && pageState.hasMore) { - loadMoreLoras(false); // false to not reset - } - }; gridId = 'loraGrid'; break; } - const debouncedLoadMore = debounce(loadMoreFunction, 100); - const grid = document.getElementById(gridId); + if (!grid) { - console.warn(`Grid with ID "${gridId}" not found for infinite scroll`); + console.warn(`Grid with ID "${gridId}" not found for virtual scroll`); return; } - // Remove any existing sentinel - const existingSentinel = document.getElementById('scroll-sentinel'); - if (existingSentinel) { - existingSentinel.remove(); + const pageContent = document.querySelector('.page-content'); + + if (!pageContent) { + console.warn('Page content element not found for virtual scroll'); + return; } - // 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(); + try { + // Get the card creator and data fetcher for this page type + const createCardFn = await getCardCreator(pageType); + const fetchDataFn = await getDataFetcher(pageType); + + if (!createCardFn || !fetchDataFn) { + throw new Error(`Required components not available for ${pageType} page`); } - }, 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; + + // Initialize the virtual scroller + state.virtualScroller = new VirtualScroller({ + gridElement: grid, + containerElement: pageContent, + createItemFn: createCardFn, + fetchItemsFn: fetchDataFn, + pageSize: 100 + }); + + // Initialize the virtual scroller + await state.virtualScroller.initialize(); + + // Add grid class for CSS styling + grid.classList.add('virtual-scroll'); + + } catch (error) { + console.error(`Error initializing virtual scroller for ${pageType}:`, error); + showToast(`Failed to initialize ${pageType} page. Please reload.`, 'error'); + + // Fallback: show a message in the grid + grid.innerHTML = ` +
+

Failed to initialize ${pageType}

+

There was an error loading this page. Please try reloading.

+
+ `; } - - // Add a simple backup scroll handler - const handleScroll = debounce(() => { - if (pageState.isLoading || !pageState.hasMore) return; - - const sentinel = document.getElementById('scroll-sentinel'); - if (!sentinel) return; - - const rect = sentinel.getBoundingClientRect(); - const windowHeight = window.innerHeight; - - if (rect.top < windowHeight + 200) { - debouncedLoadMore(); - } - }, 200); - - state.scrollHandler = handleScroll; - window.addEventListener('scroll', state.scrollHandler); - - // Clear any existing interval - if (state.scrollCheckInterval) { - clearInterval(state.scrollCheckInterval); - state.scrollCheckInterval = null; +} + +// Export a method to refresh the virtual scroller when filters change +export function refreshVirtualScroll() { + if (state.virtualScroller) { + state.virtualScroller.reset(); + state.virtualScroller.initialize(); } } \ No newline at end of file From 311e89e9e7ff6290b934e09c463b14904d27facb Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 13:59:11 +0800 Subject: [PATCH 2/8] checkpoint --- static/css/components/card.css | 4 +- static/js/api/loraApi.js | 1 - static/js/utils/VirtualScroller.js | 141 +++++++++++++++++++++++------ static/js/utils/infiniteScroll.js | 10 +- 4 files changed, 120 insertions(+), 36 deletions(-) diff --git a/static/css/components/card.css b/static/css/components/card.css index bf07d0e5..4d7a4096 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -2,7 +2,8 @@ .card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); /* Base size */ - gap: 12px; /* Reduced from var(--space-2) for tighter horizontal spacing */ + gap: 12px; /* Consistent gap for both row and column spacing */ + row-gap: 20px; /* Increase vertical spacing between rows */ margin-top: var(--space-2); padding-top: 4px; /* 添加顶部内边距,为悬停动画提供空间 */ padding-bottom: 4px; /* 添加底部内边距,为悬停动画提供空间 */ @@ -395,7 +396,6 @@ box-sizing: border-box; transition: transform 160ms ease-out; margin: 0; /* Remove margins, positioning is handled by VirtualScroller */ - padding: 6px; /* Add consistent padding on all sides */ width: 100%; /* Allow width to be set by the VirtualScroller */ } diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 5010e948..19505056 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -62,7 +62,6 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) { * @returns {Promise} Object containing items, total count, and pagination info */ export async function fetchLorasPage(page = 1, pageSize = 50) { - console.log('Fetching loras page:', page, pageSize); return fetchModelsPage({ modelType: 'lora', page, diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index eb4cd6cc..92e76afd 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -9,9 +9,11 @@ export class VirtualScroller { this.fetchItemsFn = options.fetchItemsFn; this.overscan = options.overscan || 5; // Extra items to render above/below viewport this.containerElement = options.containerElement || this.gridElement.parentElement; + this.scrollContainer = options.scrollContainer || this.containerElement; this.batchSize = options.batchSize || 50; this.pageSize = options.pageSize || 100; this.itemAspectRatio = 896/1152; // Aspect ratio of cards + this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px) // State this.items = []; // All items metadata @@ -32,6 +34,10 @@ export class VirtualScroller { this.gridPadding = 12; // Gap between cards this.columnGap = 12; // Horizontal gap + // Add loading timeout state + this.loadingTimeout = null; + this.loadingTimeoutDuration = options.loadingTimeoutDuration || 15000; // 15 seconds default + // Initialize this.initializeContainer(); this.setupEventListeners(); @@ -63,8 +69,14 @@ export class VirtualScroller { } calculateLayout() { - // Get container width + // Get container width and style information const containerWidth = this.containerElement.clientWidth; + const containerStyle = getComputedStyle(this.containerElement); + const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0; + const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0; + + // Calculate available content width (excluding padding) + const availableContentWidth = containerWidth - paddingLeft - paddingRight; // Calculate ideal card width based on breakpoints let baseCardWidth = 260; // Default for 1080p @@ -77,37 +89,41 @@ export class VirtualScroller { } // Calculate how many columns can fit - const availableWidth = Math.min( - containerWidth, - window.innerWidth >= 3000 ? 2400 : // 4K - window.innerWidth >= 2000 ? 1800 : // 2K - 1400 // 1080p - ); + const maxGridWidth = window.innerWidth >= 3000 ? 2400 : // 4K + window.innerWidth >= 2000 ? 1800 : // 2K + 1400; // 1080p + + // Use the smaller of available content width or max grid width + const actualGridWidth = Math.min(availableContentWidth, maxGridWidth); // Calculate column count based on available width and card width - this.columnsCount = Math.max(1, Math.floor((availableWidth + this.columnGap) / (baseCardWidth + this.columnGap))); + this.columnsCount = Math.max(1, Math.floor((actualGridWidth + this.columnGap) / (baseCardWidth + this.columnGap))); - // Calculate actual item width based on container and column count - this.itemWidth = (availableWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount; + // Calculate actual item width + this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount; // Calculate height based on aspect ratio this.itemHeight = this.itemWidth / this.itemAspectRatio; - // Calculate the left offset to center the grid - this.leftOffset = Math.max(0, (containerWidth - availableWidth) / 2); + // Calculate the left offset to center the grid within the content area + this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); // Log layout info console.log('Virtual Scroll Layout:', { containerWidth, - availableWidth, + availableContentWidth, + actualGridWidth, columnsCount: this.columnsCount, itemWidth: this.itemWidth, itemHeight: this.itemHeight, - leftOffset: this.leftOffset + leftOffset: this.leftOffset, + paddingLeft, + paddingRight, + rowGap: this.rowGap // Log row gap for debugging }); // Update grid element max-width to match available width - this.gridElement.style.maxWidth = `${availableWidth}px`; + this.gridElement.style.maxWidth = `${actualGridWidth}px`; // Update spacer height this.updateSpacerHeight(); @@ -122,7 +138,7 @@ export class VirtualScroller { setupEventListeners() { // Debounced scroll handler this.scrollHandler = this.debounce(() => this.handleScroll(), 10); - this.containerElement.addEventListener('scroll', this.scrollHandler); + this.scrollContainer.addEventListener('scroll', this.scrollHandler); // Window resize handler for layout recalculation this.resizeHandler = this.debounce(() => { @@ -156,6 +172,8 @@ export class VirtualScroller { if (this.isLoading) return; this.isLoading = true; + this.setLoadingTimeout(); // Add loading timeout safety + try { const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); this.items = items || []; @@ -173,8 +191,10 @@ export class VirtualScroller { return { items, totalItems, hasMore }; } catch (err) { console.error('Failed to load initial batch:', err); - this.isLoading = false; throw err; + } finally { + this.isLoading = false; + this.clearLoadingTimeout(); // Clear the timeout } } @@ -184,8 +204,10 @@ export class VirtualScroller { this.isLoading = true; pageState.isLoading = true; + this.setLoadingTimeout(); // Add loading timeout safety try { + console.log('Loading more items, page:', pageState.currentPage); const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize); if (items && items.length > 0) { @@ -201,9 +223,12 @@ export class VirtualScroller { // Render the newly loaded items if they're in view this.scheduleRender(); + + console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`); } else { this.hasMore = false; pageState.hasMore = false; + console.log('No more items to load'); } return items; @@ -213,6 +238,30 @@ export class VirtualScroller { } finally { this.isLoading = false; pageState.isLoading = false; + this.clearLoadingTimeout(); // Clear the timeout + } + } + + // Add new methods for loading timeout + setLoadingTimeout() { + // Clear any existing timeout first + this.clearLoadingTimeout(); + + // Set a new timeout to prevent loading state from getting stuck + this.loadingTimeout = setTimeout(() => { + if (this.isLoading) { + console.warn('Loading timeout occurred. Resetting loading state.'); + this.isLoading = false; + const pageState = getCurrentPageState(); + pageState.isLoading = false; + } + }, this.loadingTimeoutDuration); + } + + clearLoadingTimeout() { + if (this.loadingTimeout) { + clearTimeout(this.loadingTimeout); + this.loadingTimeout = null; } } @@ -221,19 +270,21 @@ export class VirtualScroller { // Calculate total rows needed based on total items and columns const totalRows = Math.ceil(this.totalItems / this.columnsCount); - const totalHeight = totalRows * this.itemHeight; + // Add row gaps to the total height calculation + const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap; // Update spacer height to represent all items this.spacerElement.style.height = `${totalHeight}px`; } getVisibleRange() { - const scrollTop = this.containerElement.scrollTop; - const viewportHeight = this.containerElement.clientHeight; + const scrollTop = this.scrollContainer.scrollTop; + const viewportHeight = this.scrollContainer.clientHeight; - // Calculate the visible row range - const startRow = Math.floor(scrollTop / this.itemHeight); - const endRow = Math.ceil((scrollTop + viewportHeight) / this.itemHeight); + // Calculate the visible row range, accounting for row gaps + const rowHeight = this.itemHeight + this.rowGap; + const startRow = Math.floor(scrollTop / rowHeight); + const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight); // Add overscan for smoother scrolling const overscanRows = this.overscan; @@ -330,8 +381,11 @@ export class VirtualScroller { const row = Math.floor(index / this.columnsCount); const col = index % this.columnsCount; - // Calculate precise positions - const topPos = row * this.itemHeight; + // Calculate precise positions with row gap included + const topPos = row * (this.itemHeight + this.rowGap); + + // Position correctly with leftOffset (no need to add padding as absolute + // positioning is already relative to the padding edge of the container) const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap)); // Position the element with absolute positioning @@ -346,7 +400,7 @@ export class VirtualScroller { handleScroll() { // Determine scroll direction - const scrollTop = this.containerElement.scrollTop; + const scrollTop = this.scrollContainer.scrollTop; this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.lastScrollTop = scrollTop; @@ -354,11 +408,35 @@ export class VirtualScroller { this.scheduleRender(); // If we're near the bottom and have more items, load them - const { clientHeight, scrollHeight } = this.containerElement; + const { clientHeight, scrollHeight } = this.scrollContainer; const scrollBottom = scrollTop + clientHeight; - const scrollThreshold = scrollHeight - (this.itemHeight * this.overscan); - if (scrollBottom >= scrollThreshold && this.hasMore && !this.isLoading) { + // Fix the threshold calculation - use percentage of remaining height instead + // We'll trigger loading when within 20% of the bottom of rendered content + const remainingScroll = scrollHeight - scrollBottom; + const scrollThreshold = Math.min( + // Either trigger when within 20% of the total height from bottom + scrollHeight * 0.2, + // Or when within 2 rows of content from the bottom, whichever is larger + (this.itemHeight + this.rowGap) * 2 + ); + + const shouldLoadMore = remainingScroll <= scrollThreshold; + + // Enhanced debugging + // console.log('Scroll metrics:', { + // scrollBottom, + // scrollHeight, + // remainingScroll, + // scrollThreshold, + // shouldLoad: shouldLoadMore, + // hasMore: this.hasMore, + // isLoading: this.isLoading, + // itemsLoaded: this.items.length, + // totalItems: this.totalItems + // }); + + if (shouldLoadMore && this.hasMore && !this.isLoading) { this.loadMoreItems(); } } @@ -381,7 +459,7 @@ export class VirtualScroller { dispose() { // Remove event listeners - this.containerElement.removeEventListener('scroll', this.scrollHandler); + this.scrollContainer.removeEventListener('scroll', this.scrollHandler); window.removeEventListener('resize', this.resizeHandler); // Clean up the resize observer if present @@ -397,6 +475,9 @@ export class VirtualScroller { // Remove virtual scroll class this.gridElement.classList.remove('virtual-scroll'); + + // Clear any pending timeout + this.clearLoadingTimeout(); } // Utility method for debouncing diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 4b035d60..e11741f9 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -99,7 +99,9 @@ async function initializeVirtualScroll(pageType) { return; } - const pageContent = document.querySelector('.page-content'); + // Change this line to get the actual scrolling container + const pageContainer = document.querySelector('.page-content'); + const pageContent = pageContainer.querySelector('.container'); if (!pageContent) { console.warn('Page content element not found for virtual scroll'); @@ -115,13 +117,15 @@ async function initializeVirtualScroll(pageType) { throw new Error(`Required components not available for ${pageType} page`); } - // Initialize the virtual scroller + // Pass the correct scrolling container state.virtualScroller = new VirtualScroller({ gridElement: grid, containerElement: pageContent, + scrollContainer: pageContainer, // Add this new parameter createItemFn: createCardFn, fetchItemsFn: fetchDataFn, - pageSize: 100 + pageSize: 100, + rowGap: 20 // Add consistent vertical spacing between rows }); // Initialize the virtual scroller From 303477db70e5c1f53b91cc434f03118362b0641e Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 14:50:10 +0800 Subject: [PATCH 3/8] update --- static/js/components/LoraCard.js | 351 +++++++++++++++-------------- static/js/core.js | 6 + static/js/utils/VirtualScroller.js | 143 ++++++++++-- static/js/utils/infiniteScroll.js | 7 +- 4 files changed, 322 insertions(+), 185 deletions(-) diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index aef11567..c2aaa6b1 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -6,6 +6,181 @@ import { NSFW_LEVELS } from '../utils/constants.js'; import { replacePreview, saveModelMetadata } from '../api/loraApi.js' import { showDeleteModal } from '../utils/modalUtils.js'; +// Add a global event delegation handler +export function setupLoraCardEventDelegation() { + const gridElement = document.getElementById('loraGrid'); + if (!gridElement) return; + + // Remove any existing event listener to prevent duplication + gridElement.removeEventListener('click', handleLoraCardEvent); + + // Add the event delegation handler + gridElement.addEventListener('click', handleLoraCardEvent); +} + +// Event delegation handler for all lora card events +function handleLoraCardEvent(event) { + // Find the closest card element + const card = event.target.closest('.lora-card'); + if (!card) return; + + // Handle specific elements within the card + if (event.target.closest('.toggle-blur-btn')) { + event.stopPropagation(); + toggleBlurContent(card); + return; + } + + if (event.target.closest('.show-content-btn')) { + event.stopPropagation(); + showBlurredContent(card); + return; + } + + if (event.target.closest('.fa-star')) { + event.stopPropagation(); + toggleFavorite(card); + return; + } + + if (event.target.closest('.fa-globe')) { + event.stopPropagation(); + if (card.dataset.from_civitai === 'true') { + openCivitai(card.dataset.name); + } + return; + } + + if (event.target.closest('.fa-copy')) { + event.stopPropagation(); + copyLoraCode(card); + return; + } + + if (event.target.closest('.fa-trash')) { + event.stopPropagation(); + showDeleteModal(card.dataset.filepath); + return; + } + + if (event.target.closest('.fa-image')) { + event.stopPropagation(); + replacePreview(card.dataset.filepath); + return; + } + + // If no specific element was clicked, handle the card click (show modal or toggle selection) + if (state.bulkMode) { + // Toggle selection using the bulk manager + bulkManager.toggleCardSelection(card); + } else { + // Normal behavior - show modal + const loraMeta = { + sha256: card.dataset.sha256, + file_path: card.dataset.filepath, + model_name: card.dataset.name, + file_name: card.dataset.file_name, + folder: card.dataset.folder, + modified: card.dataset.modified, + file_size: card.dataset.file_size, + from_civitai: card.dataset.from_civitai === 'true', + base_model: card.dataset.base_model, + usage_tips: card.dataset.usage_tips, + notes: card.dataset.notes, + favorite: card.dataset.favorite === 'true', + // Parse civitai metadata from the card's dataset + civitai: (() => { + try { + // Attempt to parse the JSON string + return JSON.parse(card.dataset.meta || '{}'); + } catch (e) { + console.error('Failed to parse civitai metadata:', e); + return {}; // Return empty object on error + } + })(), + tags: JSON.parse(card.dataset.tags || '[]'), + modelDescription: card.dataset.modelDescription || '' + }; + showLoraModal(loraMeta); + } +} + +// Helper functions for event handling +function toggleBlurContent(card) { + const preview = card.querySelector('.card-preview'); + const isBlurred = preview.classList.toggle('blurred'); + const icon = card.querySelector('.toggle-blur-btn i'); + + // Update the icon based on blur state + if (isBlurred) { + icon.className = 'fas fa-eye'; + } else { + icon.className = 'fas fa-eye-slash'; + } + + // Toggle the overlay visibility + const overlay = card.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = isBlurred ? 'flex' : 'none'; + } +} + +function showBlurredContent(card) { + const preview = card.querySelector('.card-preview'); + preview.classList.remove('blurred'); + + // Update the toggle button icon + const toggleBtn = card.querySelector('.toggle-blur-btn'); + if (toggleBtn) { + toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; + } + + // Hide the overlay + const overlay = card.querySelector('.nsfw-overlay'); + if (overlay) { + overlay.style.display = 'none'; + } +} + +async function toggleFavorite(card) { + const starIcon = card.querySelector('.fa-star'); + const isFavorite = starIcon.classList.contains('fas'); + const newFavoriteState = !isFavorite; + + try { + // Save the new favorite state to the server + await saveModelMetadata(card.dataset.filepath, { + favorite: newFavoriteState + }); + + // Update the UI + if (newFavoriteState) { + starIcon.classList.remove('far'); + starIcon.classList.add('fas', 'favorite-active'); + starIcon.title = 'Remove from favorites'; + card.dataset.favorite = 'true'; + showToast('Added to favorites', 'success'); + } else { + starIcon.classList.remove('fas', 'favorite-active'); + starIcon.classList.add('far'); + starIcon.title = 'Add to favorites'; + card.dataset.favorite = 'false'; + showToast('Removed from favorites', 'success'); + } + } catch (error) { + console.error('Failed to update favorite status:', error); + showToast('Failed to update favorite status', 'error'); + } +} + +async function copyLoraCode(card) { + const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); + const strength = usageTips.strength || 1; + const loraSyntax = ``; + + await copyToClipboard(loraSyntax, 'LoRA syntax copied'); +} + export function createLoraCard(lora) { const card = document.createElement('div'); card.className = 'lora-card'; @@ -123,162 +298,12 @@ export function createLoraCard(lora) { `; - // Main card click event - modified to handle bulk mode - card.addEventListener('click', () => { - // Check if we're in bulk mode - if (state.bulkMode) { - // Toggle selection using the bulk manager - bulkManager.toggleCardSelection(card); - } else { - // Normal behavior - show modal - const loraMeta = { - sha256: card.dataset.sha256, - file_path: card.dataset.filepath, - model_name: card.dataset.name, - file_name: card.dataset.file_name, - folder: card.dataset.folder, - modified: card.dataset.modified, - file_size: card.dataset.file_size, - from_civitai: card.dataset.from_civitai === 'true', - base_model: card.dataset.base_model, - usage_tips: card.dataset.usage_tips, - notes: card.dataset.notes, - favorite: card.dataset.favorite === 'true', - // Parse civitai metadata from the card's dataset - civitai: (() => { - try { - // Attempt to parse the JSON string - return JSON.parse(card.dataset.meta || '{}'); - } catch (e) { - console.error('Failed to parse civitai metadata:', e); - return {}; // Return empty object on error - } - })(), - tags: JSON.parse(card.dataset.tags || '[]'), - modelDescription: card.dataset.modelDescription || '' - }; - showLoraModal(loraMeta); - } - }); - - // Toggle blur button functionality - const toggleBlurBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBlurBtn) { - toggleBlurBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const preview = card.querySelector('.card-preview'); - const isBlurred = preview.classList.toggle('blurred'); - const icon = toggleBlurBtn.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 = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = isBlurred ? 'flex' : 'none'; - } - }); - } - - // Show content button functionality - const showContentBtn = card.querySelector('.show-content-btn'); - if (showContentBtn) { - showContentBtn.addEventListener('click', (e) => { - e.stopPropagation(); - const preview = card.querySelector('.card-preview'); - preview.classList.remove('blurred'); - - // Update the toggle button icon - const toggleBtn = card.querySelector('.toggle-blur-btn'); - if (toggleBtn) { - toggleBtn.querySelector('i').className = 'fas fa-eye-slash'; - } - - // Hide the overlay - const overlay = card.querySelector('.nsfw-overlay'); - if (overlay) { - overlay.style.display = 'none'; - } - }); - } - - // Favorite button click event - card.querySelector('.fa-star')?.addEventListener('click', async e => { - e.stopPropagation(); - const starIcon = e.currentTarget; - const isFavorite = starIcon.classList.contains('fas'); - const newFavoriteState = !isFavorite; - - try { - // Save the new favorite state to the server - await saveModelMetadata(card.dataset.filepath, { - favorite: newFavoriteState - }); - - // Update the UI - if (newFavoriteState) { - starIcon.classList.remove('far'); - starIcon.classList.add('fas', 'favorite-active'); - starIcon.title = 'Remove from favorites'; - card.dataset.favorite = 'true'; - showToast('Added to favorites', 'success'); - } else { - starIcon.classList.remove('fas', 'favorite-active'); - starIcon.classList.add('far'); - starIcon.title = 'Add to favorites'; - card.dataset.favorite = 'false'; - showToast('Removed from favorites', 'success'); - } - } catch (error) { - console.error('Failed to update favorite status:', error); - showToast('Failed to update favorite status', 'error'); - } - }); - - // Copy button click event - card.querySelector('.fa-copy')?.addEventListener('click', async e => { - e.stopPropagation(); - const usageTips = JSON.parse(card.dataset.usage_tips || '{}'); - const strength = usageTips.strength || 1; - const loraSyntax = ``; - - await copyToClipboard(loraSyntax, 'LoRA syntax copied'); - }); - - // Civitai button click event - if (lora.from_civitai) { - card.querySelector('.fa-globe')?.addEventListener('click', e => { - e.stopPropagation(); - openCivitai(lora.model_name); - }); - } - - // Delete button click event - card.querySelector('.fa-trash')?.addEventListener('click', e => { - e.stopPropagation(); - showDeleteModal(lora.file_path); - }); - - // Replace preview button click event - card.querySelector('.fa-image')?.addEventListener('click', e => { - e.stopPropagation(); - replacePreview(lora.file_path); - }); - - // Apply bulk mode styling if currently in bulk mode - if (state.bulkMode) { - const actions = card.querySelectorAll('.card-actions'); - actions.forEach(actionGroup => { - actionGroup.style.display = 'none'; - }); + // Add a special class for virtual scroll positioning if needed + if (state.virtualScroller) { + card.classList.add('virtual-scroll-item'); } - // Add autoplayOnHover handlers for video elements if needed + // Add video auto-play on hover functionality if needed const videoElement = card.querySelector('video'); if (videoElement && autoplayOnHover) { const cardPreview = card.querySelector('.card-preview'); @@ -287,20 +312,10 @@ export function createLoraCard(lora) { videoElement.removeAttribute('autoplay'); videoElement.pause(); - // Add mouse events to trigger play/pause - cardPreview.addEventListener('mouseenter', () => { - videoElement.play(); - }); - - cardPreview.addEventListener('mouseleave', () => { - videoElement.pause(); - videoElement.currentTime = 0; - }); - } - - // Add a special class for virtual scroll positioning if needed - if (state.virtualScroller) { - card.classList.add('virtual-scroll-item'); + // Add mouse events to trigger play/pause using event attributes + // This approach reduces the number of event listeners created + cardPreview.setAttribute('onmouseenter', 'this.querySelector("video")?.play()'); + cardPreview.setAttribute('onmouseleave', 'const v=this.querySelector("video"); if(v){v.pause();v.currentTime=0;}'); } return card; diff --git a/static/js/core.js b/static/js/core.js index fd045944..2f0c526c 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -9,6 +9,7 @@ import { exampleImagesManager } from './managers/ExampleImagesManager.js'; import { showToast, initTheme, initBackToTop, lazyLoadImages } from './utils/uiHelpers.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { migrateStorageItems } from './utils/storageHelpers.js'; +import { setupLoraCardEventDelegation } from './components/LoraCard.js'; // Core application class export class AppCore { @@ -63,6 +64,11 @@ export class AppCore { // Initialize lazy loading for images on all pages lazyLoadImages(); + // Setup event delegation for lora cards if on the loras page + if (pageType === 'loras') { + setupLoraCardEventDelegation(); + } + // Initialize virtual scroll for pages that need it if (['loras', 'recipes', 'checkpoints'].includes(pageType)) { initializeInfiniteScroll(pageType); diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 92e76afd..8be0b288 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -27,6 +27,13 @@ export class VirtualScroller { this.pendingScroll = null; this.resizeObserver = null; + // Data windowing parameters + this.windowSize = options.windowSize || 2000; // ±1000 items from current view + this.windowPadding = options.windowPadding || 500; // Buffer before loading more + this.dataWindow = { start: 0, end: 0 }; // Current data window indices + this.absoluteWindowStart = 0; // Start index in absolute terms + this.fetchingWindow = false; // Flag to track window fetching state + // Responsive layout state this.itemWidth = 0; this.itemHeight = 0; @@ -176,9 +183,13 @@ export class VirtualScroller { try { const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); + + // Initialize the data window with the first batch of items this.items = items || []; this.totalItems = totalItems || 0; this.hasMore = hasMore; + this.dataWindow = { start: 0, end: this.items.length }; + this.absoluteWindowStart = 0; // Update the spacer height based on the total number of items this.updateSpacerHeight(); @@ -337,20 +348,31 @@ export class VirtualScroller { } } - // Add new visible items + // Use DocumentFragment for batch DOM operations + const fragment = document.createDocumentFragment(); + + // Add new visible items to the fragment for (let i = start; i < end && i < this.items.length; i++) { if (!this.renderedItems.has(i)) { const item = this.items[i]; const element = this.createItemElement(item, i); - this.gridElement.appendChild(element); + fragment.appendChild(element); this.renderedItems.set(i, element); } } + // Add the fragment to the grid (single DOM operation) + if (fragment.childNodes.length > 0) { + this.gridElement.appendChild(fragment); + } + // If we're close to the end and have more items to load, fetch them if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) { this.loadMoreItems(); } + + // Check if we need to slide the data window + this.slideDataWindow(); } clearRenderedItems() { @@ -404,11 +426,30 @@ export class VirtualScroller { this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.lastScrollTop = scrollTop; + // Handle large jumps in scroll position - check if we need to fetch a new window + const { scrollHeight } = this.scrollContainer; + const scrollRatio = scrollTop / scrollHeight; + + // If we've jumped to a position that's significantly outside our current window + // and we know there are many items, fetch a new data window + if (this.totalItems > this.windowSize) { + const estimatedIndex = Math.floor(scrollRatio * this.totalItems); + const currentWindowStart = this.absoluteWindowStart; + const currentWindowEnd = currentWindowStart + this.items.length; + + // If the estimated position is outside our current window by a significant amount + if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) { + // Fetch a new data window centered on the estimated position + this.fetchDataWindow(Math.max(0, estimatedIndex - Math.floor(this.windowSize / 2))); + return; // Skip normal rendering until new data is loaded + } + } + // Render visible items this.scheduleRender(); // If we're near the bottom and have more items, load them - const { clientHeight, scrollHeight } = this.scrollContainer; + const { clientHeight } = this.scrollContainer; const scrollBottom = scrollTop + clientHeight; // Fix the threshold calculation - use percentage of remaining height instead @@ -423,24 +464,94 @@ export class VirtualScroller { const shouldLoadMore = remainingScroll <= scrollThreshold; - // Enhanced debugging - // console.log('Scroll metrics:', { - // scrollBottom, - // scrollHeight, - // remainingScroll, - // scrollThreshold, - // shouldLoad: shouldLoadMore, - // hasMore: this.hasMore, - // isLoading: this.isLoading, - // itemsLoaded: this.items.length, - // totalItems: this.totalItems - // }); - if (shouldLoadMore && this.hasMore && !this.isLoading) { this.loadMoreItems(); } } + // Method to fetch data for a specific window position + async fetchDataWindow(targetIndex) { + if (this.fetchingWindow) return; + this.fetchingWindow = true; + + try { + // Calculate which page we need to fetch based on target index + const targetPage = Math.floor(targetIndex / this.pageSize) + 1; + console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`); + + const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize); + + if (items && items.length > 0) { + // Calculate new absolute window start + this.absoluteWindowStart = (targetPage - 1) * this.pageSize; + + // Replace the entire data window with new items + this.items = items; + this.dataWindow = { + start: 0, + end: items.length + }; + + this.totalItems = totalItems || 0; + this.hasMore = hasMore; + + // Update the current page for future fetches + const pageState = getCurrentPageState(); + pageState.currentPage = targetPage + 1; + pageState.hasMore = hasMore; + + // Update the spacer height and clear current rendered items + this.updateSpacerHeight(); + this.clearRenderedItems(); + this.scheduleRender(); + + console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`); + } + } catch (err) { + console.error('Failed to fetch data window:', err); + showToast('Failed to load items at this position', 'error'); + } finally { + this.fetchingWindow = false; + } + } + + // Method to slide the data window if we're approaching its edges + async slideDataWindow() { + const { start, end } = this.getVisibleRange(); + const windowStart = this.dataWindow.start; + const windowEnd = this.dataWindow.end; + const absoluteIndex = this.absoluteWindowStart + windowStart; + + // Calculate the midpoint of the visible range + const visibleMidpoint = Math.floor((start + end) / 2); + const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint; + + // Check if we're too close to the window edges + const closeToStart = start - windowStart < this.windowPadding; + const closeToEnd = windowEnd - end < this.windowPadding; + + // If we're close to either edge and have total items > window size + if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) { + // Calculate a new target index centered around the current viewport + const halfWindow = Math.floor(this.windowSize / 2); + const targetIndex = Math.max(0, absoluteMidpoint - halfWindow); + + // Don't fetch a new window if we're already showing items near the beginning + if (targetIndex === 0 && this.absoluteWindowStart === 0) { + return; + } + + // Don't fetch if we're showing the end of the list and are near the end + if (this.absoluteWindowStart + this.items.length >= this.totalItems && + this.totalItems - end < halfWindow) { + return; + } + + // Fetch the new data window + await this.fetchDataWindow(targetIndex); + } + } + reset() { // Remove all rendered items this.clearRenderedItems(); diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index e11741f9..a3a41000 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -1,7 +1,7 @@ import { state, getCurrentPageState } from '../state/index.js'; import { debounce } from './debounce.js'; import { VirtualScroller } from './VirtualScroller.js'; -import { createLoraCard } from '../components/LoraCard.js'; +import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js'; import { fetchLorasPage } from '../api/loraApi.js'; import { showToast } from './uiHelpers.js'; @@ -73,6 +73,11 @@ export async function initializeInfiniteScroll(pageType = 'loras') { // Use virtual scrolling for all page types await initializeVirtualScroll(pageType); + + // Setup event delegation for lora cards if on the loras page + if (pageType === 'loras') { + setupLoraCardEventDelegation(); + } } async function initializeVirtualScroll(pageType) { From d13b1a83ad74264fe51abab3f116506bd9e7e7d5 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 16:44:45 +0800 Subject: [PATCH 4/8] checkpoint --- static/js/api/loraApi.js | 140 +++++++++++++++--- static/js/components/LoraCard.js | 7 +- static/js/components/controls/PageControls.js | 2 +- 3 files changed, 128 insertions(+), 21 deletions(-) diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 19505056..1df4d2f6 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -10,6 +10,8 @@ import { refreshSingleModelMetadata, excludeModel as baseExcludeModel } from './baseModelApi.js'; +import { state, getCurrentPageState } from '../state/index.js'; +import { showToast } from '../utils/uiHelpers.js'; /** * Save model metadata to the server @@ -45,14 +47,66 @@ export async function excludeLora(filePath) { return baseExcludeModel(filePath, 'lora'); } +/** + * Load more loras with pagination - updated to work with VirtualScroller + * @param {boolean} resetPage - Whether to reset to the first page + * @param {boolean} updateFolders - Whether to update folder tags + * @returns {Promise} + */ export async function loadMoreLoras(resetPage = false, updateFolders = false) { - return loadMoreModels({ - resetPage, - updateFolders, - modelType: 'lora', - createCardFunction: createLoraCard, - endpoint: '/api/loras' - }); + const pageState = getCurrentPageState(); + + // Check if virtual scroller is available + if (state.virtualScroller) { + try { + // Start loading state + pageState.isLoading = true; + document.body.classList.add('loading'); + + // Reset to first page if requested + if (resetPage) { + pageState.currentPage = 1; + } + + // Fetch the first page of data + const result = await fetchLorasPage(pageState.currentPage, pageState.pageSize || 50); + + // Update virtual scroller with the new data + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page to load would be 2 + + // Update folders if needed + if (updateFolders && result.folders) { + // Import function dynamically to avoid circular dependencies + const { updateFolderTags } = await import('./baseModelApi.js'); + updateFolderTags(result.folders); + } + + return result; + } catch (error) { + console.error('Error loading loras:', error); + showToast(`Failed to load loras: ${error.message}`, 'error'); + } finally { + pageState.isLoading = false; + document.body.classList.remove('loading'); + } + } else { + // Fall back to the original implementation if virtual scroller isn't available + return loadMoreModels({ + resetPage, + updateFolders, + modelType: 'lora', + createCardFunction: createLoraCard, + endpoint: '/api/loras' + }); + } } /** @@ -87,21 +141,69 @@ export async function replacePreview(filePath) { } export function appendLoraCards(loras) { - const grid = document.getElementById('loraGrid'); - const sentinel = document.getElementById('scroll-sentinel'); - - loras.forEach(lora => { - const card = createLoraCard(lora); - grid.appendChild(card); - }); + // This function is no longer needed with virtual scrolling + // but kept for compatibility + if (state.virtualScroller) { + console.warn('appendLoraCards is deprecated when using virtual scrolling'); + } else { + const grid = document.getElementById('loraGrid'); + + loras.forEach(lora => { + const card = createLoraCard(lora); + grid.appendChild(card); + }); + } } export async function resetAndReload(updateFolders = false) { - return baseResetAndReload({ - updateFolders, - modelType: 'lora', - loadMoreFunction: loadMoreLoras - }); + const pageState = getCurrentPageState(); + + // Check if virtual scroller is available + if (state.virtualScroller) { + try { + pageState.isLoading = true; + document.body.classList.add('loading'); + + // Reset page counter + pageState.currentPage = 1; + + // Fetch the first page + const result = await fetchLorasPage(1, pageState.pageSize || 50); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page will be 2 + + // Update folders if needed + if (updateFolders && result.folders) { + // Import function dynamically to avoid circular dependencies + const { updateFolderTags } = await import('./baseModelApi.js'); + updateFolderTags(result.folders); + } + + return result; + } catch (error) { + console.error('Error reloading loras:', error); + showToast(`Failed to reload loras: ${error.message}`, 'error'); + } finally { + pageState.isLoading = false; + document.body.classList.remove('loading'); + } + } else { + // Fall back to original implementation + return baseResetAndReload({ + updateFolders, + modelType: 'lora', + loadMoreFunction: loadMoreLoras + }); + } } export async function refreshLoras() { diff --git a/static/js/components/LoraCard.js b/static/js/components/LoraCard.js index c2aaa6b1..be53ba41 100644 --- a/static/js/components/LoraCard.js +++ b/static/js/components/LoraCard.js @@ -328,7 +328,7 @@ export function updateCardsForBulkMode(isBulkMode) { document.body.classList.toggle('bulk-mode', isBulkMode); - // Get all lora cards + // Get all lora cards - this can now be from the DOM or through the virtual scroller const loraCards = document.querySelectorAll('.lora-card'); loraCards.forEach(card => { @@ -350,6 +350,11 @@ export function updateCardsForBulkMode(isBulkMode) { } }); + // If using virtual scroller, we need to rerender after toggling bulk mode + if (state.virtualScroller && typeof state.virtualScroller.scheduleRender === 'function') { + state.virtualScroller.scheduleRender(); + } + // Apply selection state to cards if entering bulk mode if (isBulkMode) { bulkManager.applySelectionState(); diff --git a/static/js/components/controls/PageControls.js b/static/js/components/controls/PageControls.js index caf101d5..9569a639 100644 --- a/static/js/components/controls/PageControls.js +++ b/static/js/components/controls/PageControls.js @@ -37,7 +37,7 @@ export class PageControls { */ initializeState() { // Set default values - this.pageState.pageSize = 20; + this.pageState.pageSize = 100; this.pageState.isLoading = false; this.pageState.hasMore = true; From 01ba3c14f8deef9dcfd293fecf33478024a91b4f Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 17:47:57 +0800 Subject: [PATCH 5/8] Implement virtual scrolling for model loading and checkpoint management --- static/js/api/baseModelApi.js | 107 +++++++++++++++++++++++++++++- static/js/api/checkpointApi.js | 75 +++++++++++++++++---- static/js/api/loraApi.js | 90 ++++--------------------- static/js/utils/infiniteScroll.js | 35 ++++------ 4 files changed, 194 insertions(+), 113 deletions(-) diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 5a282594..7c7ab734 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -1,7 +1,6 @@ // filepath: d:\Workspace\ComfyUI\custom_nodes\ComfyUI-Lora-Manager\static\js\api\baseModelApi.js import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; -import { showDeleteModal, confirmDelete } from '../utils/modalUtils.js'; import { getSessionItem, saveMapToStorage } from '../utils/storageHelpers.js'; /** @@ -279,6 +278,112 @@ export async function fetchModelsPage(options = {}) { } } +/** + * Reset and reload models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function resetAndReloadWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + pageState.isLoading = true; + document.body.classList.add('loading'); + + // Reset page counter + pageState.currentPage = 1; + + // Fetch the first page + const result = await fetchPageFunction(1, pageState.pageSize || 50); + + // Update the virtual scroller + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page will be 2 + + // Update folders if needed + if (updateFolders && result.folders) { + updateFolderTags(result.folders); + } + + return result; + } catch (error) { + console.error(`Error reloading ${modelType}s:`, error); + showToast(`Failed to reload ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + document.body.classList.remove('loading'); + } +} + +/** + * Load more models using virtual scrolling + * @param {Object} options - Operation options + * @returns {Promise} The fetch result + */ +export async function loadMoreWithVirtualScroll(options = {}) { + const { + modelType = 'lora', + resetPage = false, + updateFolders = false, + fetchPageFunction + } = options; + + const pageState = getCurrentPageState(); + + try { + // Start loading state + pageState.isLoading = true; + document.body.classList.add('loading'); + + // Reset to first page if requested + if (resetPage) { + pageState.currentPage = 1; + } + + // Fetch the first page of data + const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); + + // Update virtual scroller with the new data + state.virtualScroller.refreshWithData( + result.items, + result.totalItems, + result.hasMore + ); + + // Update state + pageState.hasMore = result.hasMore; + pageState.currentPage = 2; // Next page to load would be 2 + + // Update folders if needed + if (updateFolders && result.folders) { + updateFolderTags(result.folders); + } + + return result; + } catch (error) { + console.error(`Error loading ${modelType}s:`, error); + showToast(`Failed to load ${modelType}s: ${error.message}`, 'error'); + throw error; + } finally { + pageState.isLoading = false; + document.body.classList.remove('loading'); + } +} + // Update folder tags in the UI export function updateFolderTags(folders) { const folderTagsContainer = document.querySelector('.folder-tags'); diff --git a/static/js/api/checkpointApi.js b/static/js/api/checkpointApi.js index c5ebcd3f..ea124ea9 100644 --- a/static/js/api/checkpointApi.js +++ b/static/js/api/checkpointApi.js @@ -1,7 +1,10 @@ import { createCheckpointCard } from '../components/CheckpointCard.js'; import { loadMoreModels, + fetchModelsPage, resetAndReload as baseResetAndReload, + resetAndReloadWithVirtualScroll, + loadMoreWithVirtualScroll, refreshModels as baseRefreshModels, deleteModel as baseDeleteModel, replaceModelPreview, @@ -9,25 +12,67 @@ import { refreshSingleModelMetadata, excludeModel as baseExcludeModel } from './baseModelApi.js'; +import { state } from '../state/index.js'; -// Load more checkpoints with pagination -export async function loadMoreCheckpoints(resetPagination = true) { - return loadMoreModels({ - resetPage: resetPagination, - updateFolders: true, +/** + * Fetch checkpoints with pagination for virtual scrolling + * @param {number} page - Page number to fetch + * @param {number} pageSize - Number of items per page + * @returns {Promise} Object containing items, total count, and pagination info + */ +export async function fetchCheckpointsPage(page = 1, pageSize = 100) { + return fetchModelsPage({ modelType: 'checkpoint', - createCardFunction: createCheckpointCard, + page, + pageSize, endpoint: '/api/checkpoints' }); } +/** + * Load more checkpoints with pagination - updated to work with VirtualScroller + * @param {boolean} resetPage - Whether to reset to the first page + * @param {boolean} updateFolders - Whether to update folder tags + * @returns {Promise} + */ +export async function loadMoreCheckpoints(resetPage = false, updateFolders = false) { + // Check if virtual scroller is available + if (state.virtualScroller) { + return loadMoreWithVirtualScroll({ + modelType: 'checkpoint', + resetPage, + updateFolders, + fetchPageFunction: fetchCheckpointsPage + }); + } else { + // Fall back to the original implementation if virtual scroller isn't available + return loadMoreModels({ + resetPage, + updateFolders, + modelType: 'checkpoint', + createCardFunction: createCheckpointCard, + endpoint: '/api/checkpoints' + }); + } +} + // Reset and reload checkpoints -export async function resetAndReload() { - return baseResetAndReload({ - updateFolders: true, - modelType: 'checkpoint', - loadMoreFunction: loadMoreCheckpoints - }); +export async function resetAndReload(updateFolders = false) { + // Check if virtual scroller is available + if (state.virtualScroller) { + return resetAndReloadWithVirtualScroll({ + modelType: 'checkpoint', + updateFolders, + fetchPageFunction: fetchCheckpointsPage + }); + } else { + // Fall back to original implementation + return baseResetAndReload({ + updateFolders, + modelType: 'checkpoint', + loadMoreFunction: loadMoreCheckpoints + }); + } } // Refresh checkpoints @@ -60,7 +105,11 @@ export async function fetchCivitai() { // Refresh single checkpoint metadata export async function refreshSingleCheckpointMetadata(filePath) { - return refreshSingleModelMetadata(filePath, 'checkpoint'); + const success = await refreshSingleModelMetadata(filePath, 'checkpoint'); + if (success) { + // Reload the current view to show updated data + await resetAndReload(); + } } /** diff --git a/static/js/api/loraApi.js b/static/js/api/loraApi.js index 1df4d2f6..b4bcb767 100644 --- a/static/js/api/loraApi.js +++ b/static/js/api/loraApi.js @@ -3,6 +3,8 @@ import { loadMoreModels, fetchModelsPage, resetAndReload as baseResetAndReload, + resetAndReloadWithVirtualScroll, + loadMoreWithVirtualScroll, refreshModels as baseRefreshModels, deleteModel as baseDeleteModel, replaceModelPreview, @@ -58,45 +60,12 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) { // Check if virtual scroller is available if (state.virtualScroller) { - try { - // Start loading state - pageState.isLoading = true; - document.body.classList.add('loading'); - - // Reset to first page if requested - if (resetPage) { - pageState.currentPage = 1; - } - - // Fetch the first page of data - const result = await fetchLorasPage(pageState.currentPage, pageState.pageSize || 50); - - // Update virtual scroller with the new data - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page to load would be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - // Import function dynamically to avoid circular dependencies - const { updateFolderTags } = await import('./baseModelApi.js'); - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error('Error loading loras:', error); - showToast(`Failed to load loras: ${error.message}`, 'error'); - } finally { - pageState.isLoading = false; - document.body.classList.remove('loading'); - } + return loadMoreWithVirtualScroll({ + modelType: 'lora', + resetPage, + updateFolders, + fetchPageFunction: fetchLorasPage + }); } else { // Fall back to the original implementation if virtual scroller isn't available return loadMoreModels({ @@ -115,7 +84,7 @@ export async function loadMoreLoras(resetPage = false, updateFolders = false) { * @param {number} pageSize - Number of items per page * @returns {Promise} Object containing items, total count, and pagination info */ -export async function fetchLorasPage(page = 1, pageSize = 50) { +export async function fetchLorasPage(page = 1, pageSize = 100) { return fetchModelsPage({ modelType: 'lora', page, @@ -160,42 +129,11 @@ export async function resetAndReload(updateFolders = false) { // Check if virtual scroller is available if (state.virtualScroller) { - try { - pageState.isLoading = true; - document.body.classList.add('loading'); - - // Reset page counter - pageState.currentPage = 1; - - // Fetch the first page - const result = await fetchLorasPage(1, pageState.pageSize || 50); - - // Update the virtual scroller - state.virtualScroller.refreshWithData( - result.items, - result.totalItems, - result.hasMore - ); - - // Update state - pageState.hasMore = result.hasMore; - pageState.currentPage = 2; // Next page will be 2 - - // Update folders if needed - if (updateFolders && result.folders) { - // Import function dynamically to avoid circular dependencies - const { updateFolderTags } = await import('./baseModelApi.js'); - updateFolderTags(result.folders); - } - - return result; - } catch (error) { - console.error('Error reloading loras:', error); - showToast(`Failed to reload loras: ${error.message}`, 'error'); - } finally { - pageState.isLoading = false; - document.body.classList.remove('loading'); - } + return resetAndReloadWithVirtualScroll({ + modelType: 'lora', + updateFolders, + fetchPageFunction: fetchLorasPage + }); } else { // Fall back to original implementation return baseResetAndReload({ diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index a3a41000..b7c4b4e5 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -1,8 +1,9 @@ import { state, getCurrentPageState } from '../state/index.js'; -import { debounce } from './debounce.js'; import { VirtualScroller } from './VirtualScroller.js'; import { createLoraCard, setupLoraCardEventDelegation } from '../components/LoraCard.js'; +import { createCheckpointCard } from '../components/CheckpointCard.js'; import { fetchLorasPage } from '../api/loraApi.js'; +import { fetchCheckpointsPage } from '../api/checkpointApi.js'; import { showToast } from './uiHelpers.js'; // Function to dynamically import the appropriate card creator based on page type @@ -18,13 +19,7 @@ async function getCardCreator(pageType) { return null; } } else if (pageType === 'checkpoints') { - try { - const { createCheckpointCard } = await import('../components/CheckpointCard.js'); - return createCheckpointCard; - } catch (err) { - console.error('Failed to load checkpoint card creator:', err); - return null; - } + return createCheckpointCard; } return null; } @@ -42,13 +37,7 @@ async function getDataFetcher(pageType) { return null; } } else if (pageType === 'checkpoints') { - try { - const { fetchCheckpointsPage } = await import('../api/checkpointApi.js'); - return fetchCheckpointsPage; - } catch (err) { - console.error('Failed to load checkpoint data fetcher:', err); - return null; - } + return fetchCheckpointsPage; } return null; } @@ -105,11 +94,11 @@ async function initializeVirtualScroll(pageType) { } // Change this line to get the actual scrolling container - const pageContainer = document.querySelector('.page-content'); - const pageContent = pageContainer.querySelector('.container'); + const scrollContainer = document.querySelector('.page-content'); + const gridContainer = scrollContainer.querySelector('.container'); - if (!pageContent) { - console.warn('Page content element not found for virtual scroll'); + if (!gridContainer) { + console.warn('Grid container element not found for virtual scroll'); return; } @@ -122,15 +111,15 @@ async function initializeVirtualScroll(pageType) { throw new Error(`Required components not available for ${pageType} page`); } - // Pass the correct scrolling container + // Initialize virtual scroller with renamed container elements state.virtualScroller = new VirtualScroller({ gridElement: grid, - containerElement: pageContent, - scrollContainer: pageContainer, // Add this new parameter + containerElement: gridContainer, + scrollContainer: scrollContainer, createItemFn: createCardFn, fetchItemsFn: fetchDataFn, pageSize: 100, - rowGap: 20 // Add consistent vertical spacing between rows + rowGap: 20 }); // Initialize the virtual scroller From b741ed0b3b8029af79e14adbe1d7bdbbe12e5c72 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 20:07:47 +0800 Subject: [PATCH 6/8] Refactor recipe and checkpoint management to implement virtual scrolling and improve state handling --- static/js/api/recipeApi.js | 174 +++++++++++++++++++++++ static/js/checkpoints.js | 4 - static/js/recipes.js | 229 ++++-------------------------- static/js/utils/infiniteScroll.js | 29 ++-- 4 files changed, 216 insertions(+), 220 deletions(-) create mode 100644 static/js/api/recipeApi.js diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js new file mode 100644 index 00000000..a21314a9 --- /dev/null +++ b/static/js/api/recipeApi.js @@ -0,0 +1,174 @@ +import { RecipeCard } from '../components/RecipeCard.js'; +import { + fetchModelsPage, + resetAndReloadWithVirtualScroll, + loadMoreWithVirtualScroll +} from './baseModelApi.js'; +import { state, getCurrentPageState } from '../state/index.js'; +import { showToast } from '../utils/uiHelpers.js'; + +/** + * Fetch recipes with pagination for virtual scrolling + * @param {number} page - Page number to fetch + * @param {number} pageSize - Number of items per page + * @returns {Promise} Object containing items, total count, and pagination info + */ +export async function fetchRecipesPage(page = 1, pageSize = 100) { + const pageState = getCurrentPageState(); + + try { + const params = new URLSearchParams({ + page: page, + page_size: pageSize || pageState.pageSize || 20, + sort_by: pageState.sortBy + }); + + // If we have a specific recipe ID to load + if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { + // Special case: load specific recipe + const response = await fetch(`/api/recipe/${pageState.customFilter.recipeId}`); + + if (!response.ok) { + throw new Error(`Failed to load recipe: ${response.statusText}`); + } + + const recipe = await response.json(); + + // Return in expected format + return { + items: [recipe], + totalItems: 1, + totalPages: 1, + currentPage: 1, + hasMore: false + }; + } + + // Add custom filter for Lora if present + if (pageState.customFilter?.active && pageState.customFilter?.loraHash) { + params.append('lora_hash', pageState.customFilter.loraHash); + params.append('bypass_filters', 'true'); + } else { + // Normal filtering logic + + // Add search filter if present + if (pageState.filters?.search) { + params.append('search', pageState.filters.search); + + // Add search option parameters + if (pageState.searchOptions) { + params.append('search_title', pageState.searchOptions.title.toString()); + params.append('search_tags', pageState.searchOptions.tags.toString()); + params.append('search_lora_name', pageState.searchOptions.loraName.toString()); + params.append('search_lora_model', pageState.searchOptions.loraModel.toString()); + params.append('fuzzy', 'true'); + } + } + + // Add base model filters + if (pageState.filters?.baseModel && pageState.filters.baseModel.length) { + params.append('base_models', pageState.filters.baseModel.join(',')); + } + + // Add tag filters + if (pageState.filters?.tags && pageState.filters.tags.length) { + params.append('tags', pageState.filters.tags.join(',')); + } + } + + // Fetch recipes + const response = await fetch(`/api/recipes?${params.toString()}`); + + if (!response.ok) { + throw new Error(`Failed to load recipes: ${response.statusText}`); + } + + const data = await response.json(); + + return { + items: data.items, + totalItems: data.total, + totalPages: data.total_pages, + currentPage: page, + hasMore: page < data.total_pages + }; + } catch (error) { + console.error('Error fetching recipes:', error); + showToast(`Failed to fetch recipes: ${error.message}`, 'error'); + throw error; + } +} + +/** + * Reset and reload recipes using virtual scrolling + * @param {boolean} updateFolders - Whether to update folder tags + * @returns {Promise} The fetch result + */ +export async function resetAndReload(updateFolders = false) { + return resetAndReloadWithVirtualScroll({ + modelType: 'recipe', + updateFolders, + fetchPageFunction: fetchRecipesPage + }); +} + +/** + * Refreshes the recipe list by first rebuilding the cache and then loading recipes + */ +export async function refreshRecipes() { + try { + state.loadingManager.showSimpleLoading('Refreshing recipes...'); + + // Call the API endpoint to rebuild the recipe cache + const response = await fetch('/api/recipes/scan'); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to refresh recipe cache'); + } + + // After successful cache rebuild, reload the recipes + await resetAndReload(); + + showToast('Refresh complete', 'success'); + } catch (error) { + console.error('Error refreshing recipes:', error); + showToast(error.message || 'Failed to refresh recipes', 'error'); + } finally { + state.loadingManager.hide(); + state.loadingManager.restoreProgressBar(); + } +} + +/** + * Load more recipes with pagination - updated to work with VirtualScroller + * @param {boolean} resetPage - Whether to reset to the first page + * @returns {Promise} + */ +export async function loadMoreRecipes(resetPage = false) { + const pageState = getCurrentPageState(); + + // Use virtual scroller if available + if (state.virtualScroller) { + return loadMoreWithVirtualScroll({ + modelType: 'recipe', + resetPage, + updateFolders: false, + fetchPageFunction: fetchRecipesPage + }); + } +} + +/** + * Create a recipe card instance from recipe data + * @param {Object} recipe - Recipe data + * @returns {HTMLElement} Recipe card DOM element + */ +export function createRecipeCard(recipe) { + const recipeCard = new RecipeCard(recipe, (recipe) => { + if (window.recipeManager) { + window.recipeManager.showRecipeDetails(recipe); + } + }); + return recipeCard.element; +} diff --git a/static/js/checkpoints.js b/static/js/checkpoints.js index 777277f2..7d0ebd6b 100644 --- a/static/js/checkpoints.js +++ b/static/js/checkpoints.js @@ -1,5 +1,4 @@ import { appCore } from './core.js'; -import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { createPageControls } from './components/controls/index.js'; import { loadMoreCheckpoints } from './api/checkpointApi.js'; @@ -40,9 +39,6 @@ class CheckpointsPageManager { // Initialize context menu new CheckpointContextMenu(); - // Initialize infinite scroll - initializeInfiniteScroll('checkpoints'); - // Initialize common page features appCore.initializePageFeatures(); diff --git a/static/js/recipes.js b/static/js/recipes.js index 8dc6dc80..6cf0734b 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -1,13 +1,13 @@ // Recipe manager module import { appCore } from './core.js'; import { ImportManager } from './managers/ImportManager.js'; -import { RecipeCard } from './components/RecipeCard.js'; import { RecipeModal } from './components/RecipeModal.js'; -import { getCurrentPageState } from './state/index.js'; +import { getCurrentPageState, state } from './state/index.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { DuplicatesManager } from './components/DuplicatesManager.js'; -import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; +import { initializeInfiniteScroll, refreshVirtualScroll } from './utils/infiniteScroll.js'; +import { resetAndReload, refreshRecipes } from './api/recipeApi.js'; class RecipeManager { constructor() { @@ -27,8 +27,8 @@ class RecipeManager { this.pageState.isLoading = false; this.pageState.hasMore = true; - // Custom filter state - this.customFilter = { + // Custom filter state - move to pageState for compatibility with virtual scrolling + this.pageState.customFilter = { active: false, loraName: null, loraHash: null, @@ -49,13 +49,10 @@ class RecipeManager { // Check for custom filter parameters in session storage this._checkCustomFilter(); - // Load initial set of recipes - await this.loadRecipes(); - // Expose necessary functions to the page this._exposeGlobalFunctions(); - // Initialize common page features (lazy loading, infinite scroll) + // Initialize common page features appCore.initializePageFeatures(); } @@ -87,7 +84,7 @@ class RecipeManager { // Set custom filter if any parameter is present if (filterLoraName || filterLoraHash || viewRecipeId) { - this.customFilter = { + this.pageState.customFilter = { active: true, loraName: filterLoraName, loraHash: filterLoraHash, @@ -108,11 +105,11 @@ class RecipeManager { // Update text based on filter type let filterText = ''; - if (this.customFilter.recipeId) { + if (this.pageState.customFilter.recipeId) { filterText = 'Viewing specific recipe'; - } else if (this.customFilter.loraName) { + } else if (this.pageState.customFilter.loraName) { // Format with Lora name - const loraName = this.customFilter.loraName; + const loraName = this.pageState.customFilter.loraName; const displayName = loraName.length > 25 ? loraName.substring(0, 22) + '...' : loraName; @@ -125,8 +122,8 @@ class RecipeManager { // Update indicator text and show it textElement.innerHTML = filterText; // Add title attribute to show the lora name as a tooltip - if (this.customFilter.loraName) { - textElement.setAttribute('title', this.customFilter.loraName); + if (this.pageState.customFilter.loraName) { + textElement.setAttribute('title', this.pageState.customFilter.loraName); } indicator.classList.remove('hidden'); @@ -149,7 +146,7 @@ class RecipeManager { _clearCustomFilter() { // Reset custom filter - this.customFilter = { + this.pageState.customFilter = { active: false, loraName: null, loraHash: null, @@ -167,8 +164,8 @@ class RecipeManager { removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('viewRecipeId'); - // Reload recipes without custom filter - this.loadRecipes(); + // Reset and refresh the virtual scroller + refreshVirtualScroll(); } initEventListeners() { @@ -177,105 +174,21 @@ class RecipeManager { if (sortSelect) { sortSelect.addEventListener('change', () => { this.pageState.sortBy = sortSelect.value; - this.loadRecipes(); + refreshVirtualScroll(); }); } } + // This method is kept for compatibility but now uses virtual scrolling async loadRecipes(resetPage = true) { - try { - // Skip loading if in duplicates mode - const pageState = getCurrentPageState(); - if (pageState.duplicatesMode) { - return; - } - - // Show loading indicator - document.body.classList.add('loading'); - this.pageState.isLoading = true; - - // Reset to first page if requested - if (resetPage) { - this.pageState.currentPage = 1; - // Clear grid if resetting - const grid = document.getElementById('recipeGrid'); - if (grid) grid.innerHTML = ''; - } - - // If we have a specific recipe ID to load - if (this.customFilter.active && this.customFilter.recipeId) { - await this._loadSpecificRecipe(this.customFilter.recipeId); - return; - } - - // Build query parameters - const params = new URLSearchParams({ - page: this.pageState.currentPage, - page_size: this.pageState.pageSize || 20, - sort_by: this.pageState.sortBy - }); - - // Add custom filter for Lora if present - if (this.customFilter.active && this.customFilter.loraHash) { - params.append('lora_hash', this.customFilter.loraHash); - - // Skip other filters when using custom filter - params.append('bypass_filters', 'true'); - } else { - // Normal filtering logic - - // Add search filter if present - if (this.pageState.filters.search) { - params.append('search', this.pageState.filters.search); - - // Add search option parameters - if (this.pageState.searchOptions) { - params.append('search_title', this.pageState.searchOptions.title.toString()); - params.append('search_tags', this.pageState.searchOptions.tags.toString()); - params.append('search_lora_name', this.pageState.searchOptions.loraName.toString()); - params.append('search_lora_model', this.pageState.searchOptions.loraModel.toString()); - params.append('fuzzy', 'true'); - } - } - - // Add base model filters - if (this.pageState.filters.baseModel && this.pageState.filters.baseModel.length) { - params.append('base_models', this.pageState.filters.baseModel.join(',')); - } - - // Add tag filters - if (this.pageState.filters.tags && this.pageState.filters.tags.length) { - params.append('tags', this.pageState.filters.tags.join(',')); - } - } - - // Fetch recipes - const response = await fetch(`/api/recipes?${params.toString()}`); - - if (!response.ok) { - throw new Error(`Failed to load recipes: ${response.statusText}`); - } - - const data = await response.json(); - - // Update recipes grid - this.updateRecipesGrid(data, resetPage); - - // Update pagination state based on current page and total pages - this.pageState.hasMore = data.page < data.total_pages; - - // Increment the page number AFTER successful loading - if (data.items.length > 0) { - this.pageState.currentPage++; - } - - } catch (error) { - console.error('Error loading recipes:', error); - appCore.showToast('Failed to load recipes', 'error'); - } finally { - // Hide loading indicator - document.body.classList.remove('loading'); - this.pageState.isLoading = false; + // Skip loading if in duplicates mode + const pageState = getCurrentPageState(); + if (pageState.duplicatesMode) { + return; + } + + if (resetPage) { + refreshVirtualScroll(); } } @@ -283,95 +196,7 @@ class RecipeManager { * Refreshes the recipe list by first rebuilding the cache and then loading recipes */ async refreshRecipes() { - try { - // Call the new endpoint to rebuild the recipe cache - const response = await fetch('/api/recipes/scan'); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to refresh recipe cache'); - } - - // After successful cache rebuild, load the recipes - await this.loadRecipes(true); - - appCore.showToast('Refresh complete', 'success'); - } catch (error) { - console.error('Error refreshing recipes:', error); - appCore.showToast(error.message || 'Failed to refresh recipes', 'error'); - - // Still try to load recipes even if scan failed - await this.loadRecipes(true); - } - } - - async _loadSpecificRecipe(recipeId) { - try { - // Fetch specific recipe by ID - const response = await fetch(`/api/recipe/${recipeId}`); - - if (!response.ok) { - throw new Error(`Failed to load recipe: ${response.statusText}`); - } - - const recipe = await response.json(); - - // Create a data structure that matches the expected format - const recipeData = { - items: [recipe], - total: 1, - page: 1, - page_size: 1, - total_pages: 1 - }; - - // Update grid with single recipe - this.updateRecipesGrid(recipeData, true); - - // Pagination not needed for single recipe - this.pageState.hasMore = false; - - // Show recipe details modal - setTimeout(() => { - this.showRecipeDetails(recipe); - }, 300); - - } catch (error) { - console.error('Error loading specific recipe:', error); - appCore.showToast('Failed to load recipe details', 'error'); - - // Clear the filter and show all recipes - this._clearCustomFilter(); - } - } - - updateRecipesGrid(data, resetGrid = true) { - const grid = document.getElementById('recipeGrid'); - if (!grid) return; - - // Check if data exists and has items - if (!data.items || data.items.length === 0) { - if (resetGrid) { - grid.innerHTML = ` -
-

No recipes found

-

Add recipe images to your recipes folder to see them here.

-
- `; - } - return; - } - - // Clear grid if resetting - if (resetGrid) { - grid.innerHTML = ''; - } - - // Create recipe cards - data.items.forEach(recipe => { - const recipeCard = new RecipeCard(recipe, (recipe) => this.showRecipeDetails(recipe)); - grid.appendChild(recipeCard.element); - }); + return refreshRecipes(); } showRecipeDetails(recipe) { @@ -397,7 +222,7 @@ class RecipeManager { exitDuplicateMode() { this.duplicatesManager.exitDuplicateMode(); - initializeInfiniteScroll(); + initializeInfiniteScroll('recipes'); } } diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index b7c4b4e5..6cf9b879 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -11,13 +11,18 @@ async function getCardCreator(pageType) { if (pageType === 'loras') { return createLoraCard; } else if (pageType === 'recipes') { - try { - const { createRecipeCard } = await import('../components/RecipeCard.js'); - return createRecipeCard; - } catch (err) { - console.error('Failed to load recipe card creator:', err); - return null; - } + // Import the RecipeCard module + const { RecipeCard } = await import('../components/RecipeCard.js'); + + // Return a wrapper function that creates a recipe card element + return (recipe) => { + const recipeCard = new RecipeCard(recipe, (recipe) => { + if (window.recipeManager) { + window.recipeManager.showRecipeDetails(recipe); + } + }); + return recipeCard.element; + }; } else if (pageType === 'checkpoints') { return createCheckpointCard; } @@ -29,13 +34,9 @@ async function getDataFetcher(pageType) { if (pageType === 'loras') { return fetchLorasPage; } else if (pageType === 'recipes') { - try { - const { fetchRecipesPage } = await import('../api/recipeApi.js'); - return fetchRecipesPage; - } catch (err) { - console.error('Failed to load recipe data fetcher:', err); - return null; - } + // Import the recipeApi module and use the fetchRecipesPage function + const { fetchRecipesPage } = await import('../api/recipeApi.js'); + return fetchRecipesPage; } else if (pageType === 'checkpoints') { return fetchCheckpointsPage; } From af8f5ba04eddae8cc364e402ead7d61fa3762585 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 21:20:28 +0800 Subject: [PATCH 7/8] Implement client-side placeholder handling for empty recipe grid and remove server-side conditional rendering --- static/js/utils/VirtualScroller.js | 72 ++++++++++++++++++++++++++++++ templates/recipes.html | 39 +--------------- 2 files changed, 74 insertions(+), 37 deletions(-) diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 8be0b288..83f954dd 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -194,6 +194,13 @@ export class VirtualScroller { // Update the spacer height based on the total number of items this.updateSpacerHeight(); + // Check if there are no items and show placeholder if needed + if (this.items.length === 0) { + this.showNoItemsPlaceholder(); + } else { + this.removeNoItemsPlaceholder(); + } + // Reset page state to sync with our virtual scroller pageState.currentPage = 2; // Next page to load would be 2 pageState.hasMore = this.hasMore; @@ -202,6 +209,7 @@ export class VirtualScroller { return { items, totalItems, hasMore }; } catch (err) { console.error('Failed to load initial batch:', err); + this.showNoItemsPlaceholder('Failed to load items. Please try refreshing the page.'); throw err; } finally { this.isLoading = false; @@ -387,6 +395,13 @@ export class VirtualScroller { this.hasMore = hasMore; this.updateSpacerHeight(); + // Check if there are no items and show placeholder if needed + if (this.items.length === 0) { + this.showNoItemsPlaceholder(); + } else { + this.removeNoItemsPlaceholder(); + } + // Clear all rendered items and redraw this.clearRenderedItems(); this.scheduleRender(); @@ -564,6 +579,9 @@ export class VirtualScroller { // Reset spacer height this.spacerElement.style.height = '0px'; + // Remove any placeholder + this.removeNoItemsPlaceholder(); + // Schedule a re-render this.scheduleRender(); } @@ -591,6 +609,60 @@ export class VirtualScroller { this.clearLoadingTimeout(); } + // Add methods to handle placeholder display + showNoItemsPlaceholder(message) { + // Remove any existing placeholder first + this.removeNoItemsPlaceholder(); + + // Create placeholder message + const placeholder = document.createElement('div'); + placeholder.className = 'placeholder-message'; + + // Determine appropriate message based on page type + let placeholderText = ''; + + if (message) { + placeholderText = message; + } else { + const pageType = state.currentPageType; + + if (pageType === 'recipes') { + placeholderText = ` +

No recipes found

+

Add recipe images to your recipes folder to see them here.

+ `; + } else if (pageType === 'loras') { + placeholderText = ` +

No LoRAs found

+

Add LoRAs to your models folder to see them here.

+ `; + } else if (pageType === 'checkpoints') { + placeholderText = ` +

No checkpoints found

+

Add checkpoints to your models folder to see them here.

+ `; + } else { + placeholderText = ` +

No items found

+

Try adjusting your search filters or add more content.

+ `; + } + } + + placeholder.innerHTML = placeholderText; + placeholder.id = 'virtualScrollPlaceholder'; + + // Append placeholder to the grid + this.gridElement.appendChild(placeholder); + } + + removeNoItemsPlaceholder() { + const placeholder = document.getElementById('virtualScrollPlaceholder'); + if (placeholder) { + placeholder.remove(); + } + } + // Utility method for debouncing debounce(func, wait) { let timeout; diff --git a/templates/recipes.html b/templates/recipes.html index c8f9269f..b6d921c5 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -77,43 +77,8 @@
- {% if recipes and recipes|length > 0 %} - {% for recipe in recipes %} -
-
R
-
- {{ recipe.title }} -
-
- {% if recipe.base_model %} - - {{ recipe.base_model }} - - {% endif %} -
-
- - - -
-
- -
-
- {% endfor %} - {% else %} -
-

No recipes found

-

Add recipe images to your recipes folder to see them here.

-
- {% endif %} + +
{% endblock %} From c966dbbbbceb59f88c93bcabc29f131884e03ca9 Mon Sep 17 00:00:00 2001 From: Will Miao <13051207myq@gmail.com> Date: Mon, 12 May 2025 21:31:03 +0800 Subject: [PATCH 8/8] Enhance DuplicatesManager and VirtualScroller to manage virtual scrolling state and improve rendering logic --- static/js/components/DuplicatesManager.js | 29 ++++++++------ static/js/recipes.js | 12 +++++- static/js/utils/VirtualScroller.js | 46 ++++++++++++++++++++++- static/js/utils/infiniteScroll.js | 1 + 4 files changed, 74 insertions(+), 14 deletions(-) diff --git a/static/js/components/DuplicatesManager.js b/static/js/components/DuplicatesManager.js index 66fa1f33..7387b026 100644 --- a/static/js/components/DuplicatesManager.js +++ b/static/js/components/DuplicatesManager.js @@ -1,7 +1,7 @@ // Duplicates Manager Component import { showToast } from '../utils/uiHelpers.js'; import { RecipeCard } from './RecipeCard.js'; -import { getCurrentPageState } from '../state/index.js'; +import { state, getCurrentPageState } from '../state/index.js'; import { initializeInfiniteScroll } from '../utils/infiniteScroll.js'; export class DuplicatesManager { @@ -61,10 +61,9 @@ export class DuplicatesManager { banner.style.display = 'block'; } - // Disable infinite scroll - if (this.recipeManager.observer) { - this.recipeManager.observer.disconnect(); - this.recipeManager.observer = null; + // Disable virtual scrolling if active + if (state.virtualScroller) { + state.virtualScroller.disable(); } // Add duplicate-mode class to the body @@ -94,13 +93,21 @@ export class DuplicatesManager { // Remove duplicate-mode class from the body document.body.classList.remove('duplicate-mode'); - // Reload normal recipes view - this.recipeManager.loadRecipes(); + // Clear the recipe grid first + const recipeGrid = document.getElementById('recipeGrid'); + if (recipeGrid) { + recipeGrid.innerHTML = ''; + } - // Reinitialize infinite scroll - setTimeout(() => { - initializeInfiniteScroll('recipes'); - }, 500); + // Re-enable virtual scrolling + if (state.virtualScroller) { + state.virtualScroller.enable(); + } else { + // If virtual scroller doesn't exist, reinitialize it + setTimeout(() => { + initializeInfiniteScroll('recipes'); + }, 100); + } } renderDuplicateGroups() { diff --git a/static/js/recipes.js b/static/js/recipes.js index 6cf0734b..454ba276 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -221,8 +221,18 @@ class RecipeManager { } exitDuplicateMode() { + // Clear the grid first to prevent showing old content temporarily + const recipeGrid = document.getElementById('recipeGrid'); + if (recipeGrid) { + recipeGrid.innerHTML = ''; + } + this.duplicatesManager.exitDuplicateMode(); - initializeInfiniteScroll('recipes'); + + // Use a small delay before initializing to ensure DOM is ready + setTimeout(() => { + initializeInfiniteScroll('recipes'); + }, 100); } } diff --git a/static/js/utils/VirtualScroller.js b/static/js/utils/VirtualScroller.js index 83f954dd..bc2db6ef 100644 --- a/static/js/utils/VirtualScroller.js +++ b/static/js/utils/VirtualScroller.js @@ -317,8 +317,9 @@ export class VirtualScroller { return { start: firstIndex, end: lastIndex }; } + // Update the scheduleRender method to check for disabled state scheduleRender() { - if (this.renderScheduled) return; + if (this.disabled || this.renderScheduled) return; this.renderScheduled = true; requestAnimationFrame(() => { @@ -327,8 +328,9 @@ export class VirtualScroller { }); } + // Update the renderItems method to check for disabled state renderItems() { - if (this.items.length === 0 || this.columnsCount === 0) return; + if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return; const { start, end } = this.getVisibleRange(); @@ -672,4 +674,44 @@ export class VirtualScroller { timeout = setTimeout(() => func.apply(context, args), wait); }; } + + // Add disable method to stop rendering and events + disable() { + // Detach scroll event listener + this.scrollContainer.removeEventListener('scroll', this.scrollHandler); + + // Clear all rendered items from the DOM + this.clearRenderedItems(); + + // Hide the spacer element + if (this.spacerElement) { + this.spacerElement.style.display = 'none'; + } + + // Flag as disabled + this.disabled = true; + + console.log('Virtual scroller disabled'); + } + + // Add enable method to resume rendering and events + enable() { + if (!this.disabled) return; + + // Reattach scroll event listener + this.scrollContainer.addEventListener('scroll', this.scrollHandler); + + // Show the spacer element + if (this.spacerElement) { + this.spacerElement.style.display = 'block'; + } + + // Flag as enabled + this.disabled = false; + + // Re-render items + this.scheduleRender(); + + console.log('Virtual scroller enabled'); + } } diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 6cf9b879..9c2241aa 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -58,6 +58,7 @@ export async function initializeInfiniteScroll(pageType = 'loras') { // Skip initializing if in duplicates mode (for recipes page) if (pageType === 'recipes' && pageState.duplicatesMode) { + console.log('Skipping virtual scroll initialization - duplicates mode is active'); return; }