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_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_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/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 # Add update check routes
UpdateRoutes.setup_routes(app) UpdateRoutes.setup_routes(app)
@@ -842,30 +841,4 @@ class ApiRoutes:
return web.json_response({ return web.json_response({
'success': False, 'success': False,
'error': 'Internal server error' 'error': 'Internal server error'
}, status=500) }, 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)

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
import { updateService } from '../managers/UpdateService.js'; import { updateService } from '../managers/UpdateService.js';
import { toggleTheme } from '../utils/uiHelpers.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 * Header.js - Manages the application header behavior across different pages
@@ -27,40 +29,14 @@ export class HeaderManager {
} }
initializeManagers() { initializeManagers() {
// Import and initialize appropriate search manager based on page // Initialize SearchManager for all page types
if (this.currentPage === 'loras') { this.searchManager = new SearchManager({ page: this.currentPage });
import('../managers/LoraSearchManager.js').then(module => { window.searchManager = this.searchManager;
const { LoraSearchManager } = module;
this.searchManager = new LoraSearchManager(); // Initialize FilterManager for all page types that have filters
window.searchManager = this.searchManager; if (document.getElementById('filterButton')) {
}); this.filterManager = new FilterManager({ page: this.currentPage });
window.filterManager = this.filterManager;
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
} }
} }

View File

