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] 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; }