refactor(filter): extract preset management logic into FilterPresetManager

Move filter preset creation, deletion, application, and storage logic
from FilterManager into a dedicated FilterPresetManager class to
improve separation of concerns and maintainability.

- Add FilterPresetManager with preset CRUD operations
- Update FilterManager to use preset manager via composition
- Handle EMPTY_WILDCARD_MARKER for wildcard base model filters
- Add preset-related translations to all locale files
- Update filter preset UI styling and interactions
This commit is contained in:
Will Miao
2026-01-29 16:25:45 +08:00
parent e50b2c802e
commit 08267cdb48
16 changed files with 1383 additions and 247 deletions

View File

@@ -552,26 +552,112 @@
}
.add-preset-btn {
background-color: transparent !important;
border: 1px dashed var(--border-color) !important;
background-color: transparent;
border: 1px dashed var(--border-color);
color: var(--text-color);
opacity: 0.7;
opacity: 0.85;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 14px;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all 0.25s ease;
}
.add-preset-btn:hover {
/* Enabled state - visual cue that button is actionable */
.add-preset-btn:not(.disabled) {
border-color: var(--lora-accent);
border-style: solid;
background-color: rgba(66, 153, 225, 0.08);
}
.add-preset-btn:hover:not(.disabled) {
opacity: 1;
border-color: var(--lora-accent) !important;
background-color: rgba(66, 153, 225, 0.15);
color: var(--lora-accent);
background-color: var(--lora-surface-hover) !important;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(66, 153, 225, 0.2);
}
/* Disabled state - clear "unavailable" visual language */
.add-preset-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
background-color: rgba(128, 128, 128, 0.05);
border-style: dashed;
border-color: var(--border-color);
color: var(--text-muted);
}
.add-preset-btn i {
font-size: 12px;
transition: transform 0.2s ease;
}
/* Inline preset naming input */
.preset-inline-input-container {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px;
background-color: var(--lora-surface);
border: 1px solid var(--lora-accent);
border-radius: var(--border-radius-sm);
}
.preset-inline-input {
width: 120px;
padding: 4px 8px;
border: none;
background: transparent;
color: var(--text-color);
font-size: 13px;
outline: none;
}
.preset-inline-input::placeholder {
color: var(--text-color);
opacity: 0.5;
}
.preset-inline-btn {
background: none;
border: none;
color: var(--text-color);
cursor: pointer;
padding: 4px 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
transition: color 0.2s ease;
opacity: 0.7;
}
.preset-inline-btn:hover {
opacity: 1;
}
.preset-inline-btn.save:hover {
color: var(--lora-accent);
}
.preset-inline-btn.cancel:hover {
color: var(--lora-error, #e74c3c);
}
/* Two-step delete confirmation */
.preset-delete-btn.confirm {
color: var(--lora-accent);
opacity: 1;
animation: pulse-confirm 0.5s ease-in-out infinite alternate;
}
@keyframes pulse-confirm {
from { opacity: 0.7; }
to { opacity: 1; }
}
.no-presets {

View File

@@ -59,6 +59,18 @@ export class BaseModelApiClient {
sort_by: pageState.sortBy
}, pageState);
// If params is null, it means wildcard resolved to no matches - return empty results
if (params === null) {
return {
items: [],
totalItems: 0,
totalPages: 0,
currentPage: page,
hasMore: false,
folders: []
};
}
const response = await fetch(`${this.apiConfig.endpoints.list}?${params}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${this.apiConfig.config.displayName}s: ${response.statusText}`);
@@ -868,6 +880,13 @@ export class BaseModelApiClient {
}
if (pageState.filters.baseModel && pageState.filters.baseModel.length > 0) {
// Check for empty wildcard marker - if present, no models should match
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
if (pageState.filters.baseModel.length === 1 &&
pageState.filters.baseModel[0] === EMPTY_WILDCARD_MARKER) {
// Wildcard resolved to no matches - return empty results
return null; // Signal to return empty results
}
pageState.filters.baseModel.forEach(model => {
params.append('base_model', model);
});

View File

@@ -103,6 +103,19 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
// Add base model filters
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
// Check for empty wildcard marker - if present, no models should match
const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
if (pageState.filters.baseModel.length === 1 &&
pageState.filters.baseModel[0] === EMPTY_WILDCARD_MARKER) {
// Wildcard resolved to no matches - return empty results
return {
items: [],
totalItems: 0,
totalPages: 0,
currentPage: page,
hasMore: false
};
}
params.append('base_models', pageState.filters.baseModel.join(','));
}

View File

@@ -4,6 +4,7 @@ import { getModelApiClient } from '../api/modelApiFactory.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js';
export class FilterManager {
constructor(options = {}) {
@@ -20,7 +21,12 @@ export class FilterManager {
this.filterButton = document.getElementById('filterButton');
this.activeFiltersCount = document.getElementById('activeFiltersCount');
this.tagsLoaded = false;
this.activePreset = null; // Track currently active preset
// Initialize preset manager
this.presetManager = new FilterPresetManager({
page: this.currentPage,
filterManager: this
});
this.initialize();
@@ -31,6 +37,17 @@ export class FilterManager {
}
}
// Accessor for backward compatibility with activePreset
get activePreset() {
return this.presetManager?.activePreset ?? null;
}
set activePreset(value) {
if (this.presetManager) {
this.presetManager.activePreset = value;
}
}
initialize() {
// Create base model filter tags if they exist
if (document.getElementById('baseModelTags')) {
@@ -402,7 +419,7 @@ export class FilterManager {
}
// Render presets
this.renderPresets();
this.presetManager.renderPresets();
} else {
this.closeFilterPanel();
}
@@ -461,7 +478,9 @@ export class FilterManager {
const tagFilterCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const licenseFilterCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeFilterCount = this.filters.modelTypes.length;
const totalActiveFilters = this.filters.baseModel.length + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
// Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
const totalActiveFilters = baseModelCount + tagFilterCount + licenseFilterCount + modelTypeFilterCount;
if (this.activeFiltersCount) {
if (totalActiveFilters > 0) {
@@ -471,23 +490,31 @@ export class FilterManager {
this.activeFiltersCount.style.display = 'none';
}
}
// Update add button state when filters change
if (this.presetManager) {
this.presetManager.updateAddButtonState();
}
}
async applyFilters(showToastNotification = true, isPresetApply = false) {
const pageState = getCurrentPageState();
const storageKey = `${this.currentPage}_filters`;
// Save filters to localStorage
// Save filters to localStorage (exclude EMPTY_WILDCARD_MARKER)
const filtersSnapshot = this.cloneFilters();
// Don't persist EMPTY_WILDCARD_MARKER - it's a runtime-only marker
filtersSnapshot.baseModel = filtersSnapshot.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER);
setStorageItem(storageKey, filtersSnapshot);
// Update state with current filters
pageState.filters = filtersSnapshot;
pageState.filters = this.cloneFilters();
// 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
this.presetManager.saveActivePreset(); // Persist the cleared state
this.presetManager.renderPresets(); // Re-render to remove active state
}
// Call the appropriate manager's load method based on page type
@@ -527,6 +554,7 @@ export class FilterManager {
async clearFilters() {
// Clear active preset
this.activePreset = null;
this.presetManager.saveActivePreset(); // Persist the cleared state
// Clear all filters
this.filters = this.initializeFilters({
@@ -544,7 +572,7 @@ export class FilterManager {
// Update UI
this.updateTagSelections();
this.updateActiveFiltersCount();
this.renderPresets(); // Re-render to remove active state
this.presetManager.renderPresets(); // Re-render to remove active state
// Remove from local Storage
const storageKey = `${this.currentPage}_filters`;
@@ -590,14 +618,19 @@ export class FilterManager {
console.error(`Error loading ${this.currentPage} filters from storage:`, error);
}
}
// Restore active preset after loading filters
this.presetManager.restoreActivePreset();
}
hasActiveFilters() {
const tagCount = this.filters.tags ? Object.keys(this.filters.tags).length : 0;
const licenseCount = this.filters.license ? Object.keys(this.filters.license).length : 0;
const modelTypeCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
return (
this.filters.baseModel.length > 0 ||
baseModelCount > 0 ||
tagCount > 0 ||
licenseCount > 0 ||
modelTypeCount > 0
@@ -733,219 +766,8 @@ export class FilterManager {
}
}
// 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);
}
// Preset management delegation methods for backward compatibility
hasEmptyWildcardResult() {
return this.presetManager?.hasEmptyWildcardResult() ?? false;
}
}

View File

@@ -0,0 +1,868 @@
import { showToast } from '../utils/uiHelpers.js';
import { removeStorageItem, setStorageItem, getStorageItem } from '../utils/storageHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
// Constants for preset management
const PRESETS_STORAGE_VERSION = 'v1';
const MAX_PRESET_NAME_LENGTH = 30;
const MAX_PRESETS_COUNT = 10;
// Marker for when wildcard patterns resolve to no matches
// This ensures we return empty results instead of all models
export const EMPTY_WILDCARD_MARKER = '__EMPTY_WILDCARD_RESULT__';
// Timeout for two-step delete confirmation (ms)
const DELETE_CONFIRM_TIMEOUT = 3000;
export class FilterPresetManager {
constructor(options = {}) {
this.currentPage = options.page || 'loras';
this.filterManager = options.filterManager || null;
this.activePreset = null;
// Race condition fix: track pending preset applications
this.applyPresetAbortController = null;
this.applyPresetRequestId = 0;
// UI state for two-step delete
this.pendingDeletePreset = null;
this.pendingDeleteTimeout = null;
// UI state for inline naming
this.isInlineNamingActive = false;
// Cache for presets to avoid repeated settings lookups
this._presetsCache = null;
}
// Storage key methods (legacy - for migration only)
getPresetsStorageKey() {
return `${this.currentPage}_filter_presets_${PRESETS_STORAGE_VERSION}`;
}
getActivePresetStorageKey() {
return `${this.currentPage}_active_preset`;
}
/**
* Get settings key for filter presets based on current page
*/
getSettingsKey() {
return `filter_presets`;
}
/**
* Get the filter presets object from settings
* Returns an object with page keys (loras, checkpoints, embeddings) containing presets arrays
*/
getPresetsFromSettings() {
const settings = state?.global?.settings;
const presets = settings?.filter_presets;
if (presets && typeof presets === 'object') {
return presets;
}
return {};
}
/**
* Save filter presets to backend settings
*/
async savePresetsToBackend(allPresets) {
try {
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ filter_presets: allPresets })
});
if (!response.ok) {
throw new Error('Failed to save presets to backend');
}
const data = await response.json();
if (data.success === false) {
throw new Error(data.error || 'Failed to save presets to backend');
}
// Update local cache
this._presetsCache = allPresets;
// Update local settings state
if (state?.global?.settings) {
state.global.settings.filter_presets = allPresets;
}
return true;
} catch (error) {
console.error('Error saving presets to backend:', error);
showToast('Failed to save presets to backend', {}, 'error');
return false;
}
}
/**
* Save active preset name to localStorage
* Note: This is UI state only, not persisted to backend
*/
saveActivePreset() {
const key = this.getActivePresetStorageKey();
if (this.activePreset) {
setStorageItem(key, this.activePreset);
} else {
removeStorageItem(key);
}
}
/**
* Restore active preset from localStorage
* Note: This is UI state only, not synced from backend
*/
restoreActivePreset() {
const key = this.getActivePresetStorageKey();
const savedPresetName = getStorageItem(key);
if (savedPresetName) {
// Verify the preset still exists
const presets = this.loadPresets();
const preset = presets.find(p => p.name === savedPresetName);
if (preset) {
this.activePreset = savedPresetName;
} else {
// Preset no longer exists, clear the saved value
this.activePreset = null;
this.saveActivePreset();
}
}
}
/**
* Migrate presets from localStorage to backend settings
*/
async migratePresetsFromLocalStorage() {
const legacyKey = this.getPresetsStorageKey();
const legacyPresets = getStorageItem(legacyKey);
if (!legacyPresets || !Array.isArray(legacyPresets) || legacyPresets.length === 0) {
return false;
}
// Check if we already have presets in backend for this page
const allPresets = this.getPresetsFromSettings();
if (allPresets[this.currentPage] && allPresets[this.currentPage].length > 0) {
// Already migrated, clear localStorage
removeStorageItem(legacyKey);
return false;
}
// Migrate to backend
const validPresets = legacyPresets.filter(preset => {
if (!preset || typeof preset !== 'object') return false;
if (!preset.name || typeof preset.name !== 'string') return false;
if (!preset.filters || typeof preset.filters !== 'object') return false;
return true;
});
if (validPresets.length > 0) {
allPresets[this.currentPage] = validPresets;
const success = await this.savePresetsToBackend(allPresets);
if (success) {
removeStorageItem(legacyKey);
console.log(`Migrated ${validPresets.length} presets from localStorage to backend`);
}
return success;
}
return false;
}
loadPresets() {
// Get presets from settings
const allPresets = this.getPresetsFromSettings();
let presets = allPresets[this.currentPage];
// Fallback to localStorage if no presets in settings (migration)
if (!presets) {
const legacyKey = this.getPresetsStorageKey();
presets = getStorageItem(legacyKey);
// Trigger async migration
if (presets && Array.isArray(presets) && presets.length > 0) {
this.migratePresetsFromLocalStorage();
}
}
if (!presets) {
if (this.currentPage === 'loras') {
return this.getDefaultPresets();
}
return [];
}
if (!Array.isArray(presets)) {
console.warn('Invalid presets data format: expected array');
return [];
}
const validPresets = presets.filter(preset => {
if (!preset || typeof preset !== 'object') return false;
if (!preset.name || typeof preset.name !== 'string') return false;
if (!preset.filters || typeof preset.filters !== 'object') return false;
return true;
});
if (validPresets.length === 0 && this.currentPage === 'loras') {
return this.getDefaultPresets();
}
return validPresets;
}
getDefaultPresets() {
return [
{
name: "WAN Models",
filters: {
baseModel: ["Wan Video*"],
tags: {},
license: {},
modelTypes: []
},
createdAt: Date.now(),
isDefault: true
}
];
}
/**
* Resolve base model patterns to actual available models
* Supports exact matches and wildcard patterns (ending with *)
*
* @param {Array} patterns - Array of base model patterns
* @param {AbortSignal} signal - Optional abort signal for cancellation
* @returns {Promise<Array>} Resolved base model names
*/
async resolveBaseModelPatterns(patterns, signal = null) {
if (!patterns || patterns.length === 0) return [];
const hasWildcards = patterns.some(p => p.endsWith('*'));
try {
const fetchOptions = signal ? { signal } : {};
const response = await fetch(`/api/lm/${this.currentPage}/base-models`, fetchOptions);
if (!response.ok) throw new Error('Failed to fetch base models');
const data = await response.json();
if (!data.success || !Array.isArray(data.base_models)) {
const nonWildcards = patterns.filter(p => !p.endsWith('*'));
if (hasWildcards && nonWildcards.length === 0) {
return [EMPTY_WILDCARD_MARKER];
}
return nonWildcards;
}
const availableModels = data.base_models.map(m => m.name);
const resolvedModels = [];
for (const pattern of patterns) {
if (pattern.endsWith('*')) {
const prefix = pattern.slice(0, -1);
const matches = availableModels.filter(model =>
model.startsWith(prefix)
);
resolvedModels.push(...matches);
} else {
if (availableModels.includes(pattern)) {
resolvedModels.push(pattern);
}
}
}
const uniqueModels = [...new Set(resolvedModels)];
if (hasWildcards && uniqueModels.length === 0) {
return [EMPTY_WILDCARD_MARKER];
}
return uniqueModels;
} catch (error) {
// Rethrow abort errors so they can be handled properly
if (error.name === 'AbortError') {
throw error;
}
console.warn('Error resolving base model patterns:', error);
const nonWildcards = patterns.filter(p => !p.endsWith('*'));
if (hasWildcards && nonWildcards.length === 0) {
return [EMPTY_WILDCARD_MARKER];
}
return nonWildcards;
}
}
/**
* Check if the base model filter represents an empty wildcard result
*/
hasEmptyWildcardResult() {
const filters = this.filterManager?.filters;
return filters?.baseModel?.length === 1 &&
filters.baseModel[0] === EMPTY_WILDCARD_MARKER;
}
async savePresets(presets) {
const allPresets = this.getPresetsFromSettings();
allPresets[this.currentPage] = presets;
await this.savePresetsToBackend(allPresets);
}
validatePresetName(name) {
if (!name || !name.trim()) {
return { valid: false, message: translate('toast.error.presetNameEmpty', {}, 'Preset name cannot be empty') };
}
const trimmedName = name.trim();
if (trimmedName.length > MAX_PRESET_NAME_LENGTH) {
return {
valid: false,
message: translate('toast.error.presetNameTooLong', { max: MAX_PRESET_NAME_LENGTH }, `Preset name must be ${MAX_PRESET_NAME_LENGTH} characters or less`)
};
}
const htmlSpecialChars = /[<>'&]/;
if (htmlSpecialChars.test(trimmedName)) {
return { valid: false, message: translate('toast.error.presetNameInvalidChars', {}, 'Preset name contains invalid characters') };
}
const controlChars = /[\x00-\x1F\x7F-\x9F]/;
if (controlChars.test(trimmedName)) {
return { valid: false, message: translate('toast.error.presetNameInvalidChars', {}, 'Preset name contains invalid characters') };
}
return { valid: true, name: trimmedName };
}
async createPreset(name, options = {}) {
const validation = this.validatePresetName(name);
if (!validation.valid) {
showToast(validation.message, {}, 'error');
return false;
}
const trimmedName = validation.name;
let presets = this.loadPresets();
const existingIndex = presets.findIndex(p => p.name.toLowerCase() === trimmedName.toLowerCase());
const isDuplicate = existingIndex !== -1;
if (isDuplicate) {
if (options.overwrite) {
presets[existingIndex] = {
name: trimmedName,
filters: this.filterManager.cloneFilters(),
createdAt: Date.now()
};
await this.savePresets(presets);
this.renderPresets();
showToast(
translate('toast.presets.overwritten', { name: trimmedName }, `Preset "${trimmedName}" overwritten`),
{},
'success'
);
return true;
} else {
const confirmMsg = translate('header.filter.presetOverwriteConfirm', { name: trimmedName }, `Preset "${trimmedName}" already exists. Overwrite?`);
if (confirm(confirmMsg)) {
return this.createPreset(name, { overwrite: true });
}
return false;
}
}
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return false;
}
const preset = {
name: trimmedName,
filters: this.filterManager.cloneFilters(),
createdAt: Date.now()
};
presets.push(preset);
await this.savePresets(presets);
// Auto-activate the newly created preset
this.activePreset = trimmedName;
this.saveActivePreset();
this.renderPresets();
showToast(
translate('toast.presets.created', { name: trimmedName }, `Preset "${trimmedName}" created`),
{},
'success'
);
return true;
}
async deletePreset(name) {
try {
let presets = this.loadPresets();
const filtered = presets.filter(p => p.name !== name);
if (filtered.length === 0) {
const allPresets = this.getPresetsFromSettings();
delete allPresets[this.currentPage];
await this.savePresetsToBackend(allPresets);
} else {
await this.savePresets(filtered);
}
if (this.activePreset === name) {
this.activePreset = null;
this.saveActivePreset();
}
this.renderPresets();
showToast(
translate('toast.presets.deleted', { name }, `Preset "${name}" deleted`),
{},
'success'
);
} catch (error) {
console.error('Error deleting preset:', error);
showToast(translate('toast.error.deletePresetFailed', {}, 'Failed to delete preset'), {}, 'error');
}
}
/**
* Apply a preset with race condition protection
* Cancels any pending preset application before starting a new one
*/
async applyPreset(name) {
// Cancel any pending preset application
if (this.applyPresetAbortController) {
this.applyPresetAbortController.abort();
}
this.applyPresetAbortController = new AbortController();
const signal = this.applyPresetAbortController.signal;
const requestId = ++this.applyPresetRequestId;
try {
const presets = this.loadPresets();
const preset = presets.find(p => p.name === name);
if (!preset) {
showToast(translate('toast.error.presetNotFound', {}, 'Preset not found'), {}, 'error');
return;
}
if (!preset.filters || typeof preset.filters !== 'object') {
showToast(translate('toast.error.invalidPreset', {}, 'Invalid preset data'), {}, 'error');
return;
}
// Check if aborted before expensive operations
if (signal.aborted) return;
// Resolve base model patterns (supports wildcards for default presets)
const resolvedBaseModels = await this.resolveBaseModelPatterns(
preset.filters.baseModel,
signal
);
// Check if request is still valid (another preset may have been selected)
if (requestId !== this.applyPresetRequestId) return;
if (signal.aborted) return;
// Set active preset AFTER successful resolution
this.activePreset = name;
this.saveActivePreset();
// Apply the preset filters with resolved base models
this.filterManager.filters = this.filterManager.initializeFilters({
...preset.filters,
baseModel: resolvedBaseModels
});
// Update state
const { getCurrentPageState } = await import('../state/index.js');
const pageState = getCurrentPageState();
pageState.filters = this.filterManager.cloneFilters();
// If tags haven't been loaded yet, load them first
if (!this.filterManager.tagsLoaded) {
await this.filterManager.loadTopTags();
this.filterManager.tagsLoaded = true;
}
// Check again after async operation
if (requestId !== this.applyPresetRequestId) return;
// Update UI
this.filterManager.updateTagSelections();
this.filterManager.updateActiveFiltersCount();
this.renderPresets();
// Apply filters (pass true for isPresetApply so it doesn't clear activePreset)
await this.filterManager.applyFilters(false, true);
showToast(
translate('toast.presets.applied', { name }, `Preset "${name}" applied`),
{},
'success'
);
} catch (error) {
// Silently handle abort errors
if (error.name === 'AbortError') return;
console.error('Error applying preset:', error);
showToast(translate('toast.error.applyPresetFailed', {}, 'Failed to apply preset'), {}, 'error');
}
}
hasUserCreatedPresets() {
// Check in settings first
const allPresets = this.getPresetsFromSettings();
const presets = allPresets[this.currentPage];
if (presets && Array.isArray(presets) && presets.length > 0) {
return true;
}
// Fallback to localStorage
const presetsKey = this.getPresetsStorageKey();
const localPresets = getStorageItem(presetsKey);
return Array.isArray(localPresets) && localPresets.length > 0;
}
async restoreDefaultPresets() {
const defaultPresets = this.getDefaultPresets();
await this.savePresets(defaultPresets);
this.renderPresets();
showToast(
translate('toast.presets.restored', {}, 'Default presets restored'),
{},
'success'
);
}
/**
* Check if the add button should be disabled
* Returns true if no filters are active OR a preset is already active
*/
shouldDisableAddButton() {
return !this.filterManager?.hasActiveFilters() || this.activePreset !== null;
}
/**
* Update the add button's disabled state
*/
updateAddButtonState() {
const addBtn = document.querySelector('.add-preset-btn');
if (!addBtn) return;
const shouldDisable = this.shouldDisableAddButton();
if (shouldDisable) {
addBtn.classList.add('disabled');
// Update tooltip to explain why it's disabled
if (this.activePreset) {
addBtn.title = translate('header.filter.savePresetDisabledActive', {}, 'Cannot save: A preset is already active. Clear filters to save new preset.');
} else {
addBtn.title = translate('header.filter.savePresetDisabledNoFilters', {}, 'Select filters first to save as preset');
}
} else {
addBtn.classList.remove('disabled');
addBtn.title = translate('header.filter.savePreset', {}, 'Save current filters as a new preset');
}
}
/**
* Initiate two-step delete process
*/
initiateDelete(presetName, deleteBtn) {
// If already pending for this preset, execute the delete
if (this.pendingDeletePreset === presetName) {
this.cancelPendingDelete();
this.deletePreset(presetName);
return;
}
// Cancel any previous pending delete
this.cancelPendingDelete();
// Set up new pending delete
this.pendingDeletePreset = presetName;
deleteBtn.classList.add('confirm');
deleteBtn.innerHTML = '<i class="fas fa-check"></i>';
deleteBtn.title = translate('header.filter.presetDeleteConfirmClick', {}, 'Click again to confirm');
// Auto-cancel after timeout
this.pendingDeleteTimeout = setTimeout(() => {
this.cancelPendingDelete();
}, DELETE_CONFIRM_TIMEOUT);
}
/**
* Cancel pending delete operation
*/
cancelPendingDelete() {
if (this.pendingDeleteTimeout) {
clearTimeout(this.pendingDeleteTimeout);
this.pendingDeleteTimeout = null;
}
if (this.pendingDeletePreset) {
// Reset all delete buttons to normal state
const deleteBtns = document.querySelectorAll('.preset-delete-btn.confirm');
deleteBtns.forEach(btn => {
btn.classList.remove('confirm');
btn.innerHTML = '<i class="fas fa-times"></i>';
btn.title = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
});
this.pendingDeletePreset = null;
}
}
/**
* Show inline input for preset naming
*/
showInlineNamingInput() {
if (this.isInlineNamingActive) return;
// Check if there are any active filters
if (!this.filterManager?.hasActiveFilters()) {
showToast(translate('toast.filters.noActiveFilters', {}, 'No active filters to save'), {}, 'info');
return;
}
// Check max presets limit before showing input
const presets = this.loadPresets();
if (presets.length >= MAX_PRESETS_COUNT) {
showToast(
translate('toast.error.maxPresetsReached', { max: MAX_PRESETS_COUNT }, `Maximum ${MAX_PRESETS_COUNT} presets allowed. Delete one to add more.`),
{},
'error'
);
return;
}
this.isInlineNamingActive = true;
const presetsContainer = document.getElementById('filterPresets');
if (!presetsContainer) return;
// Find the add button and hide it
const addBtn = presetsContainer.querySelector('.add-preset-btn');
if (addBtn) {
addBtn.style.display = 'none';
}
// Create inline input container
const inputContainer = document.createElement('div');
inputContainer.className = 'preset-inline-input-container';
inputContainer.innerHTML = `
<input type="text"
class="preset-inline-input"
placeholder="${translate('header.filter.presetNamePlaceholder', {}, 'Preset name...')}"
maxlength="${MAX_PRESET_NAME_LENGTH}">
<button class="preset-inline-btn save" title="${translate('common.actions.save', {}, 'Save')}">
<i class="fas fa-check"></i>
</button>
<button class="preset-inline-btn cancel" title="${translate('common.actions.cancel', {}, 'Cancel')}">
<i class="fas fa-times"></i>
</button>
`;
presetsContainer.appendChild(inputContainer);
const input = inputContainer.querySelector('.preset-inline-input');
const saveBtn = inputContainer.querySelector('.preset-inline-btn.save');
const cancelBtn = inputContainer.querySelector('.preset-inline-btn.cancel');
// Focus input
input.focus();
// Handle save
const handleSave = async () => {
const name = input.value;
if (await this.createPreset(name)) {
this.hideInlineNamingInput();
}
};
// Handle cancel
const handleCancel = () => {
this.hideInlineNamingInput();
};
// Event listeners
saveBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleSave();
});
cancelBtn.addEventListener('click', (e) => {
e.stopPropagation();
handleCancel();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
});
// Prevent clicks inside from bubbling
inputContainer.addEventListener('click', (e) => {
e.stopPropagation();
});
}
/**
* Hide inline input and restore add button
*/
hideInlineNamingInput() {
this.isInlineNamingActive = false;
const presetsContainer = document.getElementById('filterPresets');
if (!presetsContainer) return;
// Remove input container
const inputContainer = presetsContainer.querySelector('.preset-inline-input-container');
if (inputContainer) {
inputContainer.remove();
}
// Show add button
const addBtn = presetsContainer.querySelector('.add-preset-btn');
if (addBtn) {
addBtn.style.display = '';
}
}
renderPresets() {
const presetsContainer = document.getElementById('filterPresets');
if (!presetsContainer) return;
// Cancel any pending delete when re-rendering
this.cancelPendingDelete();
this.isInlineNamingActive = false;
const presets = this.loadPresets();
presetsContainer.innerHTML = '';
// Show empty state with restore option if no presets
if (presets.length === 0) {
const emptyState = document.createElement('div');
emptyState.className = 'presets-empty-state';
emptyState.style.cssText = 'width: 100%; padding: 12px; text-align: center;';
const noPresetsMsg = document.createElement('div');
noPresetsMsg.className = 'no-presets';
noPresetsMsg.style.cssText = 'margin-bottom: 8px;';
noPresetsMsg.textContent = translate('header.filter.noPresets', {}, 'No presets saved yet. Select filters below and click + to save');
const restoreLink = document.createElement('button');
restoreLink.className = 'restore-defaults-btn';
restoreLink.style.cssText = 'background: none; border: none; color: var(--lora-accent); cursor: pointer; font-size: 13px; text-decoration: underline; padding: 4px 8px;';
restoreLink.textContent = translate('header.filter.restoreDefaults', {}, 'Restore defaults');
restoreLink.addEventListener('click', (e) => {
e.stopPropagation();
this.restoreDefaultPresets();
});
emptyState.appendChild(noPresetsMsg);
emptyState.appendChild(restoreLink);
presetsContainer.appendChild(emptyState);
}
// Render existing presets
presets.forEach(preset => {
const presetEl = document.createElement('div');
presetEl.className = 'filter-preset';
const isActive = this.activePreset === preset.name;
if (isActive) {
presetEl.classList.add('active');
}
presetEl.addEventListener('click', (e) => {
e.stopPropagation();
});
const presetName = document.createElement('span');
presetName.className = 'preset-name';
if (isActive) {
presetName.innerHTML = `<i class="fas fa-check"></i> ${preset.name}`;
} else {
presetName.textContent = preset.name;
}
presetName.title = translate('header.filter.presetClickTooltip', { name: preset.name }, `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 = translate('header.filter.presetDeleteTooltip', {}, 'Delete preset');
// Apply preset on name click (toggle if already active)
presetName.addEventListener('click', async (e) => {
e.stopPropagation();
this.cancelPendingDelete();
if (this.activePreset === preset.name) {
await this.filterManager.clearFilters();
} else {
await this.applyPreset(preset.name);
}
});
// Two-step delete on delete button click
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.initiateDelete(preset.name, deleteBtn);
});
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> ${translate('common.actions.add', {}, 'Add')}`;
addBtn.title = translate('header.filter.savePreset', {}, 'Save current filters as a new preset');
addBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.cancelPendingDelete();
this.showInlineNamingInput();
});
presetsContainer.appendChild(addBtn);
// Update add button state
this.updateAddButtonState();
}
/**
* Legacy method for backward compatibility
* @deprecated Use showInlineNamingInput instead
*/
showSavePresetDialog() {
this.showInlineNamingInput();
}
}