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