diff --git a/locales/en.json b/locales/en.json index 995ce3bd..ac31a237 100644 --- a/locales/en.json +++ b/locales/en.json @@ -204,6 +204,9 @@ }, "filter": { "title": "Filter Models", + "presets": "Presets", + "savePreset": "Save current active filters as a new preset (first select filters below, then click here)", + "noPresets": "No presets saved yet. Select filters below and click + to save", "baseModel": "Base Model", "modelTags": "Tags (Top 20)", "modelTypes": "Model Types", diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index 920a868e..f177a393 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -466,6 +466,124 @@ border-color: var(--lora-accent); } +/* Presets Section Styles */ +.presets-section { + border-bottom: 1px solid var(--border-color); + padding-bottom: 16px; + margin-bottom: 16px; +} + +.presets-section h4 { + margin: 0 0 8px 0; +} + +.filter-presets { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.filter-preset { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 4px 4px 4px 10px; + border-radius: var(--border-radius-sm); + background-color: var(--lora-surface); + border: 1px solid var(--border-color); + transition: all 0.2s ease; + cursor: pointer; +} + +.filter-preset:hover { + background-color: var(--lora-surface-hover); + border-color: var(--lora-accent); +} + +.filter-preset.active { + background-color: var(--lora-accent); + border-color: var(--lora-accent); +} + +.filter-preset.active .preset-name { + color: white; + font-weight: 600; +} + +.filter-preset.active .preset-delete-btn { + color: white; + opacity: 0.8; +} + +.filter-preset.active .preset-delete-btn:hover { + opacity: 1; + color: white; +} + +.preset-name { + cursor: pointer; + font-size: 14px; + color: var(--text-color); + user-select: none; + display: flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.preset-delete-btn { + background: none; + border: none; + color: var(--text-color); + opacity: 0.5; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + transition: all 0.2s ease; + margin-left: auto; +} + +.preset-delete-btn:hover { + opacity: 1; + color: var(--lora-error, #e74c3c); +} + +.add-preset-btn { + background-color: transparent !important; + border: 1px dashed var(--border-color) !important; + color: var(--text-color); + opacity: 0.7; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + font-size: 14px; +} + +.add-preset-btn:hover { + opacity: 1; + border-color: var(--lora-accent) !important; + color: var(--lora-accent); + background-color: var(--lora-surface-hover) !important; +} + +.add-preset-btn i { + font-size: 12px; +} + +.no-presets { + width: 100%; + padding: 12px 8px; + text-align: center; + font-size: 0.9em; + color: var(--text-color); + opacity: 0.6; + font-style: italic; +} + /* Mobile adjustments */ @media (max-width: 768px) { .search-options-panel, diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 17de20f9..a48858f5 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -20,6 +20,7 @@ export class FilterManager { this.filterButton = document.getElementById('filterButton'); this.activeFiltersCount = document.getElementById('activeFiltersCount'); this.tagsLoaded = false; + this.activePreset = null; // Track currently active preset this.initialize(); @@ -109,12 +110,35 @@ export class FilterManager { return; } + // Collect existing tag names from the API response + const existingTagNames = new Set(tags.map(t => t.tag)); + + // Add any active filter tags that aren't in the top 20 + if (this.filters.tags) { + Object.keys(this.filters.tags).forEach(tagName => { + // Skip special tags like __no_tags__ + if (tagName.startsWith('__')) return; + + if (!existingTagNames.has(tagName)) { + // Add this tag to the list with count 0 (unknown) + tags.push({ tag: tagName, count: 0 }); + existingTagNames.add(tagName); + } + }); + } + 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}`; + + // Show count only if it's > 0 (known count) + if (tag.count > 0) { + tagEl.innerHTML = `${tagName} ${tag.count}`; + } else { + tagEl.textContent = tagName; + } // Add click handler to cycle through tri-state filter and automatically apply tagEl.addEventListener('click', async () => { @@ -376,6 +400,9 @@ export class FilterManager { this.loadTopTags(); this.tagsLoaded = true; } + + // Render presets + this.renderPresets(); } else { this.closeFilterPanel(); } @@ -446,7 +473,7 @@ export class FilterManager { } } - async applyFilters(showToastNotification = true) { + async applyFilters(showToastNotification = true, isPresetApply = false) { const pageState = getCurrentPageState(); const storageKey = `${this.currentPage}_filters`; @@ -457,6 +484,12 @@ export class FilterManager { // Update state with current filters pageState.filters = filtersSnapshot; + // Deactivate preset if this is a manual filter change (not from applying a preset) + if (!isPresetApply && this.activePreset) { + this.activePreset = null; + this.renderPresets(); // Re-render to remove active state + } + // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); @@ -492,6 +525,9 @@ export class FilterManager { } async clearFilters() { + // Clear active preset + this.activePreset = null; + // Clear all filters this.filters = this.initializeFilters({ ...this.filters, @@ -508,6 +544,7 @@ export class FilterManager { // Update UI this.updateTagSelections(); this.updateActiveFiltersCount(); + this.renderPresets(); // Re-render to remove active state // Remove from local Storage const storageKey = `${this.currentPage}_filters`; @@ -695,4 +732,220 @@ export class FilterManager { element.classList.add('exclude'); } } + + // Preset management methods + loadPresets() { + const presetsKey = `${this.currentPage}_filter_presets`; + const presets = getStorageItem(presetsKey); + + // If no presets exist and this is the loras page, add default example presets + if ((!presets || presets.length === 0) && this.currentPage === 'loras') { + return this.getDefaultPresets(); + } + + return Array.isArray(presets) ? presets : []; + } + + getDefaultPresets() { + // Example presets that users can modify or delete + return [ + { + name: "WAN Models", + filters: { + baseModel: [ + "Wan Video", + "Wan Video 1.3B t2v", + "Wan Video 14B t2v", + "Wan Video 14B i2v 480p", + "Wan Video 14B i2v 720p", + "Wan Video 2.2 TI2V-5B", + "Wan Video 2.2 T2V-A14B", + "Wan Video 2.2 I2V-A14B" + ], + tags: {}, + license: {}, + modelTypes: [] + }, + createdAt: Date.now(), + isDefault: true + } + ]; + } + + savePresets(presets) { + const presetsKey = `${this.currentPage}_filter_presets`; + setStorageItem(presetsKey, presets); + } + + createPreset(name) { + if (!name || !name.trim()) { + showToast('Preset name cannot be empty', {}, 'error'); + return; + } + + const presets = this.loadPresets(); + + // Check for duplicate names + if (presets.some(p => p.name === name.trim())) { + showToast('A preset with this name already exists', {}, 'error'); + return; + } + + const preset = { + name: name.trim(), + filters: this.cloneFilters(), + createdAt: Date.now() + }; + + presets.push(preset); + this.savePresets(presets); + this.renderPresets(); + showToast(`Preset "${name}" created`, {}, 'success'); + } + + deletePreset(name) { + const presets = this.loadPresets(); + const filtered = presets.filter(p => p.name !== name); + + // If no presets left, remove from storage so defaults can appear + if (filtered.length === 0) { + const presetsKey = `${this.currentPage}_filter_presets`; + removeStorageItem(presetsKey); + } else { + this.savePresets(filtered); + } + + this.renderPresets(); + showToast(`Preset "${name}" deleted`, {}, 'success'); + } + + async applyPreset(name) { + const presets = this.loadPresets(); + const preset = presets.find(p => p.name === name); + + if (!preset) { + showToast('Preset not found', {}, 'error'); + return; + } + + // Set active preset + this.activePreset = name; + + // Apply the preset filters + this.filters = this.initializeFilters(preset.filters); + + // Update state + const pageState = getCurrentPageState(); + pageState.filters = this.cloneFilters(); + + // If tags haven't been loaded yet, load them first + if (!this.tagsLoaded) { + await this.loadTopTags(); + this.tagsLoaded = true; + } + + // Update UI - this will show the blue outlines on active filters + // Must be done AFTER tags are loaded + this.updateTagSelections(); + this.updateActiveFiltersCount(); + this.renderPresets(); // Re-render to show active state + + // Apply filters (pass true for isPresetApply so it doesn't clear activePreset) + // Don't show toast here, we'll show it after + await this.applyFilters(false, true); + + // Show success toast without closing the panel + showToast(`Preset "${name}" applied`, {}, 'success'); + } + + renderPresets() { + const presetsContainer = document.getElementById('filterPresets'); + if (!presetsContainer) return; + + const presets = this.loadPresets(); + presetsContainer.innerHTML = ''; + + // Render existing presets + presets.forEach(preset => { + const presetEl = document.createElement('div'); + presetEl.className = 'filter-preset'; + + // Mark as active if this is the active preset + const isActive = this.activePreset === preset.name; + if (isActive) { + presetEl.classList.add('active'); + } + + // Stop propagation on the preset element itself + presetEl.addEventListener('click', (e) => { + e.stopPropagation(); + }); + + const presetName = document.createElement('span'); + presetName.className = 'preset-name'; + + // Add checkmark icon if active + if (isActive) { + presetName.innerHTML = ` ${preset.name}`; + } else { + presetName.textContent = preset.name; + } + presetName.title = `Click to apply preset "${preset.name}"`; + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'preset-delete-btn'; + deleteBtn.innerHTML = ''; + deleteBtn.title = 'Delete preset'; + + // Apply preset on name click (toggle if already active) + presetName.addEventListener('click', async (e) => { + e.stopPropagation(); // Prevent click from bubbling to document + + // If this preset is already active, deactivate it (clear filters) + if (this.activePreset === preset.name) { + await this.clearFilters(); + } else { + await this.applyPreset(preset.name); + } + }); + + // Delete preset on delete button click + deleteBtn.addEventListener('click', (e) => { + e.stopPropagation(); + if (confirm(`Delete preset "${preset.name}"?`)) { + this.deletePreset(preset.name); + } + }); + + presetEl.appendChild(presetName); + presetEl.appendChild(deleteBtn); + presetsContainer.appendChild(presetEl); + }); + + // Add the "Add new preset" button as the last element + const addBtn = document.createElement('div'); + addBtn.className = 'filter-preset add-preset-btn'; + addBtn.innerHTML = ' Add'; + addBtn.title = 'Save current filters as a new preset'; + + addBtn.addEventListener('click', (e) => { + e.stopPropagation(); + this.showSavePresetDialog(); + }); + + presetsContainer.appendChild(addBtn); + } + + showSavePresetDialog() { + // Check if there are any active filters + if (!this.hasActiveFilters()) { + showToast('No active filters to save', {}, 'info'); + return; + } + + const name = prompt('Enter preset name:'); + if (name !== null) { + this.createPreset(name); + } + } } diff --git a/templates/components/header.html b/templates/components/header.html index 95c7c01e..6048963e 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -135,6 +135,15 @@ + + +