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 { 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();

View File

@@ -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 = `
<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);
});
return refreshRecipes();
}
showRecipeDetails(recipe) {
@@ -397,7 +222,7 @@ class RecipeManager {
exitDuplicateMode() {
this.duplicatesManager.exitDuplicateMode();
initializeInfiniteScroll();
initializeInfiniteScroll('recipes');
}
}

View File

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