Refactor API routes and enhance recipe and filter management

- Removed the handle_get_recipes method from ApiRoutes to streamline the API structure.
- Updated RecipeRoutes to include logging for recipe retrieval requests and improved filter management.
- Consolidated filter management logic in FilterManager to support both recipes and loras, enhancing code reusability.
- Deleted obsolete LoraSearchManager and RecipeSearchManager classes to simplify the search functionality.
- Improved infinite scroll implementation for both recipes and loras, ensuring consistent loading behavior across pages.
This commit is contained in:
Will Miao
2025-03-20 14:54:13 +08:00
parent c987338c84
commit addf92d966
12 changed files with 264 additions and 782 deletions

View File

@@ -50,7 +50,6 @@ class ApiRoutes:
app.router.add_get('/api/lora-preview-url', routes.get_lora_preview_url) # Add new route
app.router.add_post('/api/move_models_bulk', routes.move_models_bulk)
app.router.add_get('/api/top-tags', routes.get_top_tags) # Add new route for top tags
app.router.add_get('/api/recipes', cls.handle_get_recipes)
# Add update check routes
UpdateRoutes.setup_routes(app)
@@ -842,30 +841,4 @@ class ApiRoutes:
return web.json_response({
'success': False,
'error': 'Internal server error'
}, status=500)
@staticmethod
async def handle_get_recipes(request):
"""API endpoint for getting paginated recipes"""
try:
# Get query parameters with defaults
page = int(request.query.get('page', '1'))
page_size = int(request.query.get('page_size', '20'))
sort_by = request.query.get('sort_by', 'date')
search = request.query.get('search', None)
# Get scanner instance
scanner = RecipeScanner(LoraScanner())
# Get paginated data
result = await scanner.get_paginated_data(
page=page,
page_size=page_size,
sort_by=sort_by,
search=search
)
return web.json_response(result)
except Exception as e:
logger.error(f"Error retrieving recipes: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
}, status=500)

View File

