Refactor recipe and checkpoint management to implement virtual scrolling and improve state handling

This commit is contained in:
Will Miao
2025-05-12 20:07:47 +08:00
parent 01ba3c14f8
commit b741ed0b3b
4 changed files with 216 additions and 220 deletions

174
static/js/api/recipeApi.js Normal file
View File

@@ -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>} 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<Object>} 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<void>}
*/
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;
}

View File

@@ -1,5 +1,4 @@
import { appCore } from './core.js'; import { appCore } from './core.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js'; import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { createPageControls } from './components/controls/index.js'; import { createPageControls } from './components/controls/index.js';
import { loadMoreCheckpoints } from './api/checkpointApi.js'; import { loadMoreCheckpoints } from './api/checkpointApi.js';
@@ -40,9 +39,6 @@ class CheckpointsPageManager {
// Initialize context menu // Initialize context menu
new CheckpointContextMenu(); new CheckpointContextMenu();
// Initialize infinite scroll
initializeInfiniteScroll('checkpoints');
// Initialize common page features // Initialize common page features
appCore.initializePageFeatures(); appCore.initializePageFeatures();

View File

@@ -1,13 +1,13 @@
// Recipe manager module // Recipe manager module
import { appCore } from './core.js'; import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js'; import { ImportManager } from './managers/ImportManager.js';
import { RecipeCard } from './components/RecipeCard.js';
import { RecipeModal } from './components/RecipeModal.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 { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.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 { class RecipeManager {
constructor() { constructor() {
@@ -27,8 +27,8 @@ class RecipeManager {
this.pageState.isLoading = false; this.pageState.isLoading = false;
this.pageState.hasMore = true; this.pageState.hasMore = true;
// Custom filter state // Custom filter state - move to pageState for compatibility with virtual scrolling
this.customFilter = { this.pageState.customFilter = {
active: false, active: false,
loraName: null, loraName: null,
loraHash: null, loraHash: null,
@@ -49,13 +49,10 @@ class RecipeManager {
// Check for custom filter parameters in session storage // Check for custom filter parameters in session storage
this._checkCustomFilter(); this._checkCustomFilter();
// Load initial set of recipes
await this.loadRecipes();
// Expose necessary functions to the page // Expose necessary functions to the page
this._exposeGlobalFunctions(); this._exposeGlobalFunctions();
// Initialize common page features (lazy loading, infinite scroll) // Initialize common page features
appCore.initializePageFeatures(); appCore.initializePageFeatures();
} }
@@ -87,7 +84,7 @@ class RecipeManager {
// Set custom filter if any parameter is present // Set custom filter if any parameter is present
if (filterLoraName || filterLoraHash || viewRecipeId) { if (filterLoraName || filterLoraHash || viewRecipeId) {
this.customFilter = { this.pageState.customFilter = {
active: true, active: true,
loraName: filterLoraName, loraName: filterLoraName,
loraHash: filterLoraHash, loraHash: filterLoraHash,
@@ -108,11 +105,11 @@ class RecipeManager {
// Update text based on filter type // Update text based on filter type
let filterText = ''; let filterText = '';
if (this.customFilter.recipeId) { if (this.pageState.customFilter.recipeId) {
filterText = 'Viewing specific recipe'; filterText = 'Viewing specific recipe';
} else if (this.customFilter.loraName) { } else if (this.pageState.customFilter.loraName) {
// Format with Lora name // Format with Lora name
const loraName = this.customFilter.loraName; const loraName = this.pageState.customFilter.loraName;
const displayName = loraName.length > 25 ? const displayName = loraName.length > 25 ?
loraName.substring(0, 22) + '...' : loraName.substring(0, 22) + '...' :
loraName; loraName;
@@ -125,8 +122,8 @@ class RecipeManager {
// Update indicator text and show it // Update indicator text and show it
textElement.innerHTML = filterText; textElement.innerHTML = filterText;
// Add title attribute to show the lora name as a tooltip // Add title attribute to show the lora name as a tooltip
if (this.customFilter.loraName) { if (this.pageState.customFilter.loraName) {
textElement.setAttribute('title', this.customFilter.loraName); textElement.setAttribute('title', this.pageState.customFilter.loraName);
} }
indicator.classList.remove('hidden'); indicator.classList.remove('hidden');
@@ -149,7 +146,7 @@ class RecipeManager {
_clearCustomFilter() { _clearCustomFilter() {
// Reset custom filter // Reset custom filter
this.customFilter = { this.pageState.customFilter = {
active: false, active: false,
loraName: null, loraName: null,
loraHash: null, loraHash: null,
@@ -167,8 +164,8 @@ class RecipeManager {
removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('viewRecipeId'); removeSessionItem('viewRecipeId');
// Reload recipes without custom filter // Reset and refresh the virtual scroller
this.loadRecipes(); refreshVirtualScroll();
} }
initEventListeners() { initEventListeners() {
@@ -177,105 +174,21 @@ class RecipeManager {
if (sortSelect) { if (sortSelect) {
sortSelect.addEventListener('change', () => { sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value; this.pageState.sortBy = sortSelect.value;
this.loadRecipes(); refreshVirtualScroll();
}); });
} }
} }
// This method is kept for compatibility but now uses virtual scrolling
async loadRecipes(resetPage = true) { async loadRecipes(resetPage = true) {
try { // Skip loading if in duplicates mode
// Skip loading if in duplicates mode const pageState = getCurrentPageState();
const pageState = getCurrentPageState(); if (pageState.duplicatesMode) {
if (pageState.duplicatesMode) { return;
return; }
}
// Show loading indicator if (resetPage) {
document.body.classList.add('loading'); refreshVirtualScroll();
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;
} }
} }
@@ -283,95 +196,7 @@ class RecipeManager {
* Refreshes the recipe list by first rebuilding the cache and then loading recipes * Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/ */
async refreshRecipes() { async refreshRecipes() {
try { return refreshRecipes();
// 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 = `
<div class="placeholder-message">
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
</div>
`;
}
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);
});
} }
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
@@ -397,7 +222,7 @@ class RecipeManager {
exitDuplicateMode() { exitDuplicateMode() {
this.duplicatesManager.exitDuplicateMode(); this.duplicatesManager.exitDuplicateMode();
initializeInfiniteScroll(); initializeInfiniteScroll('recipes');
} }
} }

View File

@@ -11,13 +11,18 @@ async function getCardCreator(pageType) {
if (pageType === 'loras') { if (pageType === 'loras') {
return createLoraCard; return createLoraCard;
} else if (pageType === 'recipes') { } else if (pageType === 'recipes') {
try { // Import the RecipeCard module
const { createRecipeCard } = await import('../components/RecipeCard.js'); const { RecipeCard } = await import('../components/RecipeCard.js');
return createRecipeCard;
} catch (err) { // Return a wrapper function that creates a recipe card element
console.error('Failed to load recipe card creator:', err); return (recipe) => {
return null; const recipeCard = new RecipeCard(recipe, (recipe) => {
} if (window.recipeManager) {
window.recipeManager.showRecipeDetails(recipe);
}
});
return recipeCard.element;
};
} else if (pageType === 'checkpoints') { } else if (pageType === 'checkpoints') {
return createCheckpointCard; return createCheckpointCard;
} }
@@ -29,13 +34,9 @@ async function getDataFetcher(pageType) {
if (pageType === 'loras') { if (pageType === 'loras') {
return fetchLorasPage; return fetchLorasPage;
} else if (pageType === 'recipes') { } else if (pageType === 'recipes') {
try { // Import the recipeApi module and use the fetchRecipesPage function
const { fetchRecipesPage } = await import('../api/recipeApi.js'); const { fetchRecipesPage } = await import('../api/recipeApi.js');
return fetchRecipesPage; return fetchRecipesPage;
} catch (err) {
console.error('Failed to load recipe data fetcher:', err);
return null;
}
} else if (pageType === 'checkpoints') { } else if (pageType === 'checkpoints') {
return fetchCheckpointsPage; return fetchCheckpointsPage;
} }