diff --git a/py/routes/lora_routes.py b/py/routes/lora_routes.py index ef6aed55..ae1a5d8f 100644 --- a/py/routes/lora_routes.py +++ b/py/routes/lora_routes.py @@ -71,7 +71,8 @@ class LoraRoutes: rendered = template.render( folders=[], # 空文件夹列表 is_initializing=True, # 新增标志 - settings=settings # Pass settings to template + settings=settings, # Pass settings to template + request=request # Pass the request object to the template ) else: # 正常流程 @@ -80,7 +81,8 @@ class LoraRoutes: rendered = template.render( folders=cache.folders, is_initializing=False, - settings=settings # Pass settings to template + settings=settings, # Pass settings to template + request=request # Pass the request object to the template ) return web.Response( @@ -110,7 +112,8 @@ class LoraRoutes: template = self.template_env.get_template('recipes.html') rendered = template.render( is_initializing=True, - settings=settings + settings=settings, + request=request # Pass the request object to the template ) else: # Normal flow - get recipes with the same formatting as the API endpoint @@ -137,7 +140,8 @@ class LoraRoutes: rendered = template.render( recipes=recipes_data, is_initializing=False, - settings=settings + settings=settings, + request=request # Pass the request object to the template ) return web.Response( diff --git a/static/js/main.js b/static/js/main.js index 797e5c3c..78489f0c 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -22,7 +22,7 @@ import { } from './utils/uiHelpers.js'; import { initializeInfiniteScroll } from './utils/infiniteScroll.js'; import { showDeleteModal, confirmDelete, closeDeleteModal } from './utils/modalUtils.js'; -import { SearchManager } from './utils/search.js'; +import { SearchManager } from './managers/SearchManager.js'; import { DownloadManager } from './managers/DownloadManager.js'; import { SettingsManager, toggleApiKeyVisibility } from './managers/SettingsManager.js'; import { LoraContextMenu } from './components/ContextMenu.js'; @@ -104,6 +104,22 @@ document.addEventListener('DOMContentLoaded', async () => { // Update positions on window resize window.addEventListener('resize', updatePanelPositions); + + // Initialize search manager with LoRA-specific options + const loraSearchManager = new SearchManager({ + searchCallback: (query, options, recursive) => { + // LoRA-specific search implementation + // This could call your API with the right parameters + fetchLoras({ + search: query, + search_options: options, + recursive: recursive + }); + } + }); + + // Set the current page for proper context + document.body.dataset.page = 'loras'; }); // Initialize event listeners diff --git a/static/js/managers/RecipeFilterManager.js b/static/js/managers/RecipeFilterManager.js new file mode 100644 index 00000000..cc661e8e --- /dev/null +++ b/static/js/managers/RecipeFilterManager.js @@ -0,0 +1,360 @@ +import { showToast } 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 = '
'; + } + + 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 = ''; + } + } + } + + createTagFilterElements(tags) { + const tagsContainer = document.getElementById('modelTagsFilter'); + if (!tagsContainer) return; + + tagsContainer.innerHTML = ''; + + if (!tags.length) { + tagsContainer.innerHTML = ''; + 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} ${tag.count}`; + + // 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} ${model.count}`; + + // 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 = ''; + }); + } + + toggleFilterPanel() { + if (this.filterPanel) { + const isHidden = this.filterPanel.classList.contains('hidden'); + + if (isHidden) { + // Update panel positions before showing + if (window.searchManager && typeof window.searchManager.updatePanelPositions === 'function') { + window.searchManager.updatePanelPositions(); + } else if (typeof updatePanelPositions === 'function') { + 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; + } +} \ No newline at end of file diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js new file mode 100644 index 00000000..51a00343 --- /dev/null +++ b/static/js/managers/SearchManager.js @@ -0,0 +1,154 @@ +/** + * SearchManager - Handles search functionality across different pages + * Each page can extend or customize this base functionality + */ +export class SearchManager { + constructor(options = {}) { + this.options = { + searchDelay: 300, + minSearchLength: 2, + ...options + }; + + this.searchInput = document.getElementById('searchInput'); + this.searchOptionsToggle = document.getElementById('searchOptionsToggle'); + this.searchOptionsPanel = document.getElementById('searchOptionsPanel'); + this.closeSearchOptions = document.getElementById('closeSearchOptions'); + this.searchOptionTags = document.querySelectorAll('.search-option-tag'); + this.recursiveSearchToggle = document.getElementById('recursiveSearchToggle'); + + this.searchTimeout = null; + this.currentPage = document.body.dataset.page || 'loras'; + + this.initEventListeners(); + this.loadSearchPreferences(); + } + + initEventListeners() { + // Search input event + if (this.searchInput) { + this.searchInput.addEventListener('input', () => { + clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => this.performSearch(), this.options.searchDelay); + }); + + // Clear search with Escape key + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + this.searchInput.value = ''; + this.performSearch(); + } + }); + } + + // Search options toggle + if (this.searchOptionsToggle) { + this.searchOptionsToggle.addEventListener('click', () => { + this.searchOptionsPanel.classList.toggle('hidden'); + }); + } + + // Close search options + if (this.closeSearchOptions) { + this.closeSearchOptions.addEventListener('click', () => { + this.searchOptionsPanel.classList.add('hidden'); + }); + } + + // Search option tags + if (this.searchOptionTags) { + this.searchOptionTags.forEach(tag => { + tag.addEventListener('click', () => { + tag.classList.toggle('active'); + this.saveSearchPreferences(); + this.performSearch(); + }); + }); + } + + // Recursive search toggle + if (this.recursiveSearchToggle) { + this.recursiveSearchToggle.addEventListener('change', () => { + this.saveSearchPreferences(); + this.performSearch(); + }); + } + } + + loadSearchPreferences() { + try { + const preferences = JSON.parse(localStorage.getItem(`${this.currentPage}_search_prefs`)) || {}; + + // Apply search options + if (preferences.options) { + this.searchOptionTags.forEach(tag => { + const option = tag.dataset.option; + if (preferences.options[option] !== undefined) { + tag.classList.toggle('active', preferences.options[option]); + } + }); + } + + // Apply recursive search + if (this.recursiveSearchToggle && preferences.recursive !== undefined) { + this.recursiveSearchToggle.checked = preferences.recursive; + } + } catch (error) { + console.error('Error loading search preferences:', error); + } + } + + saveSearchPreferences() { + try { + const options = {}; + this.searchOptionTags.forEach(tag => { + options[tag.dataset.option] = tag.classList.contains('active'); + }); + + const preferences = { + options, + recursive: this.recursiveSearchToggle ? this.recursiveSearchToggle.checked : false + }; + + localStorage.setItem(`${this.currentPage}_search_prefs`, JSON.stringify(preferences)); + } catch (error) { + console.error('Error saving search preferences:', error); + } + } + + getActiveSearchOptions() { + const options = []; + this.searchOptionTags.forEach(tag => { + if (tag.classList.contains('active')) { + options.push(tag.dataset.option); + } + }); + return options; + } + + performSearch() { + const query = this.searchInput.value.trim(); + 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 + }); + + // Dispatch a custom event that page-specific code can listen for + const searchEvent = new CustomEvent('app:search', { + detail: { + query, + options, + recursive, + page: this.currentPage + } + }); + + document.dispatchEvent(searchEvent); + } + } \ No newline at end of file diff --git a/static/js/recipes.js b/static/js/recipes.js index 19eb10e1..23ec1f4b 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -5,6 +5,7 @@ import { initializeCommonComponents } from './common.js'; import { ImportManager } from './managers/ImportManager.js'; import { RecipeCard } from './components/RecipeCard.js'; import { RecipeModal } from './components/RecipeModal.js'; +import { SearchManager } from './managers/SearchManager.js'; class RecipeManager { constructor() { @@ -28,6 +29,21 @@ class RecipeManager { // Load initial set of recipes this.loadRecipes(); + + // Initialize search manager with Recipe-specific options + const recipeSearchManager = new SearchManager({ + searchCallback: (query, options, recursive) => { + // Recipe-specific search implementation + fetchRecipes({ + search: query, + search_options: options, + recursive: recursive + }); + } + }); + + // Set the current page for proper context + document.body.dataset.page = 'recipes'; } initEventListeners() { diff --git a/templates/components/header.html b/templates/components/header.html index 7f9725a7..9ad6cfdb 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -7,21 +7,21 @@ - +