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(); + } +}