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:
Will Miao
2026-08-27 22:37:10 +08:00
parent 17dcbd3d4f
commit 65ba750634
17 changed files with 564 additions and 102 deletions
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "Fehler beim Senden des Workflows an ComfyUI", "sendFailed": "Fehler beim Senden des Workflows an ComfyUI",
"noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden" "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": { "controls": {
"import": { "import": {
"action": "Importieren", "action": "Importieren",
@@ -1033,8 +1045,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limitedslowing down...", "rateLimitedSlowdown": "Ratenlimit — wird verlangsamt…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "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", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path", "batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "[TODO: Translate] Metadata provider rate limit reachedrequests are being slowed and some items may be skipped. You can re-run the import later.", "batchImportRateLimited": "Ratenlimit des Metadaten-Anbieters erreichtAnfragen 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}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt", "noRecipesSelected": "Keine Rezepte ausgewählt",
+12
View File
@@ -873,6 +873,18 @@
"sendFailed": "Failed to send workflow to ComfyUI", "sendFailed": "Failed to send workflow to ComfyUI",
"noWorkflow": "No embedded workflow found in this recipe" "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": { "controls": {
"import": { "import": {
"action": "Import", "action": "Import",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "Error al enviar el workflow a ComfyUI", "sendFailed": "Error al enviar el workflow a ComfyUI",
"noWorkflow": "No se encontró ningún workflow integrado en esta receta" "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": { "controls": {
"import": { "import": {
"action": "Importar", "action": "Importar",
@@ -1033,8 +1045,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "Límite de velocidad — ralentizando…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "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", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory 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}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No se han seleccionado recetas", "noRecipesSelected": "No se han seleccionado recetas",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "Échec de l'envoi du workflow vers ComfyUI", "sendFailed": "Échec de l'envoi du workflow vers ComfyUI",
"noWorkflow": "Aucun workflow intégré trouvé dans cette recette" "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": { "controls": {
"import": { "import": {
"action": "Importer", "action": "Importer",
@@ -1033,8 +1045,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "Limitation de débit — ralentissement…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "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", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory 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}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Aucune recette sélectionnée", "noRecipesSelected": "Aucune recette sélectionnée",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה", "sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה",
"noWorkflow": "לא נמצא workflow מוטבע במתכון זה" "noWorkflow": "לא נמצא workflow מוטבע במתכון זה"
}, },
"status": {
"ready": "מוכן לשימוש",
"missingCount": "{count} חסרים",
"deletedCount": "{count} נמחקו",
"downloadMissing": "הורד {count} LoRAs חסרים",
"downloadMissingTooltip": "לחץ כדי להוריד LoRAs חסרים"
},
"loraStatus": {
"none": "אין LoRAs במתכון הזה",
"allAvailable": "כל ה-LoRAs זמינים - מוכן לשימוש",
"missing": "{missing} מתוך {total} LoRAs חסרים"
},
"controls": { "controls": {
"import": { "import": {
"action": "ייבא", "action": "ייבא",
@@ -1033,8 +1045,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "הגבלת קצב — מאט…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "rateLimitedHint": "חלק מהפריטים דולגו עקב הגבלות קצב של ספק המטא-נתונים. הפעל שוב את הייבוא מאוחר יותר כדי לנסות שוב.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory 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}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "לא נבחרו מתכונים", "noRecipesSelected": "לא נבחרו מתכונים",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "ワークフローをComfyUIへ送信できませんでした", "sendFailed": "ワークフローをComfyUIへ送信できませんでした",
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません" "noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
}, },
"status": {
"ready": "使用可能",
"missingCount": "{count} 件不足",
"deletedCount": "{count} 件削除済み",
"downloadMissing": "不足している {count} 件のLoRAをダウンロード",
"downloadMissingTooltip": "クリックして不足しているLoRAをダウンロード"
},
"loraStatus": {
"none": "このレシピにはLoRAがありません",
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
"missing": "{total} 件中 {missing} 件のLoRAが不足"
},
"controls": { "controls": {
"import": { "import": {
"action": "インポート", "action": "インポート",
@@ -1033,8 +1045,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "レート制限 — 速度を落としています…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "rateLimitedHint": "メタデータプロバイダーのレート制限により、一部のアイテムがスキップされました。後でインポートを再実行すると再試行できます。",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory 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}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "レシピが選択されていません", "noRecipesSelected": "レシピが選択されていません",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다", "sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다",
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다" "noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
}, },
"status": {
"ready": "바로 사용 가능",
"missingCount": "{count}개 누락",
"deletedCount": "{count}개 삭제됨",
"downloadMissing": "누락된 LoRA {count}개 다운로드",
"downloadMissingTooltip": "클릭하여 누락된 LoRA 다운로드"
},
"loraStatus": {
"none": "이 레시피에는 LoRA가 없습니다",
"allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능",
"missing": "총 {total}개 중 {missing}개 LoRA 누락"
},
"controls": { "controls": {
"import": { "import": {
"action": "가져오기", "action": "가져오기",
@@ -1033,8 +1045,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "속도 제한 — 속도를 줄이는 중…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "rateLimitedHint": "메타데이터 제공자의 속도 제한으로 일부 항목이 건너뛰어졌습니다. 나중에 가져오기를 다시 실행하여 재시도하세요.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory 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}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다", "noRecipesSelected": "선택한 레시피가 없습니다",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "Не удалось отправить workflow в ComfyUI", "sendFailed": "Не удалось отправить workflow в ComfyUI",
"noWorkflow": "В этом рецепте не найден встроенный workflow" "noWorkflow": "В этом рецепте не найден встроенный workflow"
}, },
"status": {
"ready": "Готово к использованию",
"missingCount": "{count} отсутствует",
"deletedCount": "{count} удалено",
"downloadMissing": "Скачать {count} отсутствующих LoRAs",
"downloadMissingTooltip": "Нажмите, чтобы скачать отсутствующие LoRAs"
},
"loraStatus": {
"none": "В этом рецепте нет LoRAs",
"allAvailable": "Все LoRAs доступны - Готово к использованию",
"missing": "Отсутствует {missing} из {total} LoRAs"
},
"controls": { "controls": {
"import": { "import": {
"action": "Импортировать", "action": "Импортировать",
@@ -1033,8 +1045,8 @@
"start": "Start Import", "start": "Start Import",
"startImport": "Start Import", "startImport": "Start Import",
"importing": "Importing...", "importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "Ограничение частоты запросов — замедление…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "rateLimitedHint": "Некоторые элементы были пропущены из-за ограничений частоты запросов поставщика метаданных. Повторите импорт позже, чтобы повторить попытку.",
"progress": "Progress", "progress": "Progress",
"total": "Total", "total": "Total",
"success": "Success", "success": "Success",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}", "batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path", "batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory 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}", "batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}", "batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Рецепты не выбраны", "noRecipesSelected": "Рецепты не выбраны",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "发送工作流到 ComfyUI 失败", "sendFailed": "发送工作流到 ComfyUI 失败",
"noWorkflow": "此配方中未找到内嵌工作流" "noWorkflow": "此配方中未找到内嵌工作流"
}, },
"status": {
"ready": "可直接使用",
"missingCount": "缺失 {count} 个",
"deletedCount": "已删除 {count} 个",
"downloadMissing": "下载 {count} 个缺失的 LoRA",
"downloadMissingTooltip": "点击下载缺失的 LoRA"
},
"loraStatus": {
"none": "此配方不包含 LoRA",
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
"missing": "{total} 个 LoRA 中缺失 {missing} 个"
},
"controls": { "controls": {
"import": { "import": {
"action": "导入", "action": "导入",
@@ -1033,8 +1045,8 @@
"start": "开始导入", "start": "开始导入",
"startImport": "开始导入", "startImport": "开始导入",
"importing": "正在导入配方...", "importing": "正在导入配方...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "触发速率限制 — 正在减速…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "rateLimitedHint": "部分条目因元数据提供方的速率限制而被跳过。稍后重新运行导入即可重试这些条目。",
"progress": "进度", "progress": "进度",
"total": "总计", "total": "总计",
"success": "成功", "success": "成功",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "取消批量导入失败:{message}", "batchImportCancelFailed": "取消批量导入失败:{message}",
"batchImportNoUrls": "请输入至少一个 URL 或文件路径", "batchImportNoUrls": "请输入至少一个 URL 或文件路径",
"batchImportNoDirectory": "请输入目录路径", "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}", "batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}", "batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方", "noRecipesSelected": "未选择任何配方",
+15 -3
View File
@@ -873,6 +873,18 @@
"sendFailed": "傳送工作流到 ComfyUI 失敗", "sendFailed": "傳送工作流到 ComfyUI 失敗",
"noWorkflow": "此配方中未找到內嵌工作流" "noWorkflow": "此配方中未找到內嵌工作流"
}, },
"status": {
"ready": "可直接使用",
"missingCount": "缺少 {count} 個",
"deletedCount": "已刪除 {count} 個",
"downloadMissing": "下載 {count} 個缺少的 LoRA",
"downloadMissingTooltip": "點擊下載缺少的 LoRA"
},
"loraStatus": {
"none": "此配方不含 LoRA",
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
"missing": "{total} 個 LoRA 中缺少 {missing} 個"
},
"controls": { "controls": {
"import": { "import": {
"action": "匯入", "action": "匯入",
@@ -1033,8 +1045,8 @@
"start": "開始匯入", "start": "開始匯入",
"startImport": "開始匯入", "startImport": "開始匯入",
"importing": "匯入中...", "importing": "匯入中...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limited — slowing down...", "rateLimitedSlowdown": "觸發速率限制 — 正在減速…",
"rateLimitedHint": "[TODO: Translate] Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.", "rateLimitedHint": "部分項目因元數據提供方的速率限制而被略過。稍後重新執行匯入即可重試這些項目。",
"progress": "進度", "progress": "進度",
"total": "總計", "total": "總計",
"success": "成功", "success": "成功",
@@ -2040,7 +2052,7 @@
"batchImportCancelFailed": "取消批量匯入失敗:{message}", "batchImportCancelFailed": "取消批量匯入失敗:{message}",
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑", "batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
"batchImportNoDirectory": "請輸入目錄路徑", "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}", "batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}", "batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜", "noRecipesSelected": "未選取任何食譜",
+14 -5
View File
@@ -671,23 +671,32 @@ body.hide-card-version .hl-badge {
user-select: none; 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 { .lora-count {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
background: rgba(255, 255, 255, 0.2); flex-shrink: 0;
padding: 2px 8px; /* Pin to the bottom-right corner of the footer, matching how model card
border-radius: var(--border-radius-xs); footer .card-actions behave when the title wraps to multiple lines */
align-self: flex-end;
font-size: 0.85em; 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 { .lora-count.ready {
background: rgba(46, 204, 113, 0.3); background: rgba(46, 204, 113, 0.3);
border-color: rgba(46, 204, 113, 0.6);
} }
.lora-count.missing { .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 { .placeholder-message {
+14 -35
View File
@@ -1153,47 +1153,26 @@
margin-left: auto; margin-left: auto;
} }
/* Add styles for missing LoRAs download feature */ /* Missing LoRAs status is a real button: the affordance must be visible at
.recipe-status.missing { rest (persistent border), not only on hover. */
.recipe-status.missing.clickable {
position: relative; position: relative;
cursor: pointer; 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 { .recipe-status.missing.clickable:hover {
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2); background: oklch(var(--lora-error) / 0.22);
box-shadow: 0 0 0 2px oklch(var(--lora-error) / 0.25);
} }
.recipe-status.missing .missing-tooltip { .recipe-status.missing.clickable:focus-visible {
position: absolute; outline: 2px solid var(--lora-error);
display: none; outline-offset: 2px;
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-checkpoint-meta { .recipe-checkpoint-meta {
@@ -37,8 +37,7 @@ export class RecipeContextMenu extends BaseContextMenu {
if (recipeId && missingLorasItem) { if (recipeId && missingLorasItem) {
// Check if this card has missing LoRAs // Check if this card has missing LoRAs
const loraCountElement = card.querySelector('.lora-count'); const hasMissingLoras = Boolean(card.querySelector('.lora-count.missing'));
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
// Show/hide the download missing LoRAs option based on missing status // Show/hide the download missing LoRAs option based on missing status
if (hasMissingLoras) { if (hasMissingLoras) {
+20 -6
View File
@@ -45,6 +45,13 @@ class RecipeCard {
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length; const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0; 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 // Ensure file_url exists, fallback to API URL if needed
let previewUrl = this.recipe.file_url; let previewUrl = this.recipe.file_url;
if (!previewUrl) { if (!previewUrl) {
@@ -128,9 +135,8 @@ class RecipeCard {
<span class="model-name">${this.recipe.title}</span> <span class="model-name">${this.recipe.title}</span>
</div> </div>
${!isDuplicatesMode ? ` ${!isDuplicatesMode ? `
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}" <div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}"> <i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
<i class="fas fa-layer-group"></i> ${lorasCount}
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -149,9 +155,17 @@ class RecipeCard {
} }
getLoraStatusTitle(totalCount, missingCount) { getLoraStatusTitle(totalCount, missingCount) {
if (totalCount === 0) return "No LoRAs in this recipe"; if (totalCount === 0) {
if (missingCount === 0) return "All LoRAs available - Ready to use"; return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe');
return `${missingCount} of ${totalCount} LoRAs missing`; }
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) { async toggleFavorite(card) {
+16 -27
View File
@@ -299,21 +299,6 @@ class RecipeModal {
tooltip.style.left = (badgeRect.right - tooltip.offsetWidth) + 'px'; 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); }, true);
} }
@@ -872,30 +857,34 @@ class RecipeModal {
let statusHTML = ''; let statusHTML = '';
if (totalCount > 0) { if (totalCount > 0) {
if (allLorasAvailable && deletedLorasCount === 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) { } else if (missingLorasCount > 0) {
statusHTML = `<div class="recipe-status missing"> // Rendered as a real button so the affordance is visible without
<i class="fas fa-exclamation-triangle"></i> ${missingLorasCount} missing // hover and the control is keyboard/screen-reader accessible.
<div class="missing-tooltip">Click to download missing LoRAs</div> // Leading download icon: the red tint + "missing" text already
</div>`; // 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) { } 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}`; 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(() => { setTimeout(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) { if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage()); 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); }, 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();
});
});