@@ -1,11 +1,9 @@
import os
import logging
import sys
from aiohttp import web
from typing import Dict
import tempfile
import json
import aiohttp
import asyncio
from ..utils.exif_utils import ExifUtils
from ..utils.recipe_parsers import RecipeParserFactory
@@ -70,6 +68,7 @@ class RecipeRoutes:
async def get_recipes(self, request: web.Request) -> web.Response:
"""API endpoint for getting paginated recipes"""
try:
logger.info(f"get_recipes, Request: {request}")
# Get query parameters with defaults
page = int(request.query.get('page', '1'))
page_size = int(request.query.get('page_size', '20'))
@@ -100,7 +99,8 @@ class RecipeRoutes:
'lora_name': search_lora_name,
'lora_model': search_lora_model
}
logger.info(f"get_recipes, Filters: {filters}, Search Options: {search_options}")
# Get paginated data
result = await self.recipe_scanner.get_paginated_data(
page=page,
@@ -136,7 +136,7 @@ class RecipeRoutes:
"""Get detailed information about a specific recipe"""
try:
recipe_id = request.match_info['recipe_id']
# Get all recipes from cache
cache = await self.recipe_scanner.get_cached_data()

View File

@@ -24,11 +24,9 @@ class UpdateRoutes:
try:
# Read local version from pyproject.toml
local_version = UpdateRoutes._get_local_version()
logger.info(f"Local version: {local_version}")
# Fetch remote version from GitHub
remote_version, changelog = await UpdateRoutes._get_remote_version()
logger.info(f"Remote version: {remote_version}")
# Compare versions
update_available = UpdateRoutes._compare_versions(
@@ -36,8 +34,6 @@ class UpdateRoutes:
remote_version.replace('v', '')
)
logger.info(f"Update available: {update_available}")
return web.json_response({
'success': True,
'current_version': local_version,

View File

@@ -5,13 +5,21 @@ import { initializeInfiniteScroll } from '../utils/infiniteScroll.js';
import { showDeleteModal } from '../utils/modalUtils.js';
import { toggleFolder } from '../utils/uiHelpers.js';
export async function loadMoreLoras(boolUpdateFolders = false) {
export async function loadMoreLoras(resetPage = false, updateFolders = false) {
const pageState = getCurrentPageState();
if (pageState.isLoading || !pageState.hasMore) return;
if (pageState.isLoading || (!pageState.hasMore && !resetPage)) return;
pageState.isLoading = true;
try {
// Reset to first page if requested
if (resetPage) {
pageState.currentPage = 1;
// Clear grid if resetting
const grid = document.getElementById('loraGrid');
if (grid) grid.innerHTML = '';
}
const params = new URLSearchParams({
page: pageState.currentPage,
page_size: 20,
@@ -19,7 +27,7 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
});
// Use pageState instead of state
const isRecursiveSearch = pageState.searchManager?.isRecursiveSearch ?? false;
const isRecursiveSearch = pageState.searchOptions?.recursive ?? false;
if (pageState.activeFolder !== null) {
params.append('folder', pageState.activeFolder);
@@ -27,10 +35,16 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
}
// Add search parameters if there's a search term
const searchInput = document.getElementById('searchInput');
if (searchInput && searchInput.value.trim()) {
params.append('search', searchInput.value.trim());
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());
params.append('search_tags', (pageState.searchOptions.tags || false).toString());
}
}
// Add filter parameters if active
@@ -72,7 +86,7 @@ export async function loadMoreLoras(boolUpdateFolders = false) {
pageState.hasMore = false;
}
if (boolUpdateFolders && data.folders) {
if (updateFolders && data.folders) {
updateFolderTags(data.folders);
}
@@ -271,24 +285,15 @@ export function appendLoraCards(loras) {
});
}
export async function resetAndReload(boolUpdateFolders = false) {
export async function resetAndReload(updateFolders = false) {
const pageState = getCurrentPageState();
console.log('Resetting with state:', { ...pageState });
pageState.currentPage = 1;
pageState.hasMore = true;
pageState.isLoading = false;
const grid = document.getElementById('loraGrid');
grid.innerHTML = '';
const sentinel = document.createElement('div');
sentinel.id = 'scroll-sentinel';
grid.appendChild(sentinel);
// Initialize infinite scroll - will reset the observer
initializeInfiniteScroll();
await loadMoreLoras(boolUpdateFolders);
// Load more loras with reset flag
await loadMoreLoras(true, updateFolders);
}
export async function refreshLoras() {

View File

@@ -1,5 +1,7 @@
import { updateService } from '../managers/UpdateService.js';
import { toggleTheme } from '../utils/uiHelpers.js';
import { SearchManager } from '../managers/SearchManager.js';
import { FilterManager } from '../managers/FilterManager.js';
/**
* Header.js - Manages the application header behavior across different pages
@@ -27,40 +29,14 @@ export class HeaderManager {
}
initializeManagers() {
// Import and initialize appropriate search manager based on page
if (this.currentPage === 'loras') {
import('../managers/LoraSearchManager.js').then(module => {
const { LoraSearchManager } = module;
this.searchManager = new LoraSearchManager();
window.searchManager = this.searchManager;
});
import('../managers/FilterManager.js').then(module => {
const { FilterManager } = module;
this.filterManager = new FilterManager();
window.filterManager = this.filterManager;
});
} else if (this.currentPage === 'recipes') {
import('../managers/RecipeSearchManager.js').then(module => {
const { RecipeSearchManager } = module;
this.searchManager = new RecipeSearchManager();
window.searchManager = this.searchManager;
});
import('../managers/RecipeFilterManager.js').then(module => {
const { RecipeFilterManager } = module;
this.filterManager = new RecipeFilterManager();
window.filterManager = this.filterManager;
});
} else if (this.currentPage === 'checkpoints') {
import('../managers/CheckpointSearchManager.js').then(module => {
const { CheckpointSearchManager } = module;
this.searchManager = new CheckpointSearchManager();
window.searchManager = this.searchManager;
});
// Note: Checkpoints page might get its own filter manager in the future
// For now, we can use a basic filter manager or none at all
// Initialize SearchManager for all page types
this.searchManager = new SearchManager({ page: this.currentPage });
window.searchManager = this.searchManager;
// Initialize FilterManager for all page types that have filters
if (document.getElementById('filterButton')) {
this.filterManager = new FilterManager({ page: this.currentPage });
window.filterManager = this.filterManager;
}
}

View File

@@ -1,11 +1,17 @@
import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { resetAndReload } from '../api/loraApi.js';
import { loadMoreLoras } from '../api/loraApi.js';
export class FilterManager {
constructor() {
constructor(options = {}) {
this.options = {
...options
};
this.currentPage = options.page || document.body.dataset.page || 'loras';
const pageState = getCurrentPageState();
this.filters = pageState.filters || {
baseModel: [],
tags: []
@@ -17,11 +23,18 @@ export class FilterManager {
this.tagsLoaded = false;
this.initialize();
// Store this instance in the state
if (pageState) {
pageState.filterManager = this;
}
}
initialize() {
// Create base model filter tags
this.createBaseModelTags();
// Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) {
this.createBaseModelTags();
}
// Add click handler for filter button
if (this.filterButton) {
@@ -32,7 +45,7 @@ export class FilterManager {
// Close filter panel when clicking outside
document.addEventListener('click', (e) => {
if (!this.filterPanel.contains(e.target) &&
if (this.filterPanel && !this.filterPanel.contains(e.target) &&
e.target !== this.filterButton &&
!this.filterButton.contains(e.target) &&
!this.filterPanel.classList.contains('hidden')) {
@@ -48,15 +61,20 @@ export class FilterManager {
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
if (!tagsContainer) return;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
// Determine the API endpoint based on the page type
let tagsEndpoint = '/api/top-tags?limit=20';
if (this.currentPage === 'recipes') {
tagsEndpoint = '/api/recipes/top-tags?limit=20';
}
const response = await fetch('/api/top-tags?limit=20');
const response = await fetch(tagsEndpoint);
if (!response.ok) throw new Error('Failed to fetch tags');
const data = await response.json();
console.log('Top tags:', data);
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
@@ -81,14 +99,13 @@ export class FilterManager {
tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = '<div class="no-tags">No tags available</div>';
tagsContainer.innerHTML = `<div class="no-tags">No ${this.currentPage === 'recipes' ? 'recipe ' : ''}tags available</div>`;
return;
}
tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
// {tag: "name", count: number}
const tagName = tag.tag;
tagEl.dataset.tag = tagName;
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
@@ -119,34 +136,80 @@ export class FilterManager {
const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return;
baseModelTagsContainer.innerHTML = '';
Object.entries(BASE_MODELS).forEach(([key, value]) => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag ${BASE_MODEL_CLASSES[value]}`;
tag.dataset.baseModel = value;
tag.innerHTML = value;
if (this.currentPage === 'loras') {
// Use predefined base models for loras page
baseModelTagsContainer.innerHTML = '';
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
Object.entries(BASE_MODELS).forEach(([key, value]) => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag ${BASE_MODEL_CLASSES[value]}`;
tag.dataset.baseModel = value;
tag.innerHTML = value;
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(value)) {
this.filters.baseModel.push(value);
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(value)) {
this.filters.baseModel.push(value);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(model => model !== value);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(model => model !== value);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
baseModelTagsContainer.appendChild(tag);
});
baseModelTagsContainer.appendChild(tag);
});
} else if (this.currentPage === 'recipes') {
// Fetch base models for recipes
fetch('/api/recipes/base-models')
.then(response => response.json())
.then(data => {
if (data.success && data.base_models) {
baseModelTagsContainer.innerHTML = '';
data.base_models.forEach(model => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag`;
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
// Update selections based on stored filters
this.updateTagSelections();
}
})
.catch(error => {
console.error('Error fetching base models:', error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
}
}
toggleFilterPanel() {
@@ -172,8 +235,12 @@ export class FilterManager {
}
closeFilterPanel() {
this.filterPanel.classList.add('hidden');
this.filterButton.classList.remove('active');
if (this.filterPanel) {
this.filterPanel.classList.add('hidden');
}
if (this.filterButton) {
this.filterButton.classList.remove('active');
}
}
updateTagSelections() {
@@ -203,24 +270,35 @@ export class FilterManager {
updateActiveFiltersCount() {
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
this.activeFiltersCount.style.display = 'inline-flex';
} else {
this.activeFiltersCount.style.display = 'none';
if (this.activeFiltersCount) {
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
this.activeFiltersCount.style.display = 'inline-flex';
} else {
this.activeFiltersCount.style.display = 'none';
}
}
}
async applyFilters(showToastNotification = true) {
const pageState = getCurrentPageState();
const storageKey = `${this.currentPage}_filters`;
// Save filters to localStorage
localStorage.setItem('loraFilters', JSON.stringify(this.filters));
localStorage.setItem(storageKey, JSON.stringify(this.filters));
// Update state with current filters
const pageState = getCurrentPageState();
pageState.filters = { ...this.filters };
// Reload loras with filters applied
await resetAndReload();
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras') {
// For loras page, reset the page and reload
await loadMoreLoras(true, true);
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
await window.checkpointManager.loadCheckpoints(true);
}
// Update filter button to show active state
if (this.hasActiveFilters()) {
@@ -264,15 +342,28 @@ export class FilterManager {
this.updateActiveFiltersCount();
// Remove from localStorage
localStorage.removeItem('loraFilters');
const storageKey = `${this.currentPage}_filters`;
localStorage.removeItem(storageKey);
// Update UI and reload data
// Update UI
this.filterButton.classList.remove('active');
await resetAndReload();
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras') {
await loadMoreLoras(true, true);
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
await window.checkpointManager.loadCheckpoints(true);
}
showToast(`Filters cleared`, 'info');
}
loadFiltersFromStorage() {
const savedFilters = localStorage.getItem('loraFilters');
const storageKey = `${this.currentPage}_filters`;
const savedFilters = localStorage.getItem(storageKey);
if (savedFilters) {
try {
const parsedFilters = JSON.parse(savedFilters);
@@ -283,6 +374,10 @@ export class FilterManager {
tags: parsedFilters.tags || []
};
// Update state with loaded filters
const pageState = getCurrentPageState();
pageState.filters = { ...this.filters };
this.updateTagSelections();
this.updateActiveFiltersCount();
@@ -290,7 +385,7 @@ export class FilterManager {
this.filterButton.classList.add('active');
}
} catch (error) {
console.error('Error loading filters from storage:', error);
console.error(`Error loading ${this.currentPage} filters from storage:`, error);
}
}
}

View File

@@ -1,136 +0,0 @@
/**
* LoraSearchManager - Specialized search manager for the LoRAs page
* Extends the base SearchManager with LoRA-specific functionality
*/
import { SearchManager } from './SearchManager.js';
import { appendLoraCards } from '../api/loraApi.js';
import { resetAndReload } from '../api/loraApi.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
export class LoraSearchManager extends SearchManager {
constructor(options = {}) {
super({
page: 'loras',
...options
});
this.currentSearchTerm = '';
// Store this instance in the state
if (state) {
const pageState = getCurrentPageState();
pageState.searchManager = this;
}
}
async performSearch() {
const searchTerm = this.searchInput.value.trim().toLowerCase();
const pageState = getCurrentPageState();
// Log the search attempt for debugging
console.log('LoraSearchManager performSearch called with:', searchTerm);
if (searchTerm === this.currentSearchTerm && !this.isSearching) {
return; // Avoid duplicate searches
}
this.currentSearchTerm = searchTerm;
const grid = document.getElementById('loraGrid');
if (!grid) {
console.error('Error: Could not find loraGrid element');
return;
}
if (!searchTerm) {
if (pageState) {
pageState.currentPage = 1;
}
await resetAndReload();
return;
}
try {
this.isSearching = true;
if (state && state.loadingManager) {
state.loadingManager.showSimpleLoading('Searching...');
}
// Store current scroll position
const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
if (pageState) {
pageState.currentPage = 1;
pageState.hasMore = true;
}
const url = new URL('/api/loras', window.location.origin);
url.searchParams.set('page', '1');
url.searchParams.set('page_size', '20');
url.searchParams.set('sort_by', pageState ? pageState.sortBy : 'name');
url.searchParams.set('search', searchTerm);
url.searchParams.set('fuzzy', 'true');
// Add search options
const searchOptions = this.getActiveSearchOptions();
console.log('Active search options:', searchOptions);
// Make sure we're sending boolean values as strings
url.searchParams.set('search_filename', searchOptions.filename ? 'true' : 'false');
url.searchParams.set('search_modelname', searchOptions.modelname ? 'true' : 'false');
url.searchParams.set('search_tags', searchOptions.tags ? 'true' : 'false');
// Always send folder parameter if there is an active folder
if (pageState && pageState.activeFolder) {
url.searchParams.set('folder', pageState.activeFolder);
// Add recursive parameter when recursive search is enabled
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
url.searchParams.set('recursive', recursive.toString());
}
console.log('Search URL:', url.toString());
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Search failed with status: ${response.status}`);
}
const data = await response.json();
console.log('Search results:', data);
if (searchTerm === this.currentSearchTerm) {
grid.innerHTML = '';
if (data.items.length === 0) {
grid.innerHTML = '<div class="no-results">No matching loras found</div>';
if (pageState) {
pageState.hasMore = false;
}
} else {
appendLoraCards(data.items);
if (pageState) {
pageState.hasMore = pageState.currentPage < data.total_pages;
pageState.currentPage++;
}
}
// Restore scroll position after content is loaded
setTimeout(() => {
window.scrollTo({
top: scrollPosition,
behavior: 'instant' // Use 'instant' to prevent animation
});
}, 10);
}
} catch (error) {
console.error('Search error:', error);
showToast('Search failed', 'error');
} finally {
this.isSearching = false;
if (state && state.loadingManager) {
state.loadingManager.hide();
}
}
}
}

View File

@@ -1,356 +0,0 @@
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
export class RecipeFilterManager {
constructor() {
this.filters = {
baseModel: [],
tags: []
};
this.filterPanel = document.getElementById('filterPanel');
this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.tagsLoaded = false;
this.initialize();
}
initialize() {
// Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) {
this.createBaseModelTags();
}
// Close filter panel when clicking outside
document.addEventListener('click', (e) => {
if (!this.filterPanel.contains(e.target) &&
e.target !== this.filterButton &&
!this.filterButton.contains(e.target) &&
!this.filterPanel.classList.contains('hidden')) {
this.closeFilterPanel();
}
});
// Add click handler for filter button
if (this.filterButton) {
this.filterButton.addEventListener('click', () => {
this.toggleFilterPanel();
});
}
// Initialize active filters from localStorage if available
this.loadFiltersFromStorage();
}
async loadTopTags() {
try {
// Show loading state
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
}
const response = await fetch('/api/recipes/top-tags?limit=20');
if (!response.ok) throw new Error('Failed to fetch recipe tags');
const data = await response.json();
if (data.success && data.tags) {
this.createTagFilterElements(data.tags);
// After creating tag elements, mark any previously selected ones
this.updateTagSelections();
} else {
throw new Error('Invalid response format');
}
} catch (error) {
console.error('Error loading top recipe tags:', error);
const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) {
tagsContainer.innerHTML = '<div class="tags-error">Failed to load tags</div>';
}
}
}
createTagFilterElements(tags) {
const tagsContainer = document.getElementById('modelTagsFilter');
if (!tagsContainer) return;
tagsContainer.innerHTML = '';
if (!tags.length) {
tagsContainer.innerHTML = '<div class="no-tags">No recipe tags available</div>';
return;
}
tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter';
const tagName = tag.tag;
tagEl.dataset.tag = tagName;
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
// Add click handler to toggle selection and automatically apply
tagEl.addEventListener('click', async () => {
tagEl.classList.toggle('active');
if (tagEl.classList.contains('active')) {
if (!this.filters.tags.includes(tagName)) {
this.filters.tags.push(tagName);
}
} else {
this.filters.tags = this.filters.tags.filter(t => t !== tagName);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
tagsContainer.appendChild(tagEl);
});
}
createBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return;
// Fetch base models used in recipes
fetch('/api/recipes/base-models')
.then(response => response.json())
.then(data => {
if (data.success && data.base_models) {
baseModelTagsContainer.innerHTML = '';
data.base_models.forEach(model => {
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag`;
tag.dataset.baseModel = model.name;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
// Add click handler to toggle selection and automatically apply
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
}
this.updateActiveFiltersCount();
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
});
baseModelTagsContainer.appendChild(tag);
});
// Update selections based on stored filters
this.updateTagSelections();
}
})
.catch(error => {
console.error('Error fetching base models:', error);
baseModelTagsContainer.innerHTML = '<div class="tags-error">Failed to load base models</div>';
});
}
toggleFilterPanel() {
if (this.filterPanel) {
const isHidden = this.filterPanel.classList.contains('hidden');
if (isHidden) {
// Update panel positions before showing
updatePanelPositions();
this.filterPanel.classList.remove('hidden');
this.filterButton.classList.add('active');
// Load tags if they haven't been loaded yet
if (!this.tagsLoaded) {
this.loadTopTags();
this.tagsLoaded = true;
}
} else {
this.closeFilterPanel();
}
}
}
closeFilterPanel() {
if (this.filterPanel) {
this.filterPanel.classList.add('hidden');
}
if (this.filterButton) {
this.filterButton.classList.remove('active');
}
}
updateTagSelections() {
// Update base model tags
const baseModelTags = document.querySelectorAll('.base-model-tag');
baseModelTags.forEach(tag => {
const baseModel = tag.dataset.baseModel;
if (this.filters.baseModel.includes(baseModel)) {
tag.classList.add('active');
} else {
tag.classList.remove('active');
}
});
// Update model tags
const modelTags = document.querySelectorAll('.tag-filter');
modelTags.forEach(tag => {
const tagName = tag.dataset.tag;
if (this.filters.tags.includes(tagName)) {
tag.classList.add('active');
} else {
tag.classList.remove('active');
}
});
}
updateActiveFiltersCount() {
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
if (totalActiveFilters > 0) {
this.activeFiltersCount.textContent = totalActiveFilters;
this.activeFiltersCount.style.display = 'inline-flex';
} else {
this.activeFiltersCount.style.display = 'none';
}
}
async applyFilters(showToastNotification = true) {
// Save filters to localStorage
localStorage.setItem('recipeFilters', JSON.stringify(this.filters));
// Reload recipes with filters applied
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
try {
// Show loading state if available
if (window.recipeManager.showLoading) {
window.recipeManager.showLoading();
}
// Apply the filters
await window.recipeManager.loadRecipes(this.filters);
// Hide loading state if available
if (window.recipeManager.hideLoading) {
window.recipeManager.hideLoading();
}
} catch (error) {
console.error('Error applying filters:', error);
// Fallback to page reload
this._applyFiltersByPageReload();
}
} else {
// Fallback to page reload with filter parameters
this._applyFiltersByPageReload();
}
// Update filter button to show active state
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
if (showToastNotification) {
const baseModelCount = this.filters.baseModel.length;
const tagsCount = this.filters.tags.length;
let message = '';
if (baseModelCount > 0 && tagsCount > 0) {
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''} and ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
} else if (baseModelCount > 0) {
message = `Filtering by ${baseModelCount} base model${baseModelCount > 1 ? 's' : ''}`;
} else if (tagsCount > 0) {
message = `Filtering by ${tagsCount} tag${tagsCount > 1 ? 's' : ''}`;
}
showToast(message, 'success');
}
} else {
this.filterButton.classList.remove('active');
if (showToastNotification) {
showToast('Filters cleared', 'info');
}
}
}
// Add a helper method for page reload fallback
_applyFiltersByPageReload() {
const params = new URLSearchParams();
if (this.filters.baseModel.length > 0) {
params.append('base_models', this.filters.baseModel.join(','));
}
if (this.filters.tags.length > 0) {
params.append('tags', this.filters.tags.join(','));
}
if (params.toString()) {
window.location.href = `/loras/recipes?${params.toString()}`;
} else {
window.location.href = '/loras/recipes';
}
}
async clearFilters() {
// Clear all filters
this.filters = {
baseModel: [],
tags: []
};
// Update UI
this.updateTagSelections();
this.updateActiveFiltersCount();
// Remove from localStorage
localStorage.removeItem('recipeFilters');
// Update UI and reload data
this.filterButton.classList.remove('active');
// Reload recipes without filters
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
await window.recipeManager.loadRecipes();
} else {
window.location.href = '/loras/recipes';
}
showToast('Recipe filters cleared', 'info');
}
loadFiltersFromStorage() {
const savedFilters = localStorage.getItem('recipeFilters');
if (savedFilters) {
try {
const parsedFilters = JSON.parse(savedFilters);
// Ensure backward compatibility with older filter format
this.filters = {
baseModel: parsedFilters.baseModel || [],
tags: parsedFilters.tags || []
};
this.updateTagSelections();
this.updateActiveFiltersCount();
if (this.hasActiveFilters()) {
this.filterButton.classList.add('active');
}
} catch (error) {
console.error('Error loading recipe filters from storage:', error);
}
}
}
hasActiveFilters() {
return this.filters.baseModel.length > 0 || this.filters.tags.length > 0;
}
}

