diff --git a/locales/de.json b/locales/de.json index 4ad2ca78..479ca126 100644 --- a/locales/de.json +++ b/locales/de.json @@ -873,6 +873,18 @@ "sendFailed": "Fehler beim Senden des Workflows an ComfyUI", "noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden" }, + "status": { + "ready": "Einsatzbereit", + "missingCount": "{count} fehlen", + "deletedCount": "{count} gelöscht", + "downloadMissing": "{count} fehlende LoRAs herunterladen", + "downloadMissingTooltip": "Klicken, um fehlende LoRAs herunterzuladen" + }, + "loraStatus": { + "none": "Keine LoRAs in diesem Rezept", + "allAvailable": "Alle LoRAs verfügbar - Einsatzbereit", + "missing": "{missing} von {total} LoRAs fehlen" + }, "controls": { "import": { "action": "Importieren", @@ -1033,8 +1045,8 @@ "start": "Start Import", "startImport": "Start Import", "importing": "Importing...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "Ratenlimit — wird verlangsamt…", + "rateLimitedHint": "Einige Einträge wurden aufgrund von Ratenlimits des Metadaten-Anbieters übersprungen. Führen Sie den Import später erneut aus, um sie zu wiederholen.", "progress": "Progress", "total": "Total", "success": "Success", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "Ratenlimit des Metadaten-Anbieters erreicht — Anfragen werden verlangsamt und einige Einträge können übersprungen werden. Sie können den Import später erneut ausführen.", "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "Keine Rezepte ausgewählt", diff --git a/locales/en.json b/locales/en.json index 228ef318..a1e553a8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -873,6 +873,18 @@ "sendFailed": "Failed to send workflow to ComfyUI", "noWorkflow": "No embedded workflow found in this recipe" }, + "status": { + "ready": "Ready to use", + "missingCount": "{count} missing", + "deletedCount": "{count} deleted", + "downloadMissing": "Download {count} missing LoRAs", + "downloadMissingTooltip": "Click to download missing LoRAs" + }, + "loraStatus": { + "none": "No LoRAs in this recipe", + "allAvailable": "All LoRAs available - Ready to use", + "missing": "{missing} of {total} LoRAs missing" + }, "controls": { "import": { "action": "Import", diff --git a/locales/es.json b/locales/es.json index 391414f9..5eab91d1 100644 --- a/locales/es.json +++ b/locales/es.json @@ -873,6 +873,18 @@ "sendFailed": "Error al enviar el workflow a ComfyUI", "noWorkflow": "No se encontró ningún workflow integrado en esta receta" }, + "status": { + "ready": "Listo para usar", + "missingCount": "{count} faltante(s)", + "deletedCount": "{count} eliminado(s)", + "downloadMissing": "Descargar {count} LoRAs faltantes", + "downloadMissingTooltip": "Haz clic para descargar los LoRAs faltantes" + }, + "loraStatus": { + "none": "No hay LoRAs en esta receta", + "allAvailable": "Todos los LoRAs disponibles - Listo para usar", + "missing": "Faltan {missing} de {total} LoRAs" + }, "controls": { "import": { "action": "Importar", @@ -1033,8 +1045,8 @@ "start": "Start Import", "startImport": "Start Import", "importing": "Importing...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "Límite de velocidad — ralentizando…", + "rateLimitedHint": "Algunos elementos se omitieron debido a los límites de velocidad del proveedor de metadatos. Vuelve a ejecutar la importación más tarde para reintentarlos.", "progress": "Progress", "total": "Total", "success": "Success", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "Se alcanzó el límite de velocidad del proveedor de metadatos — las solicitudes se están ralentizando y algunos elementos pueden omitirse. Puedes volver a ejecutar la importación más tarde.", "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "No se han seleccionado recetas", diff --git a/locales/fr.json b/locales/fr.json index 6e9f1eb0..771401ac 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -873,6 +873,18 @@ "sendFailed": "Échec de l'envoi du workflow vers ComfyUI", "noWorkflow": "Aucun workflow intégré trouvé dans cette recette" }, + "status": { + "ready": "Prêt à l'emploi", + "missingCount": "{count} manquant(s)", + "deletedCount": "{count} supprimé(s)", + "downloadMissing": "Télécharger {count} LoRAs manquants", + "downloadMissingTooltip": "Cliquer pour télécharger les LoRAs manquants" + }, + "loraStatus": { + "none": "Aucun LoRA dans cette recette", + "allAvailable": "Tous les LoRAs sont disponibles - Prêt à l'emploi", + "missing": "{missing} LoRA(s) manquant(s) sur {total}" + }, "controls": { "import": { "action": "Importer", @@ -1033,8 +1045,8 @@ "start": "Start Import", "startImport": "Start Import", "importing": "Importing...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "Limitation de débit — ralentissement…", + "rateLimitedHint": "Certains éléments ont été ignorés en raison des limites de débit du fournisseur de métadonnées. Relancez l'importation plus tard pour les réessayer.", "progress": "Progress", "total": "Total", "success": "Success", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "Limite de débit du fournisseur de métadonnées atteinte — les requêtes sont ralenties et certains éléments peuvent être ignorés. Vous pouvez relancer l'importation plus tard.", "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "Aucune recette sélectionnée", diff --git a/locales/he.json b/locales/he.json index 573e2024..23ab84ba 100644 --- a/locales/he.json +++ b/locales/he.json @@ -873,6 +873,18 @@ "sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה", "noWorkflow": "לא נמצא workflow מוטבע במתכון זה" }, + "status": { + "ready": "מוכן לשימוש", + "missingCount": "{count} חסרים", + "deletedCount": "{count} נמחקו", + "downloadMissing": "הורד {count} LoRAs חסרים", + "downloadMissingTooltip": "לחץ כדי להוריד LoRAs חסרים" + }, + "loraStatus": { + "none": "אין LoRAs במתכון הזה", + "allAvailable": "כל ה-LoRAs זמינים - מוכן לשימוש", + "missing": "{missing} מתוך {total} LoRAs חסרים" + }, "controls": { "import": { "action": "ייבא", @@ -1033,8 +1045,8 @@ "start": "Start Import", "startImport": "Start Import", "importing": "Importing...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "הגבלת קצב — מאט…", + "rateLimitedHint": "חלק מהפריטים דולגו עקב הגבלות קצב של ספק המטא-נתונים. הפעל שוב את הייבוא מאוחר יותר כדי לנסות שוב.", "progress": "Progress", "total": "Total", "success": "Success", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "הושגה הגבלת הקצב של ספק המטא-נתונים — הבקשות מאטות וחלק מהפריטים עשויים להידלג. תוכל להפעיל שוב את הייבוא מאוחר יותר.", "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "לא נבחרו מתכונים", diff --git a/locales/ja.json b/locales/ja.json index 26a67532..9edb534f 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -873,6 +873,18 @@ "sendFailed": "ワークフローをComfyUIへ送信できませんでした", "noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません" }, + "status": { + "ready": "使用可能", + "missingCount": "{count} 件不足", + "deletedCount": "{count} 件削除済み", + "downloadMissing": "不足している {count} 件のLoRAをダウンロード", + "downloadMissingTooltip": "クリックして不足しているLoRAをダウンロード" + }, + "loraStatus": { + "none": "このレシピにはLoRAがありません", + "allAvailable": "すべてのLoRAが利用可能 - 使用可能", + "missing": "{total} 件中 {missing} 件のLoRAが不足" + }, "controls": { "import": { "action": "インポート", @@ -1033,8 +1045,8 @@ "start": "Start Import", "startImport": "Start Import", "importing": "Importing...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "レート制限 — 速度を落としています…", + "rateLimitedHint": "メタデータプロバイダーのレート制限により、一部のアイテムがスキップされました。後でインポートを再実行すると再試行できます。", "progress": "Progress", "total": "Total", "success": "Success", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "メタデータプロバイダーのレート制限に達しました — リクエストが低速化され、一部のアイテムがスキップされる可能性があります。後でインポートを再実行できます。", "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "レシピが選択されていません", diff --git a/locales/ko.json b/locales/ko.json index 67b25feb..e9194474 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -873,6 +873,18 @@ "sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다", "noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다" }, + "status": { + "ready": "바로 사용 가능", + "missingCount": "{count}개 누락", + "deletedCount": "{count}개 삭제됨", + "downloadMissing": "누락된 LoRA {count}개 다운로드", + "downloadMissingTooltip": "클릭하여 누락된 LoRA 다운로드" + }, + "loraStatus": { + "none": "이 레시피에는 LoRA가 없습니다", + "allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능", + "missing": "총 {total}개 중 {missing}개 LoRA 누락" + }, "controls": { "import": { "action": "가져오기", @@ -1033,8 +1045,8 @@ "start": "Start Import", "startImport": "Start Import", "importing": "Importing...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "속도 제한 — 속도를 줄이는 중…", + "rateLimitedHint": "메타데이터 제공자의 속도 제한으로 일부 항목이 건너뛰어졌습니다. 나중에 가져오기를 다시 실행하여 재시도하세요.", "progress": "Progress", "total": "Total", "success": "Success", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "메타데이터 제공자의 속도 제한에 도달했습니다 — 요청이 느려지고 일부 항목이 건너뛰어질 수 있습니다. 나중에 가져오기를 다시 실행할 수 있습니다.", "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "선택한 레시피가 없습니다", diff --git a/locales/ru.json b/locales/ru.json index 9b527020..dc052b22 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -873,6 +873,18 @@ "sendFailed": "Не удалось отправить workflow в ComfyUI", "noWorkflow": "В этом рецепте не найден встроенный workflow" }, + "status": { + "ready": "Готово к использованию", + "missingCount": "{count} отсутствует", + "deletedCount": "{count} удалено", + "downloadMissing": "Скачать {count} отсутствующих LoRAs", + "downloadMissingTooltip": "Нажмите, чтобы скачать отсутствующие LoRAs" + }, + "loraStatus": { + "none": "В этом рецепте нет LoRAs", + "allAvailable": "Все LoRAs доступны - Готово к использованию", + "missing": "Отсутствует {missing} из {total} LoRAs" + }, "controls": { "import": { "action": "Импортировать", @@ -1033,8 +1045,8 @@ "start": "Start Import", "startImport": "Start Import", "importing": "Importing...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "Ограничение частоты запросов — замедление…", + "rateLimitedHint": "Некоторые элементы были пропущены из-за ограничений частоты запросов поставщика метаданных. Повторите импорт позже, чтобы повторить попытку.", "progress": "Progress", "total": "Total", "success": "Success", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoDirectory": "Please enter a directory path", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "Достигнуто ограничение частоты запросов поставщика метаданных — запросы замедляются, некоторые элементы могут быть пропущены. Вы можете повторить импорт позже.", "batchImportBrowseFailed": "Failed to browse directory: {message}", "batchImportDirectorySelected": "Directory selected: {path}", "noRecipesSelected": "Рецепты не выбраны", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index db012a63..a2b45225 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -873,6 +873,18 @@ "sendFailed": "发送工作流到 ComfyUI 失败", "noWorkflow": "此配方中未找到内嵌工作流" }, + "status": { + "ready": "可直接使用", + "missingCount": "缺失 {count} 个", + "deletedCount": "已删除 {count} 个", + "downloadMissing": "下载 {count} 个缺失的 LoRA", + "downloadMissingTooltip": "点击下载缺失的 LoRA" + }, + "loraStatus": { + "none": "此配方不包含 LoRA", + "allAvailable": "所有 LoRA 均已就绪 - 可直接使用", + "missing": "{total} 个 LoRA 中缺失 {missing} 个" + }, "controls": { "import": { "action": "导入", @@ -1033,8 +1045,8 @@ "start": "开始导入", "startImport": "开始导入", "importing": "正在导入配方...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "触发速率限制 — 正在减速…", + "rateLimitedHint": "部分条目因元数据提供方的速率限制而被跳过。稍后重新运行导入即可重试这些条目。", "progress": "进度", "total": "总计", "success": "成功", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "取消批量导入失败:{message}", "batchImportNoUrls": "请输入至少一个 URL 或文件路径", "batchImportNoDirectory": "请输入目录路径", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "已达到元数据提供方的速率限制 — 请求正在放缓,部分条目可能被跳过。你可以稍后重新运行导入。", "batchImportBrowseFailed": "浏览目录失败:{message}", "batchImportDirectorySelected": "已选择目录:{path}", "noRecipesSelected": "未选择任何配方", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 45f3f7a3..ad79bee8 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -873,6 +873,18 @@ "sendFailed": "傳送工作流到 ComfyUI 失敗", "noWorkflow": "此配方中未找到內嵌工作流" }, + "status": { + "ready": "可直接使用", + "missingCount": "缺少 {count} 個", + "deletedCount": "已刪除 {count} 個", + "downloadMissing": "下載 {count} 個缺少的 LoRA", + "downloadMissingTooltip": "點擊下載缺少的 LoRA" + }, + "loraStatus": { + "none": "此配方不含 LoRA", + "allAvailable": "所有 LoRA 皆已就緒 - 可直接使用", + "missing": "{total} 個 LoRA 中缺少 {missing} 個" + }, "controls": { "import": { "action": "匯入", @@ -1033,8 +1045,8 @@ "start": "開始匯入", "startImport": "開始匯入", "importing": "匯入中...", - "rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", - "rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", + "rateLimitedSlowdown": "觸發速率限制 — 正在減速…", + "rateLimitedHint": "部分項目因元數據提供方的速率限制而被略過。稍後重新執行匯入即可重試這些項目。", "progress": "進度", "total": "總計", "success": "成功", @@ -2040,7 +2052,7 @@ "batchImportCancelFailed": "取消批量匯入失敗:{message}", "batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑", "batchImportNoDirectory": "請輸入目錄路徑", - "batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.", + "batchImportRateLimited": "已達到元數據提供方的速率限制 — 請求正在放緩,部分項目可能被略過。你可以稍後重新執行匯入。", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}", "batchImportDirectorySelected": "已選擇目錄:{path}", "noRecipesSelected": "未選取任何食譜", diff --git a/static/css/components/card.css b/static/css/components/card.css index be8d7ba6..e0c439f1 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -671,23 +671,32 @@ body.hide-card-version .hl-badge { user-select: none; } +/* Compact LoRA status pill: state icon + available/total fraction (e.g. "2/3"). + The icon switches by state (warning/check/layers) so status never relies on + color alone; the tooltip spells out the full details. */ .lora-count { display: flex; align-items: center; gap: 4px; - background: rgba(255, 255, 255, 0.2); - padding: 2px 8px; - border-radius: var(--border-radius-xs); + flex-shrink: 0; + /* Pin to the bottom-right corner of the footer, matching how model card + footer .card-actions behave when the title wraps to multiple lines */ + align-self: flex-end; font-size: 0.85em; - position: relative; + padding: 2px 8px; + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: var(--border-radius-xs); } .lora-count.ready { background: rgba(46, 204, 113, 0.3); + border-color: rgba(46, 204, 113, 0.6); } .lora-count.missing { - background: rgba(231, 76, 60, 0.3); + background: rgba(231, 76, 60, 0.35); + border-color: rgba(231, 76, 60, 0.65); } .placeholder-message { diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css index 022690ac..32300415 100644 --- a/static/css/components/recipe-modal.css +++ b/static/css/components/recipe-modal.css @@ -1153,47 +1153,26 @@ margin-left: auto; } -/* Add styles for missing LoRAs download feature */ -.recipe-status.missing { +/* Missing LoRAs status is a real button: the affordance must be visible at + rest (persistent border), not only on hover. */ +.recipe-status.missing.clickable { position: relative; cursor: pointer; - transition: background-color 0.2s ease; + font: inherit; + background: oklch(var(--lora-error) / 0.12); + color: var(--lora-error); + border: 1px solid oklch(var(--lora-error) / 0.45); + transition: background-color 0.2s ease, box-shadow 0.2s ease; } -.recipe-status.missing:hover { - background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2); +.recipe-status.missing.clickable:hover { + background: oklch(var(--lora-error) / 0.22); + box-shadow: 0 0 0 2px oklch(var(--lora-error) / 0.25); } -.recipe-status.missing .missing-tooltip { - position: absolute; - display: none; - background-color: var(--card-bg); - color: var(--text-color); - padding: 8px 12px; - border-radius: var(--border-radius-xs); - border: 1px solid var(--border-color); - box-shadow: var(--shadow-header); - z-index: var(--z-overlay); - width: max-content; - max-width: 200px; - font-size: 0.85rem; - font-weight: normal; - margin-left: -100px; - margin-top: -65px; -} - -.recipe-status.missing:hover .missing-tooltip { - display: block; -} - -.recipe-status.clickable { - cursor: pointer; - padding: 4px 8px; - border-radius: var(--border-radius-xs); -} - -.recipe-status.clickable:hover { - background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2); +.recipe-status.missing.clickable:focus-visible { + outline: 2px solid var(--lora-error); + outline-offset: 2px; } .recipe-checkpoint-meta { diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index 49be1425..a6a6ffa6 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -37,8 +37,7 @@ export class RecipeContextMenu extends BaseContextMenu { if (recipeId && missingLorasItem) { // Check if this card has missing LoRAs - const loraCountElement = card.querySelector('.lora-count'); - const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing'); + const hasMissingLoras = Boolean(card.querySelector('.lora-count.missing')); // Show/hide the download missing LoRAs option based on missing status if (hasMissingLoras) { diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index bc6acee2..18ae172c 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -45,6 +45,13 @@ class RecipeCard { const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length; const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0; + // Compact status pill: state icon + available/total fraction. + // Icon switches by state so status never relies on color alone. + const availableLorasCount = lorasCount - missingLorasCount; + const loraCountStateClass = missingLorasCount > 0 ? 'missing' : (allLorasAvailable ? 'ready' : ''); + const loraCountIcon = missingLorasCount > 0 ? 'fa-exclamation-triangle' : (allLorasAvailable ? 'fa-check' : 'fa-layer-group'); + const loraCountLabel = lorasCount > 0 ? `${availableLorasCount}/${lorasCount}` : `${lorasCount}`; + // Ensure file_url exists, fallback to API URL if needed let previewUrl = this.recipe.file_url; if (!previewUrl) { @@ -128,9 +135,8 @@ class RecipeCard { ${this.recipe.title} ${!isDuplicatesMode ? ` -
- ${lorasCount} +
+ ${loraCountLabel}
` : ''}
@@ -149,9 +155,17 @@ class RecipeCard { } getLoraStatusTitle(totalCount, missingCount) { - if (totalCount === 0) return "No LoRAs in this recipe"; - if (missingCount === 0) return "All LoRAs available - Ready to use"; - return `${missingCount} of ${totalCount} LoRAs missing`; + if (totalCount === 0) { + return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe'); + } + if (missingCount === 0) { + return translate('recipes.loraStatus.allAvailable', {}, 'All LoRAs available - Ready to use'); + } + return translate( + 'recipes.loraStatus.missing', + { missing: missingCount, total: totalCount }, + `${missingCount} of ${totalCount} LoRAs missing` + ); } async toggleFavorite(card) { diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 6d9e0a80..aedff055 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -299,21 +299,6 @@ class RecipeModal { tooltip.style.left = (badgeRect.right - tooltip.offsetWidth) + 'px'; } } - - // Add tooltip positioning for missing badge - if (event.target.closest('.recipe-status.missing')) { - const badge = event.target.closest('.recipe-status.missing'); - const tooltip = badge.querySelector('.missing-tooltip'); - - if (tooltip) { - // Get badge position - const badgeRect = badge.getBoundingClientRect(); - - // Position the tooltip - tooltip.style.top = (badgeRect.bottom + 4) + 'px'; - tooltip.style.left = (badgeRect.left) + 'px'; - } - } }, true); } @@ -872,30 +857,34 @@ class RecipeModal { let statusHTML = ''; if (totalCount > 0) { if (allLorasAvailable && deletedLorasCount === 0) { - statusHTML = `
Ready to use
`; + statusHTML = `
${translate('recipes.status.ready', {}, 'Ready to use')}
`; } else if (missingLorasCount > 0) { - statusHTML = `
- ${missingLorasCount} missing -
Click to download missing LoRAs
-
`; + // Rendered as a real button so the affordance is visible without + // hover and the control is keyboard/screen-reader accessible. + // Leading download icon: the red tint + "missing" text already + // encode the state, so the icon's job is to hint the action. + statusHTML = ``; } else if (deletedLorasCount > 0 && missingLorasCount === 0) { - statusHTML = `
${deletedLorasCount} deleted
`; + statusHTML = `
${translate('recipes.status.deletedCount', { count: deletedLorasCount }, `${deletedLorasCount} deleted`)}
`; } } lorasCountElement.innerHTML = ` ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`; + const missingStatus = lorasCountElement.querySelector('.recipe-status.missing'); + if (missingStatus && missingLorasCount > 0) { + missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal()); + } + setTimeout(() => { const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); if (viewRecipeLorasBtn) { viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); } - - const missingStatus = document.querySelector('.recipe-status.missing'); - if (missingStatus && missingLorasCount > 0) { - missingStatus.classList.add('clickable'); - missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal()); - } }, 100); } diff --git a/tests/frontend/components/recipeCard.loraBadges.test.js b/tests/frontend/components/recipeCard.loraBadges.test.js new file mode 100644 index 00000000..45ed92fb --- /dev/null +++ b/tests/frontend/components/recipeCard.loraBadges.test.js @@ -0,0 +1,155 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const { + RECIPE_CARD_MODULE, + UI_HELPERS_MODULE, + RECIPE_API_MODULE, + MODEL_CARD_MODULE, + MODAL_MANAGER_MODULE, + STATE_MODULE, + BULK_MANAGER_MODULE, + CONSTANTS_MODULE, + I18N_MODULE, + UNDO_HELPERS_MODULE, +} = vi.hoisted(() => ({ + RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname, + MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname, + MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname, + CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname, +})); + +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: vi.fn(), + showActionToast: vi.fn(), + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), +})); + +vi.mock(RECIPE_API_MODULE, () => ({ + updateRecipeMetadata: vi.fn(), +})); + +vi.mock(MODEL_CARD_MODULE, () => ({ + configureModelCardVideo: vi.fn(), +})); + +vi.mock(MODAL_MANAGER_MODULE, () => ({ + modalManager: { + showModal: vi.fn(), + closeModal: vi.fn(), + }, +})); + +vi.mock(STATE_MODULE, () => ({ + state: { + global: { settings: {} }, + settings: {}, + virtualScroller: { removeItemByFilePath: vi.fn() }, + }, + getCurrentPageState: vi.fn(() => ({})), +})); + +vi.mock(BULK_MANAGER_MODULE, () => ({ + bulkManager: {}, +})); + +vi.mock(CONSTANTS_MODULE, () => ({ + NSFW_LEVELS: {}, + getBaseModelAbbreviation: vi.fn((label) => label), + getMatureBlurThreshold: vi.fn(() => 10), +})); + +vi.mock(I18N_MODULE, () => ({ + translate: translateMock, +})); + +vi.mock(UNDO_HELPERS_MODULE, () => ({ + handleUndoDelete: vi.fn(), +})); + +function buildRecipe(loras) { + return { + id: 'recipe-1', + file_path: '/recipes/r1.json', + title: 'Badge Recipe', + file_url: '/preview.png', + preview_nsfw_level: 0, + created_date: '2024-01-01', + base_model: 'SDXL', + loras, + }; +} + +async function createCard(loras) { + const { RecipeCard } = await import(RECIPE_CARD_MODULE); + return new RecipeCard(buildRecipe(loras), vi.fn()); +} + +describe('RecipeCard LoRA status pill', () => { + beforeEach(() => { + translateMock.mockClear(); + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('shows available/total with a warning icon when LoRAs are missing', async () => { + const card = await createCard([ + { name: 'a', inLibrary: true }, + { name: 'b', inLibrary: false }, + { name: 'c', inLibrary: true }, + ]); + + const pill = card.element.querySelector('.lora-count.missing'); + expect(pill).not.toBeNull(); + expect(pill.querySelector('.fa-exclamation-triangle')).not.toBeNull(); + expect(pill.textContent).toContain('2/3'); + expect(pill.title).toBe('1 of 3 LoRAs missing'); + }); + + it('shows a green check with n/n when every LoRA is available', async () => { + const card = await createCard([ + { name: 'a', inLibrary: true }, + { name: 'b', inLibrary: true }, + ]); + + const pill = card.element.querySelector('.lora-count.ready'); + expect(pill).not.toBeNull(); + expect(pill.querySelector('.fa-check')).not.toBeNull(); + expect(pill.textContent).toContain('2/2'); + expect(pill.title).toBe('All LoRAs available - Ready to use'); + expect(card.element.querySelector('.lora-count.missing')).toBeNull(); + }); + + it('does not count deleted LoRAs as missing', async () => { + const card = await createCard([ + { name: 'a', inLibrary: true }, + { name: 'b', inLibrary: false, isDeleted: true }, + ]); + + expect(card.element.querySelector('.lora-count.missing')).toBeNull(); + expect(card.element.querySelector('.lora-count.ready')).not.toBeNull(); + }); + + it('shows a neutral layers icon with a bare 0 when the recipe has no LoRAs', async () => { + const card = await createCard([]); + + const pill = card.element.querySelector('.lora-count'); + expect(pill).not.toBeNull(); + expect(pill.classList.contains('missing')).toBe(false); + expect(pill.classList.contains('ready')).toBe(false); + expect(pill.querySelector('.fa-layer-group')).not.toBeNull(); + expect(pill.textContent).toContain('0'); + expect(pill.textContent).not.toContain('/'); + expect(pill.title).toBe('No LoRAs in this recipe'); + }); +}); diff --git a/tests/frontend/components/recipeModal.missingStatus.test.js b/tests/frontend/components/recipeModal.missingStatus.test.js new file mode 100644 index 00000000..62bc7fcd --- /dev/null +++ b/tests/frontend/components/recipeModal.missingStatus.test.js @@ -0,0 +1,197 @@ +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +const showToastMock = vi.fn(); +const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)); + +const loadingManagerStub = { + showSimpleLoading: vi.fn(), + hide: vi.fn(), + show: vi.fn(), + restoreProgressBar: vi.fn(), +}; + +const virtualScrollerStub = { + updateSingleItem: vi.fn(), + getNavigationState: vi.fn(() => ({ + index: 0, + hasPrev: false, + hasNext: false, + loadedItems: 1, + totalItems: 1, + })), + getAdjacentItemByFilePath: vi.fn(async () => null), +}; + +const stateStub = { + global: { settings: {}, loadingManager: loadingManagerStub }, + loadingManager: loadingManagerStub, + virtualScroller: virtualScrollerStub, +}; + +const modalManagerMock = { + showModal: vi.fn(), + closeModal: vi.fn(), +}; + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: showToastMock, + copyToClipboard: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendModelPathToWorkflow: vi.fn(), + openCivitaiByMetadata: vi.fn(), + stripLoraTags: vi.fn((text) => text), + sendPromptToWorkflow: vi.fn(), + sendGenParamsToWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: translateMock, +})); + +vi.mock('../../../static/js/state/index.js', () => ({ + state: stateStub, +})); + +vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ + setSessionItem: vi.fn(), + removeSessionItem: vi.fn(), + getStorageItem: vi.fn(() => null), + setStorageItem: vi.fn(), +})); + +vi.mock('../../../static/js/api/recipeApi.js', () => ({ + fetchRecipeDetails: vi.fn(), + updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })), + sendRecipeWorkflow: vi.fn(), +})); + +vi.mock('../../../static/js/api/apiConfig.js', () => ({ + MODEL_TYPES: { + LORA: 'loras', + CHECKPOINT: 'checkpoints', + EMBEDDING: 'embeddings', + }, +})); + +function recipeModalFixture() { + return ` + + `; +} + +const recipeWithMissing = { + id: 'recipe-missing', + file_path: '/recipes/missing.json', + title: 'Missing Recipe', + tags: [], + loras: [ + { name: 'present-lora', inLibrary: true }, + { name: 'gone-lora', inLibrary: false }, + ], +}; + +const recipeReady = { + id: 'recipe-ready', + file_path: '/recipes/ready.json', + title: 'Ready Recipe', + tags: [], + loras: [ + { name: 'present-lora', inLibrary: true }, + ], +}; + +const createdModals = []; + +async function createRecipeModal() { + const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js'); + const recipeModal = new RecipeModal(); + createdModals.push(recipeModal); + return recipeModal; +} + +describe('RecipeModal missing LoRA status', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = recipeModalFixture(); + global.modalManager = modalManagerMock; + global.fetch = vi.fn(async () => ({ + ok: true, + json: async () => ({}), + })); + }); + + afterEach(() => { + createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts()); + createdModals.length = 0; + document.body.innerHTML = ''; + delete global.modalManager; + delete global.fetch; + }); + + it('renders the missing status as a button with a persistent affordance', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeWithMissing); + + const status = document.querySelector('#recipeLorasCount .recipe-status.missing'); + expect(status).not.toBeNull(); + expect(status.tagName).toBe('BUTTON'); + expect(status.type).toBe('button'); + expect(status.classList.contains('clickable')).toBe(true); + expect(status.getAttribute('aria-label')).toBe('Download 1 missing LoRAs'); + expect(status.title).toBe('Click to download missing LoRAs'); + // Leading download icon hints the action; the warning glyph was removed + // because the red tint + text already encode the state + expect(status.querySelector('i').classList.contains('fa-download')).toBe(true); + expect(status.querySelector('.fa-exclamation-triangle')).toBeNull(); + expect(status.textContent).toContain('1 missing'); + + // The hover-only tooltip was replaced by the always-visible button styling + expect(status.querySelector('.missing-tooltip')).toBeNull(); + }); + + it('opens the download-missing flow when the status button is clicked', async () => { + const recipeModal = await createRecipeModal(); + const downloadSpy = vi + .spyOn(recipeModal, 'showDownloadMissingLorasModal') + .mockImplementation(() => {}); + + recipeModal.showRecipeDetails(recipeWithMissing); + + const status = document.querySelector('#recipeLorasCount .recipe-status.missing'); + status.click(); + + expect(downloadSpy).toHaveBeenCalledTimes(1); + }); + + it('renders a non-interactive ready badge when every LoRA is available', async () => { + const recipeModal = await createRecipeModal(); + recipeModal.showRecipeDetails(recipeReady); + + const ready = document.querySelector('#recipeLorasCount .recipe-status.ready'); + expect(ready).not.toBeNull(); + expect(ready.tagName).toBe('DIV'); + expect(ready.textContent).toContain('Ready to use'); + expect(document.querySelector('#recipeLorasCount .recipe-status.missing')).toBeNull(); + }); +});