diff --git a/locales/de.json b/locales/de.json
index e2c58159..909fd2fa 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -9,6 +9,7 @@
"back": "Zurück",
"next": "Weiter",
"backToTop": "Nach oben",
+ "add": "Hinzufügen",
"settings": "Einstellungen",
"help": "Hilfe"
},
@@ -204,6 +205,19 @@
},
"filter": {
"title": "Modelle filtern",
+ "presets": "Voreinstellungen",
+ "savePreset": "Aktive Filter als neue Voreinstellung speichern.",
+ "savePresetDisabledActive": "Speichern nicht möglich: Eine Voreinstellung ist bereits aktiv. Ändern Sie die Filter, um eine neue Voreinstellung zu speichern",
+ "savePresetDisabledNoFilters": "Wählen Sie zuerst Filter aus, um als Voreinstellung zu speichern",
+ "savePresetPrompt": "Voreinstellungsname eingeben:",
+ "presetClickTooltip": "Voreinstellung \"{name}\" anwenden",
+ "presetDeleteTooltip": "Voreinstellung löschen",
+ "presetDeleteConfirm": "Voreinstellung \"{name}\" löschen?",
+ "presetDeleteConfirmClick": "Zum Bestätigen erneut klicken",
+ "presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?",
+ "presetNamePlaceholder": "Voreinstellungsname...",
+ "restoreDefaults": "Standard wiederherstellen",
+ "noPresets": "Noch keine Voreinstellungen gespeichert. Filter unten auswählen und auf + klicken zum Speichern",
"baseModel": "Basis-Modell",
"modelTags": "Tags (Top 20)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "Filter gelöscht",
- "noCustomFilterToClear": "Kein benutzerdefinierter Filter zum Löschen"
+ "noCustomFilterToClear": "Kein benutzerdefinierter Filter zum Löschen",
+ "noActiveFilters": "Keine aktiven Filter zum Speichern"
+ },
+ "presets": {
+ "created": "Voreinstellung \"{name}\" erstellt",
+ "deleted": "Voreinstellung \"{name}\" gelöscht",
+ "applied": "Voreinstellung \"{name}\" angewendet",
+ "overwritten": "Voreinstellung \"{name}\" überschrieben",
+ "restored": "Standard-Voreinstellungen wiederhergestellt"
+ },
+ "error": {
+ "presetNameEmpty": "Voreinstellungsname darf nicht leer sein",
+ "presetNameTooLong": "Voreinstellungsname darf maximal {max} Zeichen haben",
+ "presetNameInvalidChars": "Voreinstellungsname enthält ungültige Zeichen",
+ "presetNameExists": "Eine Voreinstellung mit diesem Namen existiert bereits",
+ "maxPresetsReached": "Maximal {max} Voreinstellungen erlaubt. Löschen Sie eine, um weitere hinzuzufügen.",
+ "presetNotFound": "Voreinstellung nicht gefunden",
+ "invalidPreset": "Ungültige Voreinstellungsdaten",
+ "deletePresetFailed": "Fehler beim Löschen der Voreinstellung",
+ "applyPresetFailed": "Fehler beim Anwenden der Voreinstellung"
},
"downloads": {
"imagesCompleted": "Beispielbilder {action} abgeschlossen",
diff --git a/locales/en.json b/locales/en.json
index ac31a237..19de693e 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -10,7 +10,8 @@
"next": "Next",
"backToTop": "Back to top",
"settings": "Settings",
- "help": "Help"
+ "help": "Help",
+ "add": "Add"
},
"status": {
"loading": "Loading...",
@@ -205,7 +206,17 @@
"filter": {
"title": "Filter Models",
"presets": "Presets",
- "savePreset": "Save current active filters as a new preset (first select filters below, then click here)",
+ "savePreset": "Save current active filters as a new preset.",
+ "savePresetDisabledActive": "Cannot save: A preset is already active. Modify filters to save new preset.",
+ "savePresetDisabledNoFilters": "Select filters first to save as preset",
+ "savePresetPrompt": "Enter preset name:",
+ "presetClickTooltip": "Click to apply preset \"{name}\"",
+ "presetDeleteTooltip": "Delete preset",
+ "presetDeleteConfirm": "Delete preset \"{name}\"?",
+ "presetDeleteConfirmClick": "Click again to confirm",
+ "presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?",
+ "presetNamePlaceholder": "Preset name...",
+ "restoreDefaults": "Restore defaults",
"noPresets": "No presets saved yet. Select filters below and click + to save",
"baseModel": "Base Model",
"modelTags": "Tags (Top 20)",
@@ -1422,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "Filters cleared",
- "noCustomFilterToClear": "No custom filter to clear"
+ "noCustomFilterToClear": "No custom filter to clear",
+ "noActiveFilters": "No active filters to save"
+ },
+ "presets": {
+ "created": "Preset \"{name}\" created",
+ "deleted": "Preset \"{name}\" deleted",
+ "applied": "Preset \"{name}\" applied",
+ "overwritten": "Preset \"{name}\" overwritten",
+ "restored": "Default presets restored"
+ },
+ "error": {
+ "presetNameEmpty": "Preset name cannot be empty",
+ "presetNameTooLong": "Preset name must be {max} characters or less",
+ "presetNameInvalidChars": "Preset name contains invalid characters",
+ "presetNameExists": "A preset with this name already exists",
+ "maxPresetsReached": "Maximum {max} presets allowed. Delete one to add more.",
+ "presetNotFound": "Preset not found",
+ "invalidPreset": "Invalid preset data",
+ "deletePresetFailed": "Failed to delete preset",
+ "applyPresetFailed": "Failed to apply preset"
},
"downloads": {
"imagesCompleted": "Example images {action} completed",
diff --git a/locales/es.json b/locales/es.json
index a7373353..76f9366f 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -10,7 +10,8 @@
"next": "Siguiente",
"backToTop": "Volver arriba",
"settings": "Configuración",
- "help": "Ayuda"
+ "help": "Ayuda",
+ "add": "Añadir"
},
"status": {
"loading": "Cargando...",
@@ -204,6 +205,19 @@
},
"filter": {
"title": "Filtrar modelos",
+ "presets": "Preajustes",
+ "savePreset": "Guardar filtros activos como nuevo preajuste.",
+ "savePresetDisabledActive": "No se puede guardar: Ya hay un preajuste activo. Modifique los filtros para guardar un nuevo preajuste",
+ "savePresetDisabledNoFilters": "Seleccione filtros primero para guardar como preajuste",
+ "savePresetPrompt": "Ingrese el nombre del preajuste:",
+ "presetClickTooltip": "Hacer clic para aplicar preajuste \"{name}\"",
+ "presetDeleteTooltip": "Eliminar preajuste",
+ "presetDeleteConfirm": "¿Eliminar preajuste \"{name}\"?",
+ "presetDeleteConfirmClick": "Haga clic de nuevo para confirmar",
+ "presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?",
+ "presetNamePlaceholder": "Nombre del preajuste...",
+ "restoreDefaults": "Restaurar predeterminados",
+ "noPresets": "Aún no hay preajustes guardados. Seleccione filtros abajo y haga clic en + para guardar",
"baseModel": "Modelo base",
"modelTags": "Etiquetas (Top 20)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "Filtros limpiados",
- "noCustomFilterToClear": "No hay filtro personalizado para limpiar"
+ "noCustomFilterToClear": "No hay filtro personalizado para limpiar",
+ "noActiveFilters": "No hay filtros activos para guardar"
+ },
+ "presets": {
+ "created": "Preajuste \"{name}\" creado",
+ "deleted": "Preajuste \"{name}\" eliminado",
+ "applied": "Preajuste \"{name}\" aplicado",
+ "overwritten": "Preset \"{name}\" sobrescrito",
+ "restored": "Presets predeterminados restaurados"
+ },
+ "error": {
+ "presetNameEmpty": "El nombre del preajuste no puede estar vacío",
+ "presetNameTooLong": "El nombre del preajuste debe tener {max} caracteres o menos",
+ "presetNameInvalidChars": "El nombre del preajuste contiene caracteres inválidos",
+ "presetNameExists": "Ya existe un preajuste con este nombre",
+ "maxPresetsReached": "Máximo {max} preajustes permitidos. Elimine uno para agregar más.",
+ "presetNotFound": "Preajuste no encontrado",
+ "invalidPreset": "Datos de preajuste inválidos",
+ "deletePresetFailed": "Error al eliminar el preajuste",
+ "applyPresetFailed": "Error al aplicar el preajuste"
},
"downloads": {
"imagesCompleted": "Imágenes de ejemplo {action} completadas",
diff --git a/locales/fr.json b/locales/fr.json
index 931d049b..ee37339b 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -10,7 +10,8 @@
"next": "Suivant",
"backToTop": "Retour en haut",
"settings": "Paramètres",
- "help": "Aide"
+ "help": "Aide",
+ "add": "Ajouter"
},
"status": {
"loading": "Chargement...",
@@ -204,6 +205,19 @@
},
"filter": {
"title": "Filtrer les modèles",
+ "presets": "Préréglages",
+ "savePreset": "Enregistrer les filtres actifs comme nouveau préréglage.",
+ "savePresetDisabledActive": "Impossible d'enregistrer : Un préréglage est déjà actif. Modifiez les filtres pour enregistrer un nouveau préréglage",
+ "savePresetDisabledNoFilters": "Sélectionnez d'abord des filtres à enregistrer comme préréglage",
+ "savePresetPrompt": "Entrez le nom du préréglage :",
+ "presetClickTooltip": "Cliquer pour appliquer le préréglage \"{name}\"",
+ "presetDeleteTooltip": "Supprimer le préréglage",
+ "presetDeleteConfirm": "Supprimer le préréglage \"{name}\" ?",
+ "presetDeleteConfirmClick": "Cliquez à nouveau pour confirmer",
+ "presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?",
+ "presetNamePlaceholder": "Nom du préréglage...",
+ "restoreDefaults": "Restaurer les paramètres par défaut",
+ "noPresets": "Aucun préréglage enregistré. Sélectionnez des filtres ci-dessous et cliquez sur + pour enregistrer",
"baseModel": "Modèle de base",
"modelTags": "Tags (Top 20)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "Filtres effacés",
- "noCustomFilterToClear": "Aucun filtre personnalisé à effacer"
+ "noCustomFilterToClear": "Aucun filtre personnalisé à effacer",
+ "noActiveFilters": "Aucun filtre actif à enregistrer"
+ },
+ "presets": {
+ "created": "Préréglage \"{name}\" créé",
+ "deleted": "Préréglage \"{name}\" supprimé",
+ "applied": "Préréglage \"{name}\" appliqué",
+ "overwritten": "Préréglage \"{name}\" remplacé",
+ "restored": "Paramètres par défaut restaurés"
+ },
+ "error": {
+ "presetNameEmpty": "Le nom du préréglage ne peut pas être vide",
+ "presetNameTooLong": "Le nom du préréglage doit contenir au maximum {max} caractères",
+ "presetNameInvalidChars": "Le nom du préréglage contient des caractères invalides",
+ "presetNameExists": "Un préréglage avec ce nom existe déjà",
+ "maxPresetsReached": "Maximum {max} préréglages autorisés. Supprimez-en un pour en ajouter plus.",
+ "presetNotFound": "Préréglage non trouvé",
+ "invalidPreset": "Données de préréglage invalides",
+ "deletePresetFailed": "Échec de la suppression du préréglage",
+ "applyPresetFailed": "Échec de l'application du préréglage"
},
"downloads": {
"imagesCompleted": "Images d'exemple {action} terminées",
diff --git a/locales/he.json b/locales/he.json
index ade6ec63..ddf39f10 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -9,6 +9,7 @@
"back": "חזור",
"next": "הבא",
"backToTop": "חזור למעלה",
+ "add": "הוסף",
"settings": "הגדרות",
"help": "עזרה"
},
@@ -204,6 +205,19 @@
},
"filter": {
"title": "סנן מודלים",
+ "presets": "קביעות מראש",
+ "savePreset": "שמור מסננים פעילים כקביעה מראש חדשה.",
+ "savePresetDisabledActive": "לא ניתן לשמור: קביעה מראש כבר פעילה. שנה מסננים כדי לשמור קביעה מראש חדשה",
+ "savePresetDisabledNoFilters": "בחר מסננים תחילה כדי לשמור כקביעה מראש",
+ "savePresetPrompt": "הזן שם קביעה מראש:",
+ "presetClickTooltip": "לחץ כדי להפעיל קביעה מראש \"{name}\"",
+ "presetDeleteTooltip": "מחק קביעה מראש",
+ "presetDeleteConfirm": "למחוק קביעה מראש \"{name}\"?",
+ "presetDeleteConfirmClick": "לחץ שוב לאישור",
+ "presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?",
+ "presetNamePlaceholder": "שם קביעה מראש...",
+ "restoreDefaults": "שחזור ברירות מחדל",
+ "noPresets": "עדיין אין קביעות מראש שמורות. בחר מסננים למטה ולחץ על + כדי לשמור",
"baseModel": "מודל בסיס",
"modelTags": "תגיות (20 המובילות)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "המסננים נוקו",
- "noCustomFilterToClear": "אין מסנן מותאם אישית לניקוי"
+ "noCustomFilterToClear": "אין מסנן מותאם אישית לניקוי",
+ "noActiveFilters": "אין מסננים פעילים לשמירה"
+ },
+ "presets": {
+ "created": "קביעה מראש \"{name}\" נוצרה",
+ "deleted": "קביעה מראש \"{name}\" נמחקה",
+ "applied": "קביעה מראש \"{name}\" הופעלה",
+ "overwritten": "קביעה מראש \"{name}\" נדרסה",
+ "restored": "ברירות המחדל שוחזרו"
+ },
+ "error": {
+ "presetNameEmpty": "שם קביעה מראש לא יכול להיות ריק",
+ "presetNameTooLong": "שם קביעה מראש חייב להיות {max} תווים או פחות",
+ "presetNameInvalidChars": "שם קביעה מראש מכיל תווים לא חוקיים",
+ "presetNameExists": "קביעה מראש עם שם זה כבר קיימת",
+ "maxPresetsReached": "מותר מקסימום {max} קביעות מראש. מחק אחת כדי להוסיף עוד.",
+ "presetNotFound": "קביעה מראש לא נמצאה",
+ "invalidPreset": "נתוני קביעה מראש לא חוקיים",
+ "deletePresetFailed": "מחיקת קביעה מראש נכשלה",
+ "applyPresetFailed": "הפעלת קביעה מראש נכשלה"
},
"downloads": {
"imagesCompleted": "{action} תמונות הדוגמה הושלם",
diff --git a/locales/ja.json b/locales/ja.json
index 52e08474..76ef3f6c 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -10,7 +10,8 @@
"next": "次へ",
"backToTop": "トップに戻る",
"settings": "設定",
- "help": "ヘルプ"
+ "help": "ヘルプ",
+ "add": "追加"
},
"status": {
"loading": "読み込み中...",
@@ -204,6 +205,19 @@
},
"filter": {
"title": "モデルをフィルタ",
+ "presets": "プリセット",
+ "savePreset": "現在のアクティブフィルタを新しいプリセットとして保存。",
+ "savePresetDisabledActive": "保存できません:プリセットがすでにアクティブです。フィルタを変更して新しいプリセットを保存してください",
+ "savePresetDisabledNoFilters": "先にフィルタを選択してからプリセットとして保存",
+ "savePresetPrompt": "プリセット名を入力:",
+ "presetClickTooltip": "プリセット \"{name}\" を適用するにはクリック",
+ "presetDeleteTooltip": "プリセットを削除",
+ "presetDeleteConfirm": "プリセット \"{name}\" を削除しますか?",
+ "presetDeleteConfirmClick": "もう一度クリックして確認",
+ "presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?",
+ "presetNamePlaceholder": "プリセット名...",
+ "restoreDefaults": "デフォルトを復元",
+ "noPresets": "まだプリセットが保存されていません。下のフィルタを選択して+をクリックして保存",
"baseModel": "ベースモデル",
"modelTags": "タグ(上位20)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "フィルタがクリアされました",
- "noCustomFilterToClear": "クリアするカスタムフィルタがありません"
+ "noCustomFilterToClear": "クリアするカスタムフィルタがありません",
+ "noActiveFilters": "保存するアクティブフィルタがありません"
+ },
+ "presets": {
+ "created": "プリセット \"{name}\" が作成されました",
+ "deleted": "プリセット \"{name}\" が削除されました",
+ "applied": "プリセット \"{name}\" が適用されました",
+ "overwritten": "プリセット「{name}」を上書きしました",
+ "restored": "デフォルトのプリセットを復元しました"
+ },
+ "error": {
+ "presetNameEmpty": "プリセット名を入力してください",
+ "presetNameTooLong": "プリセット名は{max}文字以内にしてください",
+ "presetNameInvalidChars": "プリセット名に使用できない文字が含まれています",
+ "presetNameExists": "同じ名前のプリセットが既に存在します",
+ "maxPresetsReached": "プリセットは最大{max}個までです。追加するには既存のものを削除してください。",
+ "presetNotFound": "プリセットが見つかりません",
+ "invalidPreset": "無効なプリセットデータです",
+ "deletePresetFailed": "プリセットの削除に失敗しました",
+ "applyPresetFailed": "プリセットの適用に失敗しました"
},
"downloads": {
"imagesCompleted": "例画像 {action} が完了しました",
diff --git a/locales/ko.json b/locales/ko.json
index d468aa7b..8f3d57d5 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -10,7 +10,8 @@
"next": "다음",
"backToTop": "맨 위로",
"settings": "설정",
- "help": "도움말"
+ "help": "도움말",
+ "add": "추가"
},
"status": {
"loading": "로딩 중...",
@@ -204,6 +205,19 @@
},
"filter": {
"title": "모델 필터",
+ "presets": "프리셋",
+ "savePreset": "현재 활성 필터를 새 프리셋으로 저장.",
+ "savePresetDisabledActive": "저장할 수 없음: 프리셋이 이미 활성화되어 있습니다. 필터를 수정한 후 새 프리셋을 저장하세요",
+ "savePresetDisabledNoFilters": "먼저 필터를 선택한 후 프리셋으로 저장",
+ "savePresetPrompt": "프리셋 이름 입력:",
+ "presetClickTooltip": "프리셋 \"{name}\" 적용하려면 클릭",
+ "presetDeleteTooltip": "프리셋 삭제",
+ "presetDeleteConfirm": "프리셋 \"{name}\" 삭제하시겠습니까?",
+ "presetDeleteConfirmClick": "다시 클릭하여 확인",
+ "presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?",
+ "presetNamePlaceholder": "프리셋 이름...",
+ "restoreDefaults": "기본값 복원",
+ "noPresets": "저장된 프리셋이 없습니다. 아래 필터를 선택하고 +를 클릭하여 저장",
"baseModel": "베이스 모델",
"modelTags": "태그 (상위 20개)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "필터가 지워졌습니다",
- "noCustomFilterToClear": "지울 사용자 정의 필터가 없습니다"
+ "noCustomFilterToClear": "지울 사용자 정의 필터가 없습니다",
+ "noActiveFilters": "저장할 활성 필터가 없습니다"
+ },
+ "presets": {
+ "created": "프리셋 \"{name}\" 생성됨",
+ "deleted": "프리셋 \"{name}\" 삭제됨",
+ "applied": "프리셋 \"{name}\" 적용됨",
+ "overwritten": "프리셋 \"{name}\" 덮어쓰기 완료",
+ "restored": "기본 프리셋 복원 완료"
+ },
+ "error": {
+ "presetNameEmpty": "프리셋 이름을 입력하세요",
+ "presetNameTooLong": "프리셋 이름은 {max}자 이하여야 합니다",
+ "presetNameInvalidChars": "프리셋 이름에 유효하지 않은 문자가 포함되어 있습니다",
+ "presetNameExists": "동일한 이름의 프리셋이 이미 존재합니다",
+ "maxPresetsReached": "최대 {max}개의 프리셋만 허용됩니다. 더 추가하려면 기존 것을 삭제하세요.",
+ "presetNotFound": "프리셋을 찾을 수 없습니다",
+ "invalidPreset": "잘못된 프리셋 데이터입니다",
+ "deletePresetFailed": "프리셋 삭제에 실패했습니다",
+ "applyPresetFailed": "프리셋 적용에 실패했습니다"
},
"downloads": {
"imagesCompleted": "예시 이미지 {action}이(가) 완료되었습니다",
diff --git a/locales/ru.json b/locales/ru.json
index 5db1838e..5e6f35c2 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -10,7 +10,8 @@
"next": "Далее",
"backToTop": "Наверх",
"settings": "Настройки",
- "help": "Справка"
+ "help": "Справка",
+ "add": "Добавить"
},
"status": {
"loading": "Загрузка...",
@@ -204,6 +205,19 @@
},
"filter": {
"title": "Фильтр моделей",
+ "presets": "Пресеты",
+ "savePreset": "Сохранить текущие активные фильтры как новый пресет.",
+ "savePresetDisabledActive": "Невозможно сохранить: Пресет уже активен. Измените фильтры, чтобы сохранить новый пресет",
+ "savePresetDisabledNoFilters": "Сначала выберите фильтры для сохранения как пресет",
+ "savePresetPrompt": "Введите имя пресета:",
+ "presetClickTooltip": "Нажмите чтобы применить пресет \"{name}\"",
+ "presetDeleteTooltip": "Удалить пресет",
+ "presetDeleteConfirm": "Удалить пресет \"{name}\"?",
+ "presetDeleteConfirmClick": "Нажмите еще раз для подтверждения",
+ "presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?",
+ "presetNamePlaceholder": "Имя пресета...",
+ "restoreDefaults": "Восстановить по умолчанию",
+ "noPresets": "Пресеты еще не сохранены. Выберите фильтры ниже и нажмите + для сохранения",
"baseModel": "Базовая модель",
"modelTags": "Теги (Топ 20)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "Фильтры очищены",
- "noCustomFilterToClear": "Нет пользовательского фильтра для очистки"
+ "noCustomFilterToClear": "Нет пользовательского фильтра для очистки",
+ "noActiveFilters": "Нет активных фильтров для сохранения"
+ },
+ "presets": {
+ "created": "Пресет \"{name}\" создан",
+ "deleted": "Пресет \"{name}\" удален",
+ "applied": "Пресет \"{name}\" применен",
+ "overwritten": "Пресет \"{name}\" перезаписан",
+ "restored": "Пресеты по умолчанию восстановлены"
+ },
+ "error": {
+ "presetNameEmpty": "Имя пресета не может быть пустым",
+ "presetNameTooLong": "Имя пресета должно содержать не более {max} символов",
+ "presetNameInvalidChars": "Имя пресета содержит недопустимые символы",
+ "presetNameExists": "Пресет с таким именем уже существует",
+ "maxPresetsReached": "Допустимо максимум {max} пресетов. Удалите один, чтобы добавить больше.",
+ "presetNotFound": "Пресет не найден",
+ "invalidPreset": "Недопустимые данные пресета",
+ "deletePresetFailed": "Не удалось удалить пресет",
+ "applyPresetFailed": "Не удалось применить пресет"
},
"downloads": {
"imagesCompleted": "Примеры изображений {action} завершены",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index c9c04def..a630223b 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -10,7 +10,8 @@
"next": "下一步",
"backToTop": "返回顶部",
"settings": "设置",
- "help": "帮助"
+ "help": "帮助",
+ "add": "添加"
},
"status": {
"loading": "加载中...",
@@ -204,6 +205,19 @@
},
"filter": {
"title": "筛选模型",
+ "presets": "预设",
+ "savePreset": "将当前激活的筛选器保存为新预设。",
+ "savePresetDisabledActive": "无法保存:已有预设处于激活状态。修改筛选器后可保存新预设",
+ "savePresetDisabledNoFilters": "先选择筛选器,然后保存为预设",
+ "savePresetPrompt": "输入预设名称:",
+ "presetClickTooltip": "点击应用预设 \"{name}\"",
+ "presetDeleteTooltip": "删除预设",
+ "presetDeleteConfirm": "删除预设 \"{name}\"?",
+ "presetDeleteConfirmClick": "再次点击确认",
+ "presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?",
+ "presetNamePlaceholder": "预设名称...",
+ "restoreDefaults": "恢复默认",
+ "noPresets": "尚未保存预设。选择下方筛选器并点击 + 保存",
"baseModel": "基础模型",
"modelTags": "标签(前20)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "筛选已清除",
- "noCustomFilterToClear": "没有自定义筛选可清除"
+ "noCustomFilterToClear": "没有自定义筛选可清除",
+ "noActiveFilters": "没有可保存的激活筛选"
+ },
+ "presets": {
+ "created": "预设 \"{name}\" 已创建",
+ "deleted": "预设 \"{name}\" 已删除",
+ "applied": "预设 \"{name}\" 已应用",
+ "overwritten": "预设 \"{name}\" 已覆盖",
+ "restored": "默认预设已恢复"
+ },
+ "error": {
+ "presetNameEmpty": "预设名称不能为空",
+ "presetNameTooLong": "预设名称不能超过 {max} 个字符",
+ "presetNameInvalidChars": "预设名称包含无效字符",
+ "presetNameExists": "已存在同名预设",
+ "maxPresetsReached": "最多允许 {max} 个预设。删除一个以添加更多。",
+ "presetNotFound": "预设未找到",
+ "invalidPreset": "无效的预设数据",
+ "deletePresetFailed": "删除预设失败",
+ "applyPresetFailed": "应用预设失败"
},
"downloads": {
"imagesCompleted": "示例图片{action}完成",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index 47d9069e..dd379abd 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -10,7 +10,8 @@
"next": "下一步",
"backToTop": "回到頂部",
"settings": "設定",
- "help": "說明"
+ "help": "說明",
+ "add": "新增"
},
"status": {
"loading": "載入中...",
@@ -204,6 +205,19 @@
},
"filter": {
"title": "篩選模型",
+ "presets": "預設",
+ "savePreset": "將目前啟用的篩選器儲存為新預設。",
+ "savePresetDisabledActive": "無法儲存:已有預設處於啟用狀態。修改篩選器後可儲存新預設",
+ "savePresetDisabledNoFilters": "先選擇篩選器,然後儲存為預設",
+ "savePresetPrompt": "輸入預設名稱:",
+ "presetClickTooltip": "點擊套用預設 \"{name}\"",
+ "presetDeleteTooltip": "刪除預設",
+ "presetDeleteConfirm": "刪除預設 \"{name}\"?",
+ "presetDeleteConfirmClick": "再次點擊確認",
+ "presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?",
+ "presetNamePlaceholder": "預設名稱...",
+ "restoreDefaults": "恢復預設",
+ "noPresets": "尚未儲存預設。選擇下方篩選器並點擊 + 儲存",
"baseModel": "基礎模型",
"modelTags": "標籤(前 20)",
"modelTypes": "Model Types",
@@ -1419,7 +1433,26 @@
"filters": {
"applied": "{message}",
"cleared": "篩選已清除",
- "noCustomFilterToClear": "無自訂篩選可清除"
+ "noCustomFilterToClear": "無自訂篩選可清除",
+ "noActiveFilters": "沒有可儲存的啟用篩選"
+ },
+ "presets": {
+ "created": "預設 \"{name}\" 已建立",
+ "deleted": "預設 \"{name}\" 已刪除",
+ "applied": "預設 \"{name}\" 已套用",
+ "overwritten": "預設 \"{name}\" 已覆蓋",
+ "restored": "預設設定已恢復"
+ },
+ "error": {
+ "presetNameEmpty": "預設名稱不能為空",
+ "presetNameTooLong": "預設名稱不能超過 {max} 個字元",
+ "presetNameInvalidChars": "預設名稱包含無效字元",
+ "presetNameExists": "已存在同名預設",
+ "maxPresetsReached": "最多允許 {max} 個預設。刪除一個以新增更多。",
+ "presetNotFound": "預設未找到",
+ "invalidPreset": "無效的預設資料",
+ "deletePresetFailed": "刪除預設失敗",
+ "applyPresetFailed": "套用預設失敗"
},
"downloads": {
"imagesCompleted": "範例圖片{action}完成",
diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py
index f0b52251..485b2379 100644
--- a/py/routes/handlers/misc_handlers.py
+++ b/py/routes/handlers/misc_handlers.py
@@ -255,6 +255,7 @@ class SettingsHandler:
"model_name_display",
"update_flag_strategy",
"auto_organize_exclusions",
+ "filter_presets",
)
_PROXY_KEYS = {
diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css
index f177a393..9345b09c 100644
--- a/static/css/components/search-filter.css
+++ b/static/css/components/search-filter.css
@@ -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 {
diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js
index c8624d73..9e2a9d09 100644
--- a/static/js/api/baseModelApi.js
+++ b/static/js/api/baseModelApi.js
@@ -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);
});
diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js
index 5d373c66..62a9e2e1 100644
--- a/static/js/api/recipeApi.js
+++ b/static/js/api/recipeApi.js
@@ -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(','));
}
diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js
index a48858f5..b8aef214 100644
--- a/static/js/managers/FilterManager.js
+++ b/static/js/managers/FilterManager.js
@@ -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 = ` ${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);
- }
+ // Preset management delegation methods for backward compatibility
+ hasEmptyWildcardResult() {
+ return this.presetManager?.hasEmptyWildcardResult() ?? false;
}
}
diff --git a/static/js/managers/FilterPresetManager.js b/static/js/managers/FilterPresetManager.js
new file mode 100644
index 00000000..3bbb5ba6
--- /dev/null
+++ b/static/js/managers/FilterPresetManager.js
@@ -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} 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 = '';
+ 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 = '';
+ 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 = `
+
+
+
+ `;
+
+ 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 = ` ${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 = '';
+ 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 = ` ${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();
+ }
+}