View File

@@ -1,120 +0,0 @@
/**
* RecipeSearchManager - Specialized search manager for the Recipes page
* Extends the base SearchManager with recipe-specific functionality
*/
import { SearchManager } from './SearchManager.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
export class RecipeSearchManager extends SearchManager {
constructor(options = {}) {
super({
page: 'recipes',
...options
});
this.currentSearchTerm = '';
// Store this instance in the state
if (state) {
state.pages.recipes.searchManager = this;
}
}
async performSearch() {
const searchTerm = this.searchInput.value.trim().toLowerCase();
if (searchTerm === this.currentSearchTerm && !this.isSearching) {
return; // Avoid duplicate searches
}
this.currentSearchTerm = searchTerm;
const grid = document.getElementById('recipeGrid');
if (!searchTerm) {
window.recipeManager.loadRecipes();
return;
}
try {
this.isSearching = true;
if (state && state.loadingManager) {
state.loadingManager.showSimpleLoading('Searching recipes...');
}
// Store current scroll position
const scrollPosition = window.pageYOffset || document.documentElement.scrollTop;
if (state) {
state.pages.recipes.currentPage = 1;
state.pages.recipes.hasMore = true;
}
const url = new URL('/api/recipes', window.location.origin);
url.searchParams.set('page', '1');
url.searchParams.set('page_size', '20');
url.searchParams.set('sort_by', state ? state.pages.recipes.sortBy : 'name');
url.searchParams.set('search', searchTerm);
url.searchParams.set('fuzzy', 'true');
// Add search options
const recipeState = getCurrentPageState();
const searchOptions = recipeState.searchOptions;
url.searchParams.set('search_title', searchOptions.title.toString());
url.searchParams.set('search_tags', searchOptions.tags.toString());
url.searchParams.set('search_lora_name', searchOptions.loraName.toString());
url.searchParams.set('search_lora_model', searchOptions.loraModel.toString());
const response = await fetch(url);
if (!response.ok) {
throw new Error('Search failed');
}
const data = await response.json();
if (searchTerm === this.currentSearchTerm && grid) {
grid.innerHTML = '';
if (data.items.length === 0) {
grid.innerHTML = '<div class="no-results">No matching recipes found</div>';
if (state) {
state.pages.recipes.hasMore = false;
}
} else {
this.appendRecipeCards(data.items);
if (state) {
state.pages.recipes.hasMore = state.pages.recipes.currentPage < data.total_pages;
state.pages.recipes.currentPage++;
}
}
// Restore scroll position after content is loaded
setTimeout(() => {
window.scrollTo({
top: scrollPosition,
behavior: 'instant' // Use 'instant' to prevent animation
});
}, 10);
}
} catch (error) {
console.error('Recipe search error:', error);
showToast('Recipe search failed', 'error');
} finally {
this.isSearching = false;
if (state && state.loadingManager) {
state.loadingManager.hide();
}
}
}
appendRecipeCards(recipes) {
const grid = document.getElementById('recipeGrid');
if (!grid) return;
// Create data object in the format expected by the RecipeManager
const data = { items: recipes, has_more: false };
window.recipeManager.updateRecipesGrid(data, false);
}
}