@@ -1,11 +1,17 @@
import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js'; import { BASE_MODELS, BASE_MODEL_CLASSES } from '../utils/constants.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast, updatePanelPositions } from '../utils/uiHelpers.js'; import { showToast, updatePanelPositions } from '../utils/uiHelpers.js';
import { resetAndReload } from '../api/loraApi.js'; import { loadMoreLoras } from '../api/loraApi.js';
export class FilterManager { export class FilterManager {
constructor() { constructor(options = {}) {
this.options = {
...options
};
this.currentPage = options.page || document.body.dataset.page || 'loras';
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
this.filters = pageState.filters || { this.filters = pageState.filters || {
baseModel: [], baseModel: [],
tags: [] tags: []
@@ -17,11 +23,18 @@ export class FilterManager {
this.tagsLoaded = false; this.tagsLoaded = false;
this.initialize(); this.initialize();
// Store this instance in the state
if (pageState) {
pageState.filterManager = this;
}
} }
initialize() { initialize() {
// Create base model filter tags // Create base model filter tags if they exist
this.createBaseModelTags(); if (document.getElementById('baseModelTags')) {
this.createBaseModelTags();
}
// Add click handler for filter button // Add click handler for filter button
if (this.filterButton) { if (this.filterButton) {
@@ -32,7 +45,7 @@ export class FilterManager {
// Close filter panel when clicking outside // Close filter panel when clicking outside
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (!this.filterPanel.contains(e.target) && if (this.filterPanel && !this.filterPanel.contains(e.target) &&
e.target !== this.filterButton && e.target !== this.filterButton &&
!this.filterButton.contains(e.target) && !this.filterButton.contains(e.target) &&
!this.filterPanel.classList.contains('hidden')) { !this.filterPanel.classList.contains('hidden')) {
@@ -48,15 +61,20 @@ export class FilterManager {
try { try {
// Show loading state // Show loading state
const tagsContainer = document.getElementById('modelTagsFilter'); const tagsContainer = document.getElementById('modelTagsFilter');
if (tagsContainer) { if (!tagsContainer) return;
tagsContainer.innerHTML = '<div class="tags-loading">Loading tags...</div>';
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'); if (!response.ok) throw new Error('Failed to fetch tags');
const data = await response.json(); const data = await response.json();
console.log('Top tags:', data);
if (data.success && data.tags) { if (data.success && data.tags) {
this.createTagFilterElements(data.tags); this.createTagFilterElements(data.tags);
@@ -81,14 +99,13 @@ export class FilterManager {
tagsContainer.innerHTML = ''; tagsContainer.innerHTML = '';
if (!tags.length) { 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; return;
} }
tags.forEach(tag => { tags.forEach(tag => {
const tagEl = document.createElement('div'); const tagEl = document.createElement('div');
tagEl.className = 'filter-tag tag-filter'; tagEl.className = 'filter-tag tag-filter';
// {tag: "name", count: number}
const tagName = tag.tag; const tagName = tag.tag;
tagEl.dataset.tag = tagName; tagEl.dataset.tag = tagName;
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`; tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
@@ -119,34 +136,80 @@ export class FilterManager {
const baseModelTagsContainer = document.getElementById('baseModelTags'); const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
baseModelTagsContainer.innerHTML = ''; if (this.currentPage === 'loras') {
// Use predefined base models for loras page
Object.entries(BASE_MODELS).forEach(([key, value]) => { baseModelTagsContainer.innerHTML = '';
const tag = document.createElement('div');
tag.className = `filter-tag base-model-tag ${BASE_MODEL_CLASSES[value]}`;
tag.dataset.baseModel = value;
tag.innerHTML = value;
// Add click handler to toggle selection and automatically apply Object.entries(BASE_MODELS).forEach(([key, value]) => {
tag.addEventListener('click', async () => { const tag = document.createElement('div');
tag.classList.toggle('active'); tag.className = `filter-tag base-model-tag ${BASE_MODEL_CLASSES[value]}`;
tag.dataset.baseModel = value;
tag.innerHTML = value;
if (tag.classList.contains('active')) { // Add click handler to toggle selection and automatically apply
if (!this.filters.baseModel.includes(value)) { tag.addEventListener('click', async () => {
this.filters.baseModel.push(value); 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(); baseModelTagsContainer.appendChild(tag);
// Auto-apply filter when tag is clicked
await this.applyFilters(false);
}); });
} else if (this.currentPage === 'recipes') {
baseModelTagsContainer.appendChild(tag); // 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() { toggleFilterPanel() {
@@ -172,8 +235,12 @@ export class FilterManager {
} }
closeFilterPanel() { closeFilterPanel() {
this.filterPanel.classList.add('hidden'); if (this.filterPanel) {
this.filterButton.classList.remove('active'); this.filterPanel.classList.add('hidden');
}
if (this.filterButton) {
this.filterButton.classList.remove('active');
}
} }
updateTagSelections() { updateTagSelections() {
@@ -203,24 +270,35 @@ export class FilterManager {
updateActiveFiltersCount() { updateActiveFiltersCount() {
const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length; const totalActiveFilters = this.filters.baseModel.length + this.filters.tags.length;
if (totalActiveFilters > 0) { if (this.activeFiltersCount) {
this.activeFiltersCount.textContent = totalActiveFilters; if (totalActiveFilters > 0) {
this.activeFiltersCount.style.display = 'inline-flex'; this.activeFiltersCount.textContent = totalActiveFilters;
} else { this.activeFiltersCount.style.display = 'inline-flex';
this.activeFiltersCount.style.display = 'none'; } else {
this.activeFiltersCount.style.display = 'none';
}
} }
} }
async applyFilters(showToastNotification = true) { async applyFilters(showToastNotification = true) {
const pageState = getCurrentPageState();
const storageKey = `${this.currentPage}_filters`;
// Save filters to localStorage // Save filters to localStorage
localStorage.setItem('loraFilters', JSON.stringify(this.filters)); localStorage.setItem(storageKey, JSON.stringify(this.filters));
// Update state with current filters // Update state with current filters
const pageState = getCurrentPageState();
pageState.filters = { ...this.filters }; pageState.filters = { ...this.filters };
// Reload loras with filters applied // Call the appropriate manager's load method based on page type
await resetAndReload(); 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 // Update filter button to show active state
if (this.hasActiveFilters()) { if (this.hasActiveFilters()) {
@@ -264,15 +342,28 @@ export class FilterManager {
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
// Remove from localStorage // 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'); 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() { loadFiltersFromStorage() {
const savedFilters = localStorage.getItem('loraFilters'); const storageKey = `${this.currentPage}_filters`;
const savedFilters = localStorage.getItem(storageKey);
if (savedFilters) { if (savedFilters) {
try { try {
const parsedFilters = JSON.parse(savedFilters); const parsedFilters = JSON.parse(savedFilters);
@@ -283,6 +374,10 @@ export class FilterManager {
tags: parsedFilters.tags || [] tags: parsedFilters.tags || []
}; };
// Update state with loaded filters
const pageState = getCurrentPageState();
pageState.filters = { ...this.filters };
this.updateTagSelections(); this.updateTagSelections();
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
@@ -290,7 +385,7 @@ export class FilterManager {
this.filterButton.classList.add('active'); this.filterButton.classList.add('active');
} }
} catch (error) { } 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 { updatePanelPositions } from "../utils/uiHelpers.js";
import { getCurrentPageState } from "../state/index.js";
/** /**
* SearchManager - Handles search functionality across different pages * SearchManager - Handles search functionality across different pages
* Each page can extend or customize this base functionality * Each page can extend or customize this base functionality
@@ -272,24 +273,52 @@ export class SearchManager {
const options = this.getActiveSearchOptions(); const options = this.getActiveSearchOptions();
const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false; const recursive = this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false;
// This is a base implementation - each page should override this method // Update the state with search parameters
console.log('Performing search:', { const pageState = getCurrentPageState();
query,
options,
recursive,
page: this.currentPage
});
// Dispatch a custom event that page-specific code can listen for // Set search query in filters
const searchEvent = new CustomEvent('app:search', { if (pageState && pageState.filters) {
detail: { pageState.filters.search = query;
query, }
options,
recursive, // Update search options based on page type
page: this.currentPage 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.recipeManager = this;
window.importRecipes = () => this.importRecipes(); window.importRecipes = () => this.importRecipes();
window.importManager = this.importManager; 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) => { window.appendRecipeCards = (recipes) => {
console.warn('appendRecipeCards is deprecated, use recipeManager.updateRecipesGrid instead');
const data = { items: recipes, has_more: false }; const data = { items: recipes, has_more: false };
this.updateRecipesGrid(data, false); this.updateRecipesGrid(data, false);
}; };
@@ -102,6 +109,15 @@ class RecipeManager {
// Add search filter if present // Add search filter if present
if (this.pageState.filters.search) { if (this.pageState.filters.search) {
params.append('search', 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 // Add base model filters
@@ -113,6 +129,8 @@ class RecipeManager {
if (this.pageState.filters.tags && this.pageState.filters.tags.length) { if (this.pageState.filters.tags && this.pageState.filters.tags.length) {
params.append('tags', this.pageState.filters.tags.join(',')); params.append('tags', this.pageState.filters.tags.join(','));
} }
console.log('Loading recipes with params:', params.toString());
// Fetch recipes // Fetch recipes
const response = await fetch(`/api/recipes?${params.toString()}`); 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) { updateRecipesGrid(data, resetGrid = true) {
const grid = document.getElementById('recipeGrid'); const grid = document.getElementById('recipeGrid');
if (!grid) return; if (!grid) return;

View File

@@ -19,16 +19,26 @@ export function initializeInfiniteScroll(pageType = 'loras') {
switch (pageType) { switch (pageType) {
case 'recipes': 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'; gridId = 'recipeGrid';
break; break;
case 'checkpoints': 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'; gridId = 'checkpointGrid';
break; break;
case 'loras': case 'loras':
default: default:
loadMoreFunction = loadMoreLoras; loadMoreFunction = () => loadMoreLoras(false); // false to not reset
gridId = 'loraGrid'; gridId = 'loraGrid';
break; break;
} }