Merge pull request #787 from diodiogod/feat/filter-presets

feat: add filter preset system
This commit is contained in:
pixelpaws
2026-01-29 09:36:44 +08:00
committed by GitHub
4 changed files with 385 additions and 2 deletions

View File

@@ -204,6 +204,9 @@
}, },
"filter": { "filter": {
"title": "Filter Models", "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", "baseModel": "Base Model",
"modelTags": "Tags (Top 20)", "modelTags": "Tags (Top 20)",
"modelTypes": "Model Types", "modelTypes": "Model Types",

View File

@@ -466,6 +466,124 @@
border-color: var(--lora-accent); 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 */ /* Mobile adjustments */
@media (max-width: 768px) { @media (max-width: 768px) {
.search-options-panel, .search-options-panel,

View File

@@ -20,6 +20,7 @@ export class FilterManager {
this.filterButton = document.getElementById('filterButton'); this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount'); this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.tagsLoaded = false; this.tagsLoaded = false;
this.activePreset = null; // Track currently active preset
this.initialize(); this.initialize();
@@ -109,12 +110,35 @@ export class FilterManager {
return; 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 => { 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';
const tagName = tag.tag; const tagName = tag.tag;
tagEl.dataset.tag = tagName; 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>`; 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 // Add click handler to cycle through tri-state filter and automatically apply
tagEl.addEventListener('click', async () => { tagEl.addEventListener('click', async () => {
@@ -376,6 +400,9 @@ export class FilterManager {
this.loadTopTags(); this.loadTopTags();
this.tagsLoaded = true; this.tagsLoaded = true;
} }
// Render presets
this.renderPresets();
} else { } else {
this.closeFilterPanel(); this.closeFilterPanel();
} }
@@ -446,7 +473,7 @@ export class FilterManager {
} }
} }
async applyFilters(showToastNotification = true) { async applyFilters(showToastNotification = true, isPresetApply = false) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
const storageKey = `${this.currentPage}_filters`; const storageKey = `${this.currentPage}_filters`;
@@ -457,6 +484,12 @@ export class FilterManager {
// Update state with current filters // Update state with current filters
pageState.filters = filtersSnapshot; 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 // Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) { if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true); await window.recipeManager.loadRecipes(true);
@@ -492,6 +525,9 @@ export class FilterManager {
} }
async clearFilters() { async clearFilters() {
// Clear active preset
this.activePreset = null;
// Clear all filters // Clear all filters
this.filters = this.initializeFilters({ this.filters = this.initializeFilters({
...this.filters, ...this.filters,
@@ -508,6 +544,7 @@ export class FilterManager {
// Update UI // Update UI
this.updateTagSelections(); this.updateTagSelections();
this.updateActiveFiltersCount(); this.updateActiveFiltersCount();
this.renderPresets(); // Re-render to remove active state
// Remove from local Storage // Remove from local Storage
const storageKey = `${this.currentPage}_filters`; const storageKey = `${this.currentPage}_filters`;
@@ -695,4 +732,220 @@ export class FilterManager {
element.classList.add('exclude'); 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 = `<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);
}
}
} }

View File

@@ -135,6 +135,15 @@
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div> </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"> <div class="filter-section">
<h4>{{ t('header.filter.baseModel') }}</h4> <h4>{{ t('header.filter.baseModel') }}</h4>
<div class="filter-tags" id="baseModelTags"> <div class="filter-tags" id="baseModelTags">