diff --git a/locales/de.json b/locales/de.json index 79b2de11..8584a12d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Voreinstellung \"{name}\" existiert bereits. Überschreiben?", "presetNamePlaceholder": "Voreinstellungsname...", "baseModel": "Basis-Modell", + "baseModelSearchPlaceholder": "Basismodelle durchsuchen...", "modelTags": "Tags (Top 20)", "modelTypes": "Modelltypen", "license": "Lizenz", "noCreditRequired": "Kein Credit erforderlich", "allowSellingGeneratedContent": "Verkauf erlaubt", "noTags": "Keine Tags", + "noBaseModelMatches": "Keine Basismodelle entsprechen der aktuellen Suche.", "clearAll": "Alle Filter löschen", "any": "Beliebig", "all": "Alle", diff --git a/locales/en.json b/locales/en.json index c2382e5a..e4272a48 100644 --- a/locales/en.json +++ b/locales/en.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Preset \"{name}\" already exists. Overwrite?", "presetNamePlaceholder": "Preset name...", "baseModel": "Base Model", + "baseModelSearchPlaceholder": "Search base models...", "modelTags": "Tags (Top 20)", "modelTypes": "Model Types", "license": "License", "noCreditRequired": "No Credit Required", "allowSellingGeneratedContent": "Allow Selling", "noTags": "No tags", + "noBaseModelMatches": "No base models match the current search.", "clearAll": "Clear All Filters", "any": "Any", "all": "All", diff --git a/locales/es.json b/locales/es.json index a49b704b..45ba1491 100644 --- a/locales/es.json +++ b/locales/es.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "El preset \"{name}\" ya existe. ¿Sobrescribir?", "presetNamePlaceholder": "Nombre del preajuste...", "baseModel": "Modelo base", + "baseModelSearchPlaceholder": "Buscar modelos base...", "modelTags": "Etiquetas (Top 20)", "modelTypes": "Tipos de modelos", "license": "Licencia", "noCreditRequired": "Sin crédito requerido", "allowSellingGeneratedContent": "Venta permitida", "noTags": "Sin etiquetas", + "noBaseModelMatches": "Ningún modelo base coincide con la búsqueda actual.", "clearAll": "Limpiar todos los filtros", "any": "Cualquiera", "all": "Todos", diff --git a/locales/fr.json b/locales/fr.json index d809f4bb..15ac157c 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Le préréglage \"{name}\" existe déjà. Remplacer?", "presetNamePlaceholder": "Nom du préréglage...", "baseModel": "Modèle de base", + "baseModelSearchPlaceholder": "Rechercher des modèles de base...", "modelTags": "Tags (Top 20)", "modelTypes": "Types de modèles", "license": "Licence", "noCreditRequired": "Crédit non requis", "allowSellingGeneratedContent": "Vente autorisée", "noTags": "Aucun tag", + "noBaseModelMatches": "Aucun modèle de base ne correspond à la recherche actuelle.", "clearAll": "Effacer tous les filtres", "any": "N'importe quel", "all": "Tous", diff --git a/locales/he.json b/locales/he.json index 94a7131f..8dfdab61 100644 --- a/locales/he.json +++ b/locales/he.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "הפריסט \"{name}\" כבר קיים. לדרוס?", "presetNamePlaceholder": "שם קביעה מראש...", "baseModel": "מודל בסיס", + "baseModelSearchPlaceholder": "חפש מודלי בסיס...", "modelTags": "תגיות (20 המובילות)", "modelTypes": "סוגי מודלים", "license": "רישיון", "noCreditRequired": "ללא קרדיט נדרש", "allowSellingGeneratedContent": "אפשר מכירה", "noTags": "ללא תגיות", + "noBaseModelMatches": "אין מודלי בסיס התואמים לחיפוש הנוכחי.", "clearAll": "נקה את כל המסננים", "any": "כלשהו", "all": "כל התגים", diff --git a/locales/ja.json b/locales/ja.json index d5a04e21..e1d4448d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "プリセット「{name}」は既に存在します。上書きしますか?", "presetNamePlaceholder": "プリセット名...", "baseModel": "ベースモデル", + "baseModelSearchPlaceholder": "ベースモデルを検索...", "modelTags": "タグ(上位20)", "modelTypes": "モデルタイプ", "license": "ライセンス", "noCreditRequired": "クレジット不要", "allowSellingGeneratedContent": "販売許可", "noTags": "タグなし", + "noBaseModelMatches": "現在の検索に一致するベースモデルはありません。", "clearAll": "すべてのフィルタをクリア", "any": "いずれか", "all": "すべて", diff --git a/locales/ko.json b/locales/ko.json index 15bfda7b..5a3f2ec0 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "프리셋 \"{name}\"이(가) 이미 존재합니다. 덮어쓰시겠습니까?", "presetNamePlaceholder": "프리셋 이름...", "baseModel": "베이스 모델", + "baseModelSearchPlaceholder": "베이스 모델 검색...", "modelTags": "태그 (상위 20개)", "modelTypes": "모델 유형", "license": "라이선스", "noCreditRequired": "크레딧 표기 없음", "allowSellingGeneratedContent": "판매 허용", "noTags": "태그 없음", + "noBaseModelMatches": "현재 검색과 일치하는 베이스 모델이 없습니다.", "clearAll": "모든 필터 지우기", "any": "아무", "all": "모두", diff --git a/locales/ru.json b/locales/ru.json index 966993d8..cf0612fe 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "Пресет \"{name}\" уже существует. Перезаписать?", "presetNamePlaceholder": "Имя пресета...", "baseModel": "Базовая модель", + "baseModelSearchPlaceholder": "Поиск базовых моделей...", "modelTags": "Теги (Топ 20)", "modelTypes": "Типы моделей", "license": "Лицензия", "noCreditRequired": "Без указания авторства", "allowSellingGeneratedContent": "Продажа разрешена", "noTags": "Без тегов", + "noBaseModelMatches": "Нет базовых моделей, соответствующих текущему поиску.", "clearAll": "Очистить все фильтры", "any": "Любой", "all": "Все", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b8a79922..898f550a 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "预设 \"{name}\" 已存在。是否覆盖?", "presetNamePlaceholder": "预设名称...", "baseModel": "基础模型", + "baseModelSearchPlaceholder": "搜索基础模型...", "modelTags": "标签(前20)", "modelTypes": "模型类型", "license": "许可证", "noCreditRequired": "无需署名", "allowSellingGeneratedContent": "允许销售", "noTags": "无标签", + "noBaseModelMatches": "没有基础模型符合当前搜索。", "clearAll": "清除所有筛选", "any": "任一", "all": "全部", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index b75e68d1..ef4077ba 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -225,12 +225,14 @@ "presetOverwriteConfirm": "預設 \"{name}\" 已存在。是否覆蓋?", "presetNamePlaceholder": "預設名稱...", "baseModel": "基礎模型", + "baseModelSearchPlaceholder": "搜尋基礎模型...", "modelTags": "標籤(前 20)", "modelTypes": "模型類型", "license": "授權", "noCreditRequired": "無需署名", "allowSellingGeneratedContent": "允許銷售", "noTags": "無標籤", + "noBaseModelMatches": "沒有基礎模型符合目前的搜尋。", "clearAll": "清除所有篩選", "any": "任一", "all": "全部", diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index c5b2babd..18bb5e1b 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -910,7 +910,7 @@ class ModelQueryHandler: async def get_base_models(self, request: web.Request) -> web.Response: try: limit = int(request.query.get("limit", "20")) - if limit < 1 or limit > 100: + if limit < 0 or limit > 100: limit = 20 base_models = await self._service.get_base_models(limit) return web.json_response({"success": True, "base_models": base_models}) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 5b477115..40f703a3 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -329,6 +329,7 @@ class RecipeQueryHandler: if recipe_scanner is None: raise RuntimeError("Recipe scanner unavailable") + limit = int(request.query.get("limit", "20")) cache = await recipe_scanner.get_cached_data() base_model_counts: Dict[str, int] = {} @@ -344,6 +345,8 @@ class RecipeQueryHandler: for model, count in base_model_counts.items() ] sorted_models.sort(key=lambda entry: entry["count"], reverse=True) + if limit > 0: + sorted_models = sorted_models[:limit] return web.json_response({"success": True, "base_models": sorted_models}) except Exception as exc: self._logger.error("Error retrieving base models: %s", exc, exc_info=True) diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index a364214c..8330c4b9 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -1535,7 +1535,7 @@ class ModelScanner: return sorted_tags[:limit] async def get_base_models(self, limit: int = 20) -> List[Dict[str, any]]: - """Get base models sorted by frequency""" + """Get base models sorted by count. If limit is 0, return all.""" cache = await self.get_cached_data() base_model_counts = {} @@ -1546,7 +1546,9 @@ class ModelScanner: sorted_models = [{'name': model, 'count': count} for model, count in base_model_counts.items()] sorted_models.sort(key=lambda x: x['count'], reverse=True) - + + if limit == 0: + return sorted_models return sorted_models[:limit] async def get_model_info_by_name(self, name): diff --git a/static/css/components/search-filter.css b/static/css/components/search-filter.css index b5ddee4a..beed5b74 100644 --- a/static/css/components/search-filter.css +++ b/static/css/components/search-filter.css @@ -145,7 +145,7 @@ position: fixed; right: 20px; top: 50px; /* Position below header */ - width: 320px; + width: 366px; background-color: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--border-radius-base); @@ -197,6 +197,31 @@ margin-bottom: 16px; } +.filter-search-input { + width: 100%; + box-sizing: border-box; + margin-bottom: 8px; + padding: 8px 10px; + border-radius: var(--border-radius-sm); + border: 1px solid var(--border-color); + background-color: var(--lora-surface); + color: var(--text-color); + font-size: 13px; +} + +.filter-search-input:focus { + outline: none; + border-color: var(--lora-accent); + box-shadow: 0 0 0 2px rgba(var(--lora-accent-rgb, 76, 175, 80), 0.15); +} + +.filter-empty-state { + margin-top: 8px; + font-size: 13px; + color: var(--text-color); + opacity: 0.7; +} + .filter-section h4 { margin: 0 0 8px 0; font-size: 14px; @@ -733,4 +758,4 @@ right: 20px; top: 160px; /* Adjusted for mobile layout */ } -} \ No newline at end of file +} diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index cebe057b..fa44914d 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -240,9 +240,7 @@ export class BulkManager { */ handleGlobalKeyboard(e) { // Skip if modal is open (handled by event manager conditions) - // Skip if search input is focused - const searchInput = document.getElementById('searchInput'); - if (searchInput && document.activeElement === searchInput) { + if (this.isEditingTextInputContext(e.target)) { return false; // Don't handle, allow default behavior } @@ -266,6 +264,26 @@ export class BulkManager { return false; // Continue with other handlers } + isEditingTextInputContext(target) { + const activeElement = document.activeElement; + const candidate = target instanceof Element ? target : activeElement; + if (!candidate) { + return false; + } + + const tagName = candidate.tagName?.toLowerCase(); + if ( + candidate.isContentEditable + || tagName === 'input' + || tagName === 'textarea' + || tagName === 'select' + ) { + return true; + } + + return Boolean(candidate.closest?.('#filterPanel')); + } + toggleBulkMode() { state.bulkMode = !state.bulkMode; diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 1b62eab1..1b8956c8 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -20,6 +20,8 @@ export class FilterManager { this.filterPanel = document.getElementById('filterPanel'); this.filterButton = document.getElementById('filterButton'); this.activeFiltersCount = document.getElementById('activeFiltersCount'); + this.baseModelSearchInput = document.getElementById('baseModelSearchInput'); + this.baseModelOptions = []; this.tagsLoaded = false; // Initialize preset manager @@ -49,6 +51,8 @@ export class FilterManager { } initialize() { + this.initializeFilterSearchInputs(); + // Create base model filter tags if they exist if (document.getElementById('baseModelTags')) { this.createBaseModelTags(); @@ -110,6 +114,18 @@ export class FilterManager { this.updateTagLogicToggleUI(); } + initializeFilterSearchInputs() { + if (this.baseModelSearchInput) { + this.baseModelSearchInput.addEventListener('input', () => { + this.renderBaseModelTags(); + }); + } + } + + getNormalizedSearchQuery(input) { + return (input?.value || '').trim().toLowerCase(); + } + updateTagLogicToggleUI() { const toggleContainer = document.getElementById('tagLogicToggle'); if (!toggleContainer) return; @@ -164,11 +180,6 @@ export class FilterManager { tagsContainer.innerHTML = ''; - if (!tags.length) { - tagsContainer.innerHTML = `
`; - return; - } - // Collect existing tag names from the API response const existingTagNames = new Set(tags.map(t => t.tag)); @@ -186,6 +197,11 @@ export class FilterManager { }); } + if (!tags.length) { + tagsContainer.innerHTML = ``; + return; + } + tags.forEach(tag => { const tagEl = document.createElement('div'); tagEl.className = 'filter-tag tag-filter'; @@ -212,7 +228,6 @@ export class FilterManager { await this.applyFilters(false); }); - this.applyTagElementState(tagEl, (this.filters.tags && this.filters.tags[tagName]) || 'none'); tagsContainer.appendChild(tagEl); }); @@ -235,8 +250,8 @@ export class FilterManager { await this.applyFilters(false); }); - this.applyTagElementState(noTagsEl, (this.filters.tags && this.filters.tags[noTagsKey]) || 'none'); tagsContainer.appendChild(noTagsEl); + this.updateTagSelections(); } initializeLicenseFilters() { @@ -323,44 +338,15 @@ export class FilterManager { if (!baseModelTagsContainer) return; // Set the API endpoint based on current page - const apiEndpoint = `/api/lm/${this.currentPage}/base-models`; + const apiEndpoint = `/api/lm/${this.currentPage}/base-models?limit=0`; // Fetch base models fetch(apiEndpoint) .then(response => response.json()) .then(data => { if (data.success && data.base_models) { - baseModelTagsContainer.innerHTML = ''; - - data.base_models.forEach(model => { - const tag = document.createElement('div'); - tag.className = `filter-tag base-model-tag`; - tag.dataset.baseModel = model.name; - tag.innerHTML = `${model.name} ${model.count}`; - - // Add click handler to toggle selection and automatically apply - tag.addEventListener('click', async () => { - tag.classList.toggle('active'); - - if (tag.classList.contains('active')) { - if (!this.filters.baseModel.includes(model.name)) { - this.filters.baseModel.push(model.name); - } - } else { - this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name); - } - - this.updateActiveFiltersCount(); - - // Auto-apply filter when tag is clicked - await this.applyFilters(false); - }); - - baseModelTagsContainer.appendChild(tag); - }); - - // Update selections based on stored filters - this.updateTagSelections(); + this.baseModelOptions = data.base_models; + this.renderBaseModelTags(); } }) .catch(error => { @@ -369,6 +355,57 @@ export class FilterManager { }); } + renderBaseModelTags() { + const baseModelTagsContainer = document.getElementById('baseModelTags'); + const emptyState = document.getElementById('baseModelEmptyState'); + if (!baseModelTagsContainer) return; + + baseModelTagsContainer.innerHTML = ''; + + if (!this.baseModelOptions.length) { + baseModelTagsContainer.innerHTML = ''; + if (emptyState) { + emptyState.hidden = true; + } + return; + } + + const query = this.getNormalizedSearchQuery(this.baseModelSearchInput); + const filteredModels = query + ? this.baseModelOptions.filter(model => model.name.toLowerCase().includes(query)) + : this.baseModelOptions; + + filteredModels.forEach(model => { + const tag = document.createElement('div'); + tag.className = 'filter-tag base-model-tag'; + tag.dataset.baseModel = model.name; + tag.innerHTML = `${model.name} ${model.count}`; + + tag.addEventListener('click', async () => { + tag.classList.toggle('active'); + + if (tag.classList.contains('active')) { + if (!this.filters.baseModel.includes(model.name)) { + this.filters.baseModel.push(model.name); + } + } else { + this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name); + } + + this.updateActiveFiltersCount(); + await this.applyFilters(false); + }); + + baseModelTagsContainer.appendChild(tag); + }); + + if (emptyState) { + emptyState.hidden = filteredModels.length > 0; + } + + this.updateTagSelections(); + } + async createModelTypeTags() { const modelTypeContainer = document.getElementById('modelTypeTags'); if (!modelTypeContainer) return; @@ -453,6 +490,7 @@ export class FilterManager { this.filterPanel.classList.remove('hidden'); this.filterButton.classList.add('active'); + this.baseModelSearchInput?.focus(); // Load tags if they haven't been loaded yet if (!this.tagsLoaded) { diff --git a/static/js/utils/eventManagementInit.js b/static/js/utils/eventManagementInit.js index 98055f43..e42cd484 100644 --- a/static/js/utils/eventManagementInit.js +++ b/static/js/utils/eventManagementInit.js @@ -25,6 +25,7 @@ export function initializeEventManagement() { setupPageUnloadCleanup(); // Register global event handlers that need coordination + registerGlobalEventHandlers(); registerContextMenuEvents(); registerGlobalClickHandlers(); @@ -148,6 +149,10 @@ function registerGlobalClickHandlers() { * Register common application-wide event handlers */ export function registerGlobalEventHandlers() { + eventManager.removeHandler('keydown', 'global-escape'); + eventManager.removeHandler('focusin', 'global-focus'); + eventManager.removeHandler('click', 'global-analytics'); + // Escape key handler for closing modals/panels eventManager.addHandler('keydown', 'global-escape', (e) => { if (e.key === 'Escape') { @@ -156,6 +161,14 @@ export function registerGlobalEventHandlers() { modalManager.closeCurrentModal(); return true; // Stop propagation } + + if ( + window.filterManager?.filterPanel + && !window.filterManager.filterPanel.classList.contains('hidden') + ) { + window.filterManager.closeFilterPanel(); + return true; // Stop propagation + } // Check if node selector is active and close it if (eventManager.getState('nodeSelectorActive')) { diff --git a/templates/components/header.html b/templates/components/header.html index b5494b70..0725c923 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -145,9 +145,22 @@