mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 16:31:28 -03:00
feat(recipes): improve recipe LoRA status indicators and missing-badge affordance (#1076)
- Recipe card: compact status pill with state icon + available/total fraction (e.g. "2/3"), pinned to the footer bottom-right like model card actions; status is encoded by icon + color, never color alone - Recipe modal: "N missing" is now a real <button> with a persistent border, leading download icon, focus-visible ring and aria-label; clicking opens the download-missing flow - Fix context menu missing-LoRA detection selector after badge refactor - i18n: add recipes.status/loraStatus keys with translations for all 10 locales, and fill pending rate-limit translations
This commit is contained in:
+15
-3
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+15
-3
@@ -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",
|
||||
|
||||
+15
-3
@@ -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",
|
||||
|
||||
+15
-3
@@ -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": "לא נבחרו מתכונים",
|
||||
|
||||
+15
-3
@@ -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": "レシピが選択されていません",
|
||||
|
||||
+15
-3
@@ -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": "선택한 레시피가 없습니다",
|
||||
|
||||
+15
-3
@@ -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": "Рецепты не выбраны",
|
||||
|
||||
+15
-3
@@ -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": "未选择任何配方",
|
||||
|
||||
+15
-3
@@ -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": "未選取任何食譜",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
<span class="model-name">${this.recipe.title}</span>
|
||||
</div>
|
||||
${!isDuplicatesMode ? `
|
||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||
<div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = `<div class="recipe-status ready"><i class="fas fa-check-circle"></i> Ready to use</div>`;
|
||||
statusHTML = `<div class="recipe-status ready"><i class="fas fa-check-circle" aria-hidden="true"></i> ${translate('recipes.status.ready', {}, 'Ready to use')}</div>`;
|
||||
} else if (missingLorasCount > 0) {
|
||||
statusHTML = `<div class="recipe-status missing">
|
||||
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing
|
||||
<div class="missing-tooltip">Click to download missing LoRAs</div>
|
||||
</div>`;
|
||||
// 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 = `<button type="button" class="recipe-status missing clickable"
|
||||
title="${translate('recipes.status.downloadMissingTooltip', {}, 'Click to download missing LoRAs')}"
|
||||
aria-label="${translate('recipes.status.downloadMissing', { count: missingLorasCount }, `Download ${missingLorasCount} missing LoRAs`)}">
|
||||
<i class="fas fa-download" aria-hidden="true"></i> ${translate('recipes.status.missingCount', { count: missingLorasCount }, `${missingLorasCount} missing`)}
|
||||
</button>`;
|
||||
} else if (deletedLorasCount > 0 && missingLorasCount === 0) {
|
||||
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle"></i> ${deletedLorasCount} deleted</div>`;
|
||||
statusHTML = `<div class="recipe-status partial"><i class="fas fa-info-circle" aria-hidden="true"></i> ${translate('recipes.status.deletedCount', { count: deletedLorasCount }, `${deletedLorasCount} deleted`)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 `
|
||||
<div id="recipeModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<header class="recipe-modal-header">
|
||||
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||
<div id="recipeTagsContainer"></div>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="recipe-media-column">
|
||||
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-section recipe-bottom-section">
|
||||
<div class="recipe-section-actions">
|
||||
<span id="recipeLorasCount"></span>
|
||||
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
|
||||
</div>
|
||||
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user