View File

@@ -1,4 +1,5 @@
import { updatePanelPositions } from "../utils/uiHelpers.js";
import { getCurrentPageState } from "../state/index.js";
/**
* SearchManager - Handles search functionality across different pages
* Each page can extend or customize this base functionality
@@ -272,24 +273,52 @@ export class SearchManager {
const options = this.getActiveSearchOptions();
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
// This is a base implementation - each page should override this method
console.log('Performing search:', {
query,
options,
recursive,
page: this.currentPage
});
// Update the state with search parameters
const pageState = getCurrentPageState();
// Dispatch a custom event that page-specific code can listen for
const searchEvent = new CustomEvent('app:search', {
detail: {
query,
options,
recursive,
page: this.currentPage
// Set search query in filters
if (pageState && pageState.filters) {
pageState.filters.search = query;
}
// Update search options based on page type
if (pageState && pageState.searchOptions) {
if (this.currentPage === 'recipes') {
pageState.searchOptions = {
title: options.title || false,
tags: options.tags || false,
loraName: options.loraName || false,
loraModel: options.loraModel || false
};
} else if (this.currentPage === 'loras') {
pageState.searchOptions = {
filename: options.filename || false,
modelname: options.modelname || false,
tags: options.tags || false,
recursive: recursive
};
} else if (this.currentPage === 'checkpoints') {
pageState.searchOptions = {
filename: options.filename || false,
modelname: options.modelname || false,
recursive: recursive
};
}
});
}
document.dispatchEvent(searchEvent);
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
console.log("load recipes")
window.recipeManager.loadRecipes(true); // true to reset pagination
} else if (this.currentPage === 'loras' && window.loadMoreLoras) {
// Reset loras page and reload
if (pageState) {
pageState.currentPage = 1;
pageState.hasMore = true;
}
window.loadMoreLoras(true); // true to reset pagination
} else if (this.currentPage === 'checkpoints' && window.checkpointManager) {
window.checkpointManager.loadCheckpoints(true); // true to reset pagination
}
}
}

View File

@@ -58,10 +58,17 @@ class RecipeManager {
window.recipeManager = this;
window.importRecipes = () => this.importRecipes();
window.importManager = this.importManager;
window.loadMoreRecipes = () => this.loadMoreRecipes();
// Add appendRecipeCards function for the search manager to use
// Deprecated - kept for backwards compatibility
window.loadMoreRecipes = () => {
console.warn('loadMoreRecipes is deprecated, use infiniteScroll instead');
this.pageState.currentPage++;
this.loadRecipes(false);
};
// Add appendRecipeCards function for compatibility
window.appendRecipeCards = (recipes) => {
console.warn('appendRecipeCards is deprecated, use recipeManager.updateRecipesGrid instead');
const data = { items: recipes, has_more: false };
this.updateRecipesGrid(data, false);
};
@@ -102,6 +109,15 @@ class RecipeManager {
// 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
@@ -113,6 +129,8 @@ class RecipeManager {
if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
params.append('tags', this.pageState.filters.tags.join(','));
}
console.log('Loading recipes with params:', params.toString());
// Fetch recipes
const response = await fetch(`/api/recipes?${params.toString()}`);
@@ -139,14 +157,6 @@ class RecipeManager {
}
}
// Load more recipes for infinite scroll
async loadMoreRecipes() {
if (this.pageState.isLoading || !this.pageState.hasMore) return;
this.pageState.currentPage++;
await this.loadRecipes(false);
}
updateRecipesGrid(data, resetGrid = true) {
const grid = document.getElementById('recipeGrid');
if (!grid) return;

View File

@@ -19,16 +19,26 @@ export function initializeInfiniteScroll(pageType = 'loras') {
switch (pageType) {
case 'recipes':
loadMoreFunction = window.recipeManager?.loadMoreRecipes || (() => console.warn('loadMoreRecipes not found'));
loadMoreFunction = () => {
if (!pageState.isLoading && pageState.hasMore) {
pageState.currentPage++;
window.recipeManager.loadRecipes(false); // false to not reset pagination
}
};
gridId = 'recipeGrid';
break;
case 'checkpoints':
loadMoreFunction = window.checkpointManager?.loadMoreCheckpoints || (() => console.warn('loadMoreCheckpoints not found'));
loadMoreFunction = () => {
if (!pageState.isLoading && pageState.hasMore) {
pageState.currentPage++;
window.checkpointManager.loadCheckpoints(false); // false to not reset pagination
}
};
gridId = 'checkpointGrid';
break;
case 'loras':
default:
loadMoreFunction = loadMoreLoras;
loadMoreFunction = () => loadMoreLoras(false); // false to not reset
gridId = 'loraGrid';
break;
}