mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: add filter preset system
Add ability to save and manage filter presets for quick access to commonly used filter combinations. Features: - Save current active filters as named presets - Apply presets with one click (shows active state with checkmark) - Toggle presets on/off like regular filters - Delete presets - Presets stored in browser localStorage per page - Default "WAN Models" preset for LoRA page - Visual feedback: active preset highlighted, filter tags show blue outlines - Inline "+ Add" button flows with preset tags UI/UX improvements: - Preset tags use same compact style as filter tags - Active preset deactivates when filters manually changed - Missing tags from presets automatically added to tag list - Clear filters properly resets preset state
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
// Show count only if it's > 0 (known count)
|
||||
if (tag.count > 0) {
|
||||
tagEl.innerHTML = `${tagName} <span class="tag-count">${tag.count}</span>`;
|
||||
} 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,211 @@ 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 1.3B t2v", "Wan Video 14B t2v", "Wan Video 14B i2v 480p", "Wan Video 14B i2v 720p"],
|
||||
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 = `<i class="fas fa-check"></i> ${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 = '<i class="fas fa-times"></i>';
|
||||
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 = '<i class="fas fa-plus"></i> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,15 @@
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Presets Section -->
|
||||
<div class="filter-section presets-section">
|
||||
<h4>{{ t('header.filter.presets') }}</h4>
|
||||
<div class="filter-presets" id="filterPresets">
|
||||
<div class="no-presets">{{ t('header.filter.noPresets') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<h4>{{ t('header.filter.baseModel') }}</h4>
|
||||
<div class="filter-tags" id="baseModelTags">
|
||||
|
||||
Reference in New Issue
Block a user