Compare commits

...

3 Commits

Author SHA1 Message Date
Will Miao a7d65fe84a feat(recipes): redesign resource item badges and actions in recipe modal
- Badges are pure status indicators with tooltips; remediation moves to a
  per-item action row (Download / Reconnect), matching the versions-tab
  badge/button pattern
- Civitai link inlines with the model title; the action row renders only
  when real actions exist, removing empty-row whitespace
- Single-LoRA download resolves identifiers from hash on demand (same
  fallback as the bulk missing-download flow) and shows immediate
  'Preparing download' feedback while resolving
- Successful downloads (LoRA and checkpoint) refresh the resources
  section and the recipe card in place, mirroring the bulk flow
- Row navigation is limited to in-library items with keyboard support;
  checkpoint type renders as muted text instead of a chip; badges use
  tonal styling; the local-path hover tooltip is removed
- Add resourceItems frontend tests and translate the new keys for all
  10 locales
2026-08-28 09:30:37 +08:00
Will Miao 15bf079af2 docs: remove git commit message guidelines from AGENTS.md 2026-08-27 22:38:52 +08:00
Will Miao 65ba750634 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
2026-08-27 22:37:10 +08:00
19 changed files with 1665 additions and 279 deletions
+1 -8
View File
@@ -223,11 +223,4 @@ The system runs in two modes:
resolved. `os.path.realpath` is only for scanner dedup and the symlink cache.
Any path passed to `os.remove`/`os.rename`/`shutil.move` or validated by a
containment check MUST use the business path (i.e. `os.path.abspath`, not
`realpath`).
## Git / Commit Messages
- Follow the style of recent repository commits when writing commit messages
- Prefer the repo's existing `feat(...)`, `fix(...)`, `chore:` style where applicable
- If the user has provided a GitHub issue link or issue ID for the task, mention that issue in the commit message, for example `(#871)`
- When unrelated local changes exist, stage and commit only the files relevant to the requested task
`realpath`).
+33 -3
View File
@@ -873,6 +873,34 @@
"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"
},
"resources": {
"inLibrary": "In Bibliothek",
"notInLibrary": "Nicht in Bibliothek",
"deleted": "Gelöscht",
"inLibraryTooltip": "Dieses Modell ist in deiner lokalen Bibliothek vorhanden",
"notInLibraryTooltip": "Dieses Modell ist nicht in deiner Bibliothek",
"deletedTooltip": "Dieses LoRA wurde an der Quelle gelöscht und kann nicht mehr heruntergeladen werden",
"download": "Herunterladen",
"downloadLoraTooltip": "Dieses LoRA herunterladen",
"preparingDownload": "Download wird vorbereitet…",
"reconnect": "Neu verknüpfen",
"reconnectTooltip": "Mit einem lokalen LoRA neu verknüpfen",
"viewOnCivitai": "Auf Civitai anzeigen",
"openLoraDetails": "{name} in der LoRA-Bibliothek anzeigen",
"openCheckpointDetails": "{name} in der Modellbibliothek anzeigen"
},
"controls": {
"import": {
"action": "Importieren",
@@ -1033,8 +1061,8 @@
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"rateLimitedSlowdown": "[TODO: Translate] Rate limitedslowing 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",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
"missingLoraDownloadInfo": "Download-Informationen für dieses LoRA fehlen",
"downloadLoraFailed": "LoRA-Download fehlgeschlagen: {message}",
"cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID",
"deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung",
"deletedSuccessfully": "Rezept erfolgreich gelöscht",
@@ -2040,7 +2070,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 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}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
+30
View File
@@ -873,6 +873,34 @@
"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"
},
"resources": {
"inLibrary": "In Library",
"notInLibrary": "Not in Library",
"deleted": "Deleted",
"inLibraryTooltip": "This model exists in your local library",
"notInLibraryTooltip": "This model is not in your library",
"deletedTooltip": "This LoRA was deleted from the source and is no longer available for download",
"download": "Download",
"downloadLoraTooltip": "Download this LoRA",
"preparingDownload": "Preparing download...",
"reconnect": "Reconnect",
"reconnectTooltip": "Reconnect with a local LoRA",
"viewOnCivitai": "View on Civitai",
"openLoraDetails": "View {name} in the LoRA library",
"openCheckpointDetails": "View {name} in the model library"
},
"controls": {
"import": {
"action": "Import",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "Checkpoint path not available",
"missingCheckpointInfo": "Missing checkpoint information",
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
"missingLoraDownloadInfo": "Missing download information for this LoRA",
"downloadLoraFailed": "Failed to download LoRA: {message}",
"cannotDelete": "Cannot delete recipe: Missing recipe ID",
"deleteConfirmationError": "Error showing delete confirmation",
"deletedSuccessfully": "Recipe deleted successfully",
+33 -3
View File
@@ -873,6 +873,34 @@
"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"
},
"resources": {
"inLibrary": "En la biblioteca",
"notInLibrary": "No en la biblioteca",
"deleted": "Eliminado",
"inLibraryTooltip": "Este modelo existe en tu biblioteca local",
"notInLibraryTooltip": "Este modelo no está en tu biblioteca",
"deletedTooltip": "Este LoRA fue eliminado de la fuente y ya no se puede descargar",
"download": "Descargar",
"downloadLoraTooltip": "Descargar este LoRA",
"preparingDownload": "Preparando descarga…",
"reconnect": "Reconectar",
"reconnectTooltip": "Reconectar con un LoRA local",
"viewOnCivitai": "Ver en Civitai",
"openLoraDetails": "Ver {name} en la biblioteca de LoRAs",
"openCheckpointDetails": "Ver {name} en la biblioteca de modelos"
},
"controls": {
"import": {
"action": "Importar",
@@ -1033,8 +1061,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",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "Ruta del checkpoint no disponible",
"missingCheckpointInfo": "Falta información del checkpoint",
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
"missingLoraDownloadInfo": "Falta la información de descarga de este LoRA",
"downloadLoraFailed": "Error al descargar el LoRA: {message}",
"cannotDelete": "No se puede eliminar receta: Falta ID de receta",
"deleteConfirmationError": "Error mostrando confirmación de eliminación",
"deletedSuccessfully": "Receta eliminada exitosamente",
@@ -2040,7 +2070,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",
+33 -3
View File
@@ -873,6 +873,34 @@
"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}"
},
"resources": {
"inLibrary": "Dans la bibliothèque",
"notInLibrary": "Pas dans la bibliothèque",
"deleted": "Supprimé",
"inLibraryTooltip": "Ce modèle existe dans votre bibliothèque locale",
"notInLibraryTooltip": "Ce modèle n'est pas dans votre bibliothèque",
"deletedTooltip": "Ce LoRA a été supprimé de la source et ne peut plus être téléchargé",
"download": "Télécharger",
"downloadLoraTooltip": "Télécharger ce LoRA",
"preparingDownload": "Préparation du téléchargement…",
"reconnect": "Reconnecter",
"reconnectTooltip": "Reconnecter avec un LoRA local",
"viewOnCivitai": "Voir sur Civitai",
"openLoraDetails": "Voir {name} dans la bibliothèque LoRA",
"openCheckpointDetails": "Voir {name} dans la bibliothèque de modèles"
},
"controls": {
"import": {
"action": "Importer",
@@ -1033,8 +1061,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",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "Chemin du checkpoint indisponible",
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
"missingLoraDownloadInfo": "Informations de téléchargement manquantes pour ce LoRA",
"downloadLoraFailed": "Échec du téléchargement du LoRA : {message}",
"cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant",
"deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression",
"deletedSuccessfully": "Recipe supprimée avec succès",
@@ -2040,7 +2070,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",
+33 -3
View File
@@ -873,6 +873,34 @@
"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 חסרים"
},
"resources": {
"inLibrary": "בספרייה",
"notInLibrary": "לא בספרייה",
"deleted": "נמחק",
"inLibraryTooltip": "מודל זה קיים בספרייה המקומית שלך",
"notInLibraryTooltip": "מודל זה לא נמצא בספרייה שלך",
"deletedTooltip": "LoRA זה נמחק מהמקור ואינו זמין יותר להורדה",
"download": "הורדה",
"downloadLoraTooltip": "הורד את ה-LoRA הזה",
"preparingDownload": "מכין את ההורדה…",
"reconnect": "חבר מחדש",
"reconnectTooltip": "חבר מחדש עם LoRA מקומי",
"viewOnCivitai": "הצג ב-Civitai",
"openLoraDetails": "הצג את {name} בספריית ה-LoRA",
"openCheckpointDetails": "הצג את {name} בספריית המודלים"
},
"controls": {
"import": {
"action": "ייבא",
@@ -1033,8 +1061,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",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
"missingCheckpointInfo": "חסרים פרטי checkpoint",
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
"missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה",
"downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
"cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון",
"deleteConfirmationError": "שגיאה בהצגת אישור המחיקה",
"deletedSuccessfully": "המתכון נמחק בהצלחה",
@@ -2040,7 +2070,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": "לא נבחרו מתכונים",
+33 -3
View File
@@ -873,6 +873,34 @@
"sendFailed": "ワークフローをComfyUIへ送信できませんでした",
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
},
"status": {
"ready": "使用可能",
"missingCount": "{count} 件不足",
"deletedCount": "{count} 件削除済み",
"downloadMissing": "不足している {count} 件のLoRAをダウンロード",
"downloadMissingTooltip": "クリックして不足しているLoRAをダウンロード"
},
"loraStatus": {
"none": "このレシピにはLoRAがありません",
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
"missing": "{total} 件中 {missing} 件のLoRAが不足"
},
"resources": {
"inLibrary": "ライブラリ内",
"notInLibrary": "ライブラリ外",
"deleted": "削除済み",
"inLibraryTooltip": "このモデルはローカルライブラリに存在します",
"notInLibraryTooltip": "このモデルはライブラリにありません",
"deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません",
"download": "ダウンロード",
"downloadLoraTooltip": "この LoRA をダウンロード",
"preparingDownload": "ダウンロードを準備中…",
"reconnect": "再接続",
"reconnectTooltip": "ローカルの LoRA と再接続",
"viewOnCivitai": "Civitai で表示",
"openLoraDetails": "LoRA ライブラリで {name} を表示",
"openCheckpointDetails": "モデルライブラリで {name} を表示"
},
"controls": {
"import": {
"action": "インポート",
@@ -1033,8 +1061,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",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "チェックポイントのパスがありません",
"missingCheckpointInfo": "チェックポイント情報が不足しています",
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
"cannotDelete": "レシピを削除できません:レシピIDがありません",
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
"deletedSuccessfully": "レシピが正常に削除されました",
@@ -2040,7 +2070,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": "レシピが選択されていません",
+33 -3
View File
@@ -873,6 +873,34 @@
"sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다",
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
},
"status": {
"ready": "바로 사용 가능",
"missingCount": "{count}개 누락",
"deletedCount": "{count}개 삭제됨",
"downloadMissing": "누락된 LoRA {count}개 다운로드",
"downloadMissingTooltip": "클릭하여 누락된 LoRA 다운로드"
},
"loraStatus": {
"none": "이 레시피에는 LoRA가 없습니다",
"allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능",
"missing": "총 {total}개 중 {missing}개 LoRA 누락"
},
"resources": {
"inLibrary": "라이브러리에 있음",
"notInLibrary": "라이브러리에 없음",
"deleted": "삭제됨",
"inLibraryTooltip": "이 모델은 로컬 라이브러리에 있습니다",
"notInLibraryTooltip": "이 모델은 라이브러리에 없습니다",
"deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다",
"download": "다운로드",
"downloadLoraTooltip": "이 LoRA 다운로드",
"preparingDownload": "다운로드 준비 중…",
"reconnect": "다시 연결",
"reconnectTooltip": "로컬 LoRA와 다시 연결",
"viewOnCivitai": "Civitai에서 보기",
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기"
},
"controls": {
"import": {
"action": "가져오기",
@@ -1033,8 +1061,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",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다",
"missingCheckpointInfo": "체크포인트 정보가 부족합니다",
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
"missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
"downloadLoraFailed": "LoRA 다운로드 실패: {message}",
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
"deleteConfirmationError": "삭제 확인 표시 오류",
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
@@ -2040,7 +2070,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": "선택한 레시피가 없습니다",
+33 -3
View File
@@ -873,6 +873,34 @@
"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"
},
"resources": {
"inLibrary": "В библиотеке",
"notInLibrary": "Не в библиотеке",
"deleted": "Удалено",
"inLibraryTooltip": "Эта модель есть в вашей локальной библиотеке",
"notInLibraryTooltip": "Этой модели нет в вашей библиотеке",
"deletedTooltip": "Этот LoRA был удалён из источника и больше недоступен для скачивания",
"download": "Скачать",
"downloadLoraTooltip": "Скачать этот LoRA",
"preparingDownload": "Подготовка к скачиванию…",
"reconnect": "Переподключить",
"reconnectTooltip": "Переподключить к локальному LoRA",
"viewOnCivitai": "Открыть на Civitai",
"openLoraDetails": "Открыть {name} в библиотеке LoRA",
"openCheckpointDetails": "Открыть {name} в библиотеке моделей"
},
"controls": {
"import": {
"action": "Импортировать",
@@ -1033,8 +1061,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",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "Путь к чекпойнту недоступен",
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
"missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
"downloadLoraFailed": "Не удалось скачать LoRA: {message}",
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
"deleteConfirmationError": "Ошибка отображения подтверждения удаления",
"deletedSuccessfully": "Рецепт успешно удален",
@@ -2040,7 +2070,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": "Рецепты не выбраны",
+33 -3
View File
@@ -873,6 +873,34 @@
"sendFailed": "发送工作流到 ComfyUI 失败",
"noWorkflow": "此配方中未找到内嵌工作流"
},
"status": {
"ready": "可直接使用",
"missingCount": "缺失 {count} 个",
"deletedCount": "已删除 {count} 个",
"downloadMissing": "下载 {count} 个缺失的 LoRA",
"downloadMissingTooltip": "点击下载缺失的 LoRA"
},
"loraStatus": {
"none": "此配方不包含 LoRA",
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
"missing": "{total} 个 LoRA 中缺失 {missing} 个"
},
"resources": {
"inLibrary": "在库中",
"notInLibrary": "不在库中",
"deleted": "已删除",
"inLibraryTooltip": "该模型已存在于本地库中",
"notInLibraryTooltip": "该模型不在你的本地库中",
"deletedTooltip": "该 LoRA 已从来源站删除,无法下载",
"download": "下载",
"downloadLoraTooltip": "下载此 LoRA",
"preparingDownload": "正在准备下载…",
"reconnect": "重新关联",
"reconnectTooltip": "与本地 LoRA 重新关联",
"viewOnCivitai": "在 Civitai 上查看",
"openLoraDetails": "在 LoRA 库中查看 {name}",
"openCheckpointDetails": "在模型库中查看 {name}"
},
"controls": {
"import": {
"action": "导入",
@@ -1033,8 +1061,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": "成功",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "缺少检查点路径",
"missingCheckpointInfo": "缺少检查点信息",
"downloadCheckpointFailed": "下载检查点失败:{message}",
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
"downloadLoraFailed": "下载 LoRA 失败:{message}",
"cannotDelete": "无法删除配方:缺少配方 ID",
"deleteConfirmationError": "显示删除确认出错",
"deletedSuccessfully": "配方删除成功",
@@ -2040,7 +2070,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": "未选择任何配方",
+33 -3
View File
@@ -873,6 +873,34 @@
"sendFailed": "傳送工作流到 ComfyUI 失敗",
"noWorkflow": "此配方中未找到內嵌工作流"
},
"status": {
"ready": "可直接使用",
"missingCount": "缺少 {count} 個",
"deletedCount": "已刪除 {count} 個",
"downloadMissing": "下載 {count} 個缺少的 LoRA",
"downloadMissingTooltip": "點擊下載缺少的 LoRA"
},
"loraStatus": {
"none": "此配方不含 LoRA",
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
"missing": "{total} 個 LoRA 中缺少 {missing} 個"
},
"resources": {
"inLibrary": "已在庫存",
"notInLibrary": "不在庫存",
"deleted": "已刪除",
"inLibraryTooltip": "此模型已存在於本地庫",
"notInLibraryTooltip": "此模型不在你的本地庫中",
"deletedTooltip": "此 LoRA 已從來源站刪除,無法下載",
"download": "下載",
"downloadLoraTooltip": "下載此 LoRA",
"preparingDownload": "正在準備下載…",
"reconnect": "重新關聯",
"reconnectTooltip": "與本地 LoRA 重新關聯",
"viewOnCivitai": "在 Civitai 上檢視",
"openLoraDetails": "在 LoRA 庫中檢視 {name}",
"openCheckpointDetails": "在模型庫中檢視 {name}"
},
"controls": {
"import": {
"action": "匯入",
@@ -1033,8 +1061,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": "成功",
@@ -2017,6 +2045,8 @@
"missingCheckpointPath": "缺少檢查點路徑",
"missingCheckpointInfo": "缺少檢查點資訊",
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
"cannotDelete": "無法刪除配方:缺少配方 ID",
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
"deletedSuccessfully": "配方已成功刪除",
@@ -2040,7 +2070,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": "未選取任何食譜",
+14 -5
View File
@@ -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 {
+128 -97
View File
@@ -700,26 +700,42 @@
will-change: transform;
/* Create a new containing block for absolutely positioned descendants */
transform: translateZ(0);
cursor: pointer; /* Make it clear the item is clickable */
/* Rows are not clickable by default; only in-library rows navigate */
cursor: default;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.recipe-lora-item:hover {
/* Click affordance (pointer + hover lift) is reserved for rows that
actually navigate: in-library items open the local detail view. */
.recipe-lora-item.exists-locally {
cursor: pointer;
}
.recipe-lora-item.exists-locally:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-header);
border-color: var(--lora-accent);
}
.recipe-lora-item.exists-locally:focus-visible {
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
}
.recipe-lora-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.recipe-lora-item.checkpoint-item {
cursor: pointer;
cursor: default;
padding-top: 8px;
padding-bottom: 8px;
align-items: center;
align-items: flex-start;
}
.recipe-lora-item.checkpoint-item.exists-locally {
cursor: pointer;
}
.recipe-lora-item.missing-locally {
@@ -776,12 +792,24 @@
transform: translateZ(0);
}
.recipe-lora-title {
display: flex;
/* Top-align so the inline Civitai link stays glued to the FIRST line
even when a long model name wraps to two lines. */
align-items: flex-start;
gap: 6px;
flex: 1;
min-width: 0; /* Allow the clamped title to shrink next to the badge */
}
.recipe-lora-content h4 {
margin: 0;
font-size: 1em;
color: var(--text-color);
flex: 1;
max-width: calc(100% - 120px); /* Make room for the badge */
/* Shrink (for the 2-line clamp) but don't grow: the inline Civitai link
should sit right after the name, not pushed to the far edge. */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
@@ -838,16 +866,6 @@
backface-visibility: hidden;
}
/* Ensure local-path tooltip is properly positioned and won't move during scroll */
.recipe-lora-header .local-badge .local-path {
z-index: 3;
top: calc(100% + 4px); /* Position tooltip below the badge */
right: -4px; /* Align with the badge */
max-width: 250px;
/* Force hardware acceleration for Chrome */
transform: translateZ(0);
}
.missing-badge {
display: inline-flex;
align-items: center;
@@ -885,39 +903,8 @@
font-size: 0.9em;
}
/* Add reconnect functionality styles */
.deleted-badge.reconnectable {
position: relative;
cursor: pointer;
transition: background-color 0.2s ease;
}
.deleted-badge.reconnectable:hover {
background-color: var(--lora-accent);
}
.deleted-badge .reconnect-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;
top: calc(100% + 5px);
left: 0;
margin-left: -100px;
}
.deleted-badge.reconnectable:hover .reconnect-tooltip {
display: block;
}
/* Deleted badge is a pure status indicator; the reconnect action lives on
an explicit ghost button in the item's action row. */
/* LoRA reconnect container */
.lora-reconnect-container {
@@ -1131,11 +1118,10 @@
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
min-width: 110px;
z-index: 2;
}
/* Update the local-badge and missing-badge to be positioned within the badge-container */
/* Badges are pure status indicators; actions live in .recipe-lora-actions */
.badge-container .local-badge,
.badge-container .missing-badge,
.badge-container .deleted-badge {
@@ -1143,57 +1129,47 @@
transform: none; /* Remove the transform */
}
/* Ensure the tooltip is still properly positioned */
.badge-container .local-badge .local-path {
position: fixed; /* Keep as fixed for Chrome */
z-index: 100;
/* Tonal (soft) status badges: a tinted fill + colored text reads calmer
than solid blocks when several rows stack, and matches the tonal
"N missing" summary pill above the list. */
.badge-container .local-badge {
background: oklch(var(--lora-accent) / 0.12);
color: var(--lora-accent);
border: 1px solid oklch(var(--lora-accent) / 0.35);
}
.badge-container .resource-action {
margin-left: auto;
.badge-container .missing-badge {
background: oklch(var(--lora-error) / 0.14);
color: var(--lora-error);
border: 1px solid oklch(var(--lora-error) / 0.35);
}
/* Add styles for missing LoRAs download feature */
.recipe-status.missing {
.badge-container .deleted-badge {
background: rgba(127, 127, 127, 0.15);
color: var(--text-muted);
border: 1px solid rgba(127, 127, 127, 0.35);
}
/* 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 {
@@ -1205,11 +1181,11 @@
margin-bottom: 2px;
}
.recipe-checkpoint-meta .checkpoint-type {
background: var(--lora-surface);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
color: var(--text-color);
/* Checkpoint type is low-information text (the entry's position above the
divider already implies "checkpoint"), so it renders as plain muted text
instead of a chip competing with the base-model chip. */
.recipe-checkpoint-meta .checkpoint-type-text {
color: var(--text-muted);
}
.recipe-resource-actions {
@@ -1253,3 +1229,58 @@
.resource-action.primary:hover {
background: color-mix(in oklch, var(--lora-accent), black 10%);
}
/* Ghost variant: secondary remediation actions (e.g. Reconnect), matching
the ghost action pattern used in the versions tab. */
.resource-action.ghost {
background: transparent;
color: var(--lora-accent);
border-color: oklch(var(--lora-accent) / 0.4);
}
.resource-action.ghost:hover {
background: oklch(var(--lora-accent) / 0.1);
border-color: var(--lora-accent);
}
/* Per-item action row: remediation lives next to the status badge that
surfaced the problem (download / reconnect / external link). Right-aligned
so the reading order stays: name status meta actions. */
.recipe-lora-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
/* External-link affordance, mirroring .version-civitai-link in the
versions tab: leaving the app is always an explicit, signposted action. */
.recipe-civitai-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 999px;
color: var(--text-muted);
text-decoration: none;
flex: 0 0 auto;
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.recipe-civitai-link:hover,
.recipe-civitai-link:focus-visible {
color: var(--lora-accent);
background: color-mix(in oklch, var(--lora-accent) 12%, transparent);
transform: translateY(-1px);
outline: none;
}
/* In titles, size the icon box to the first line box (1em * 1.3 line-height)
so it aligns with the first line of both short and wrapped names. */
.recipe-lora-title .recipe-civitai-link {
width: 20px;
height: calc(1em * 1.3);
}
@@ -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) {
+20 -6
View File
@@ -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) {
+387 -134
View File
@@ -1,10 +1,11 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow } from '../api/recipeApi.js';
import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow, extractRecipeId } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
@@ -52,6 +53,18 @@ const PARAM_DISPLAY_NAMES = {
denoising_strength: 'Denoising Strength',
};
function escapeHtml(value) {
if (value == null) {
return '';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
class RecipeModal {
constructor() {
this.promptEditorState = {};
@@ -125,10 +138,6 @@ class RecipeModal {
this.setupPromptEditors();
this.setupNavigationControls();
this.setupDeleteControl();
// Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => {
this.setupTooltipPositioning();
});
// Set up document click handler to close edit fields
document.addEventListener('click', (event) => {
@@ -162,7 +171,7 @@ class RecipeModal {
reconnectContainers.forEach(container => {
if (container.classList.contains('active') &&
!container.contains(event.target) &&
!event.target.closest('.deleted-badge.reconnectable')) {
!event.target.closest('.lora-reconnect')) {
this.hideReconnectInput(container);
}
});
@@ -282,41 +291,6 @@ class RecipeModal {
}
}
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips
setupTooltipPositioning() {
document.addEventListener('mouseover', (event) => {
// Check if we're hovering over a local-badge
if (event.target.closest('.local-badge')) {
const badge = event.target.closest('.local-badge');
const tooltip = badge.querySelector('.local-path');
if (tooltip) {
// Get badge position
const badgeRect = badge.getBoundingClientRect();
// Position the tooltip
tooltip.style.top = (badgeRect.bottom + 4) + '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);
}
showRecipeDetails(recipe) {
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
@@ -872,30 +846,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);
}
@@ -903,28 +881,39 @@ class RecipeModal {
lorasListElement.innerHTML = loras.map(lora => {
const existsLocally = lora.inLibrary;
const isDeleted = lora.isDeleted;
const localPath = lora.localPath || '';
const loraIndex = loras.indexOf(lora);
let localStatus;
// Status badges are pure indicators (consistent with the
// versions-tab pattern): they never carry click behavior,
// only a tooltip. Remediation lives in the action row below.
let statusBadge;
if (existsLocally) {
localStatus = `
<div class="local-badge">
<i class="fas fa-check"></i> In Library
<div class="local-path">${localPath}</div>
statusBadge = `
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
</div>`;
} else if (isDeleted) {
localStatus = `
<div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
statusBadge = `
<div class="deleted-badge" title="${escapeHtml(translate('recipes.resources.deletedTooltip', {}, 'This LoRA was deleted from the source and is no longer available for download'))}">
<i class="fas fa-trash-alt" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
</div>`;
} else {
localStatus = `
<div class="missing-badge">
<i class="fas fa-exclamation-triangle"></i> Not in Library
statusBadge = `
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
</div>`;
}
const actionsRow = this.renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted });
// The Civitai link belongs to the model name (it answers
// "what is this"), so it sits inline in the title — the same
// pattern the versions tab uses — never in the action row.
// Skipped for deleted models: their source page is gone.
const titleLink = isDeleted
? ''
: this.renderCivitaiLink(this.getResourceCivitaiUrl(lora));
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
const previewMedia = isPreviewVideo ?
`<video class="thumbnail-video" autoplay loop muted playsinline>
@@ -941,22 +930,33 @@ class RecipeModal {
loraItemClass += ' missing-locally';
}
// Only in-library items are row-navigable (they open the local
// LoRA detail); make that affordance keyboard-accessible.
const rowA11yAttributes = existsLocally
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openLoraDetails', { name: lora.modelName }, `View ${lora.modelName} in the LoRA library`))}"`
: '';
return `
<div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
<div class="${loraItemClass}" data-lora-index="${loraIndex}"${rowA11yAttributes}>
<div class="recipe-lora-thumbnail">
${previewMedia}
</div>
<div class="recipe-lora-content">
<div class="recipe-lora-header">
<h4>${lora.modelName}</h4>
<div class="badge-container">${localStatus}</div>
<div class="recipe-lora-title">
<h4>${lora.modelName}</h4>
${titleLink}
</div>
<div class="badge-container">${statusBadge}</div>
</div>
<div class="recipe-lora-info">
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
</div>
<div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
${actionsRow}
${isDeleted ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions">
<p>Enter LoRA Syntax or Name to Reconnect:</p>
<small>Example: <code>&lt;lora:Boris_Vallejo_BV_flux_D:1&gt;</code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
@@ -968,7 +968,7 @@ class RecipeModal {
<button class="reconnect-confirm-btn">Reconnect</button>
</div>
</div>
</div>
</div>` : ''}
</div>
</div>
`;
@@ -976,6 +976,7 @@ class RecipeModal {
setTimeout(() => {
this.setupReconnectButtons();
this.setupLoraItemActions();
this.setupLoraItemsClickable();
}, 100);
@@ -1589,25 +1590,10 @@ class RecipeModal {
}
}
// New methods for reconnecting LoRAs
// Wire the reconnect form controls. The entry point is the explicit
// "Reconnect" ghost button rendered for deleted LoRAs (see
// setupLoraItemActions), keeping badges as pure status indicators.
setupReconnectButtons() {
// Add event listeners to all deleted badges
const deletedBadges = document.querySelectorAll('.deleted-badge.reconnectable');
deletedBadges.forEach(badge => {
badge.addEventListener('mouseenter', () => {
badge.querySelector('.badge-text').innerHTML = 'Reconnect';
});
badge.addEventListener('mouseleave', () => {
badge.querySelector('.badge-text').innerHTML = '<i class="fas fa-trash-alt"></i> Deleted';
});
badge.addEventListener('click', (e) => {
const loraIndex = badge.getAttribute('data-lora-index');
this.showReconnectInput(loraIndex);
});
});
// Add event listeners to reconnect cancel buttons
const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn');
cancelButtons.forEach(button => {
@@ -1745,49 +1731,67 @@ class RecipeModal {
</video>
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
// Status badge: pure indicator with a tooltip, mirroring the LoRA
// items and the versions-tab badge pattern. The header carries only
// the badge; every action lives in the bottom action row.
const badge = existsLocally ? `
<div class="local-badge">
<i class="fas fa-check"></i> In Library
<div class="local-path">${localPath}</div>
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
</div>
` : `
<div class="missing-badge">
<i class="fas fa-exclamation-triangle"></i> Not in Library
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
</div>
`;
let headerAction = '';
const actions = [];
if (existsLocally && localPath) {
headerAction = `
actions.push(`
<button class="resource-action primary compact checkpoint-send">
<i class="fas fa-paper-plane"></i>
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
</button>
`;
} else if (this.canDownloadCheckpoint(checkpoint)) {
headerAction = `
`);
} else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) {
actions.push(`
<button class="resource-action primary compact checkpoint-download">
<i class="fas fa-download"></i>
<span>${translate('modals.model.versions.actions.download', {}, 'Download')}</span>
</button>
`;
`);
}
const actionsMarkup = actions.filter(Boolean).join('');
const actionsRow = actionsMarkup
? `<div class="recipe-lora-actions">${actionsMarkup}</div>`
: '';
// Civitai link lives inline with the title, same as LoRA items.
const titleLink = this.renderCivitaiLink(this.getResourceCivitaiUrl(checkpoint));
// Only in-library checkpoints are row-navigable; make it keyboard-accessible.
const rowA11yAttributes = existsLocally
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openCheckpointDetails', { name: checkpointName }, `View ${checkpointName} in the model library`))}"`
: '';
return `
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : 'missing-locally'}">
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : 'missing-locally'}"${rowA11yAttributes}>
<div class="recipe-lora-thumbnail">
${previewMedia}
</div>
<div class="recipe-lora-content">
<div class="recipe-lora-header">
<h4>${checkpointName}</h4>
<div class="badge-container">${headerAction}</div>
<div class="recipe-lora-title">
<h4>${checkpointName}</h4>
${titleLink}
</div>
<div class="badge-container">${badge}</div>
</div>
<div class="recipe-lora-info recipe-checkpoint-meta">
${versionLabel ? `<div class="recipe-lora-version">${versionLabel}</div>` : ''}
${baseModel ? `<div class="base-model">${baseModel}</div>` : ''}
${modelTypeLabel ? `<div class="checkpoint-type">${modelTypeLabel}</div>` : ''}
${modelTypeLabel ? `<span class="checkpoint-type-text">${modelTypeLabel}</span>` : ''}
</div>
${actionsRow}
</div>
</div>
`;
@@ -1812,12 +1816,27 @@ class RecipeModal {
}
setupCheckpointNavigation(container, checkpoint) {
// Only in-library checkpoints navigate (to the local detail view).
// Missing checkpoints expose explicit Download / Civitai-link
// controls instead, so a row click never has two different outcomes.
if (!checkpoint.inLibrary) {
return;
}
const checkpointItem = container.querySelector('.checkpoint-item');
if (!checkpointItem) return;
checkpointItem.addEventListener('click', () => {
checkpointItem.addEventListener('click', (e) => {
if (e.target.closest('.resource-action') || e.target.closest('.recipe-civitai-link')) {
return;
}
this.navigateToCheckpointPage(checkpoint);
});
checkpointItem.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.navigateToCheckpointPage(checkpoint);
}
});
}
canDownloadCheckpoint(checkpoint) {
@@ -1889,7 +1908,7 @@ class RecipeModal {
}
try {
await downloadManager.downloadVersionWithDefaults(
const success = await downloadManager.downloadVersionWithDefaults(
MODEL_TYPES.CHECKPOINT,
modelId,
versionId,
@@ -1898,6 +1917,9 @@ class RecipeModal {
source: 'recipe-modal',
}
);
if (success) {
await this.refreshResourcesAfterDownload();
}
} catch (error) {
console.error('Error downloading checkpoint:', error);
showToast('toast.recipes.downloadCheckpointFailed', { message: error.message }, 'error');
@@ -1908,18 +1930,247 @@ class RecipeModal {
}
}
navigateToCheckpointPage(checkpoint) {
if (!checkpoint.inLibrary) {
const modelId = checkpoint.modelId || checkpoint.modelID || checkpoint.model_id;
const versionId = checkpoint.id || checkpoint.modelVersionId;
const modelName = checkpoint.name || checkpoint.modelName || checkpoint.file_name;
getResourceCivitaiUrl(resource) {
if (!resource) {
return null;
}
const modelId = resource.modelId || resource.modelID || resource.model_id || null;
const versionId = resource.id || resource.modelVersionId || null;
const modelName = resource.modelName || resource.name || resource.file_name || null;
return buildCivitaiUrl({
modelId,
versionId,
modelName,
host: state?.global?.settings?.civitai_host,
});
}
if (modelId || versionId || modelName) {
openCivitaiByMetadata(modelId, versionId, modelName);
return;
canDownloadLora(lora) {
if (!lora) return false;
const modelId = lora.modelId || lora.modelID || lora.model_id;
const versionId = lora.id || lora.modelVersionId;
// Direct download needs both identifiers; a hash alone is enough
// because downloadRecipeLora resolves it to a version on demand —
// the same fallback the bulk "download missing" flow uses.
return !!((modelId && versionId) || lora.hash);
}
renderCivitaiLink(url) {
if (!url) {
return '';
}
const tooltip = translate('recipes.resources.viewOnCivitai', {}, 'View on Civitai');
return `
<a
class="recipe-civitai-link"
href="${escapeHtml(url)}"
target="_blank"
rel="noopener noreferrer"
title="${escapeHtml(tooltip)}"
aria-label="${escapeHtml(tooltip)}"
>
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
</a>
`;
}
renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) {
// In-library LoRAs need no remediation: the badge and the local path
// already tell the full story.
if (existsLocally) {
return '';
}
const controls = [];
if (isDeleted) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
} else {
if (this.canDownloadLora(lora)) {
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(`
<button type="button" class="resource-action primary compact lora-download" data-lora-index="${loraIndex}"
title="${escapeHtml(downloadTooltip)}" aria-label="${escapeHtml(downloadTooltip)}">
<i class="fas fa-download" aria-hidden="true"></i>
<span>${escapeHtml(downloadLabel)}</span>
</button>
`);
}
}
const markup = controls.filter(Boolean).join('');
if (!markup) {
return '';
}
return `<div class="recipe-lora-actions">${markup}</div>`;
}
setupLoraItemActions() {
const lorasListElement = document.getElementById('recipeLorasList');
if (!lorasListElement) {
return;
}
// Deferred wiring can run again after a hydration re-render while the
// latest DOM is already in place; a data flag prevents stacking
// duplicate handlers (which would fire the download twice).
lorasListElement.querySelectorAll('.lora-download').forEach(button => {
if (button.dataset.wired === 'true') {
return;
}
button.dataset.wired = 'true';
button.addEventListener('click', (e) => {
e.stopPropagation();
const loraIndex = parseInt(button.dataset.loraIndex, 10);
const lora = this.currentRecipe?.loras?.[loraIndex];
if (lora) {
this.downloadRecipeLora(lora, button);
}
});
});
lorasListElement.querySelectorAll('.lora-reconnect').forEach(button => {
if (button.dataset.wired === 'true') {
return;
}
button.dataset.wired = 'true';
button.addEventListener('click', (e) => {
e.stopPropagation();
this.showReconnectInput(button.dataset.loraIndex);
});
});
}
/**
* Resolve the Civitai model/version identifiers needed for download.
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
* it through the same endpoint the bulk "download missing" flow uses.
*/
async resolveLoraDownloadIdentifiers(lora) {
let modelId = lora.modelId || lora.modelID || lora.model_id;
let versionId = lora.id || lora.modelVersionId;
let versionName = lora.modelVersionName || lora.modelName || lora.name || 'LoRA';
if (modelId && versionId) {
return { modelId, versionId, versionName };
}
if (!lora.hash) {
return null;
}
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
const versionInfo = await response.json();
if (versionInfo?.error) {
return null;
}
modelId = versionInfo.modelId || versionInfo.model?.id;
versionId = versionInfo.id;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
}
/**
* A completed download flips inLibrary flags server-side; re-fetch the
* recipe and reconcile both this modal's resources section and the
* recipe card on the listing page (mirrors the bulk download flow in
* BulkMissingLoraDownloadManager).
*/
async refreshResourcesAfterDownload() {
try {
const recipeId =
this.recipeId ||
extractRecipeId(this.listFilePath || this.currentRecipe?.file_path);
if (!recipeId) {
return;
}
const updated = await fetchRecipeDetails(recipeId);
if (!updated) {
return;
}
this.currentRecipe.loras = updated.loras ?? this.currentRecipe.loras;
this.currentRecipe.checkpoint = updated.checkpoint ?? this.currentRecipe.checkpoint;
this.syncResourcesSection(this.currentRecipe);
if (state.virtualScroller) {
state.virtualScroller.updateSingleItem(
this.listFilePath || this.currentRecipe.file_path,
updated
);
}
} catch (error) {
console.warn('Failed to refresh recipe resources after download:', error);
}
}
async downloadRecipeLora(lora, button) {
if (!this.canDownloadLora(lora)) {
showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error');
return;
}
if (button) {
button.disabled = true;
}
// Hash-only LoRAs need a network round trip to resolve identifiers
// before the progress UI can appear; show immediate feedback so the
// click never feels dead.
const hasDirectIds = !!(
(lora.modelId || lora.modelID || lora.model_id) &&
(lora.id || lora.modelVersionId)
);
if (!hasDirectIds) {
state.loadingManager.showSimpleLoading(
translate('recipes.resources.preparingDownload', {}, 'Preparing download...')
);
}
try {
const identifiers = await this.resolveLoraDownloadIdentifiers(lora);
if (!hasDirectIds) {
state.loadingManager.hide();
}
if (!identifiers) {
showToast('toast.recipes.missingLoraDownloadInfo', {}, 'error');
return;
}
const success = await downloadManager.downloadVersionWithDefaults(
MODEL_TYPES.LORA,
identifiers.modelId,
identifiers.versionId,
{
versionName: identifiers.versionName,
source: 'recipe-modal',
}
);
if (success) {
await this.refreshResourcesAfterDownload();
}
} catch (error) {
if (!hasDirectIds) {
state.loadingManager.hide();
}
console.error('Error downloading LoRA:', error);
showToast('toast.recipes.downloadLoraFailed', { message: error.message }, 'error');
} finally {
if (button) {
button.disabled = false;
}
}
}
navigateToCheckpointPage(checkpoint) {
const checkpointHash = this._getCheckpointHash(checkpoint);
if (!checkpointHash) {
@@ -1967,17 +2218,6 @@ class RecipeModal {
// If a specific LoRA index is provided, navigate to view just that one LoRA
const lora = this.currentRecipe.loras[specificLoraIndex];
if (lora && !lora.inLibrary) {
const modelId = lora.modelId || lora.modelID || lora.model_id;
const versionId = lora.id || lora.modelVersionId;
const modelName = lora.modelName || lora.name || lora.file_name;
if (modelId || versionId || modelName) {
openCivitaiByMetadata(modelId, versionId, modelName);
return;
}
}
if (lora && lora.hash) {
// Set session storage to open the LoRA modal directly
setSessionItem('recipe_to_lora_filterLoraHash', lora.hash.toLowerCase());
@@ -2002,24 +2242,37 @@ class RecipeModal {
window.location.href = '/loras';
}
// New method to make LoRA items clickable
// Only in-library LoRA items are row-navigable: the row opens the local
// LoRA detail. Missing/deleted rows expose explicit action buttons
// instead (download / reconnect / Civitai link), so a single gesture
// never produces two different outcomes.
setupLoraItemsClickable() {
const loraItems = document.querySelectorAll('.recipe-lora-item:not(.checkpoint-item)');
const loraItems = document.querySelectorAll('.recipe-lora-item.exists-locally:not(.checkpoint-item)');
loraItems.forEach(item => {
// Guard against duplicate wiring from deferred re-runs (see
// setupLoraItemActions).
if (item.dataset.navigationWired === 'true') {
return;
}
item.dataset.navigationWired = 'true';
// Get the lora index from the data attribute
const loraIndex = parseInt(item.dataset.loraIndex);
item.addEventListener('click', (e) => {
// If the click is on the reconnect container or badge, don't navigate
if (e.target.closest('.lora-reconnect-container') ||
e.target.closest('.deleted-badge') ||
e.target.closest('.reconnect-tooltip')) {
// The inline Civitai link inside the title keeps its own
// navigation; don't let it trigger the row navigation.
if (e.target.closest('.recipe-civitai-link')) {
return;
}
// Navigate to the LoRAs page with the specific LoRA index
this.navigateToLorasPage(loraIndex);
});
item.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.navigateToLorasPage(loraIndex);
}
});
});
}
}
@@ -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();
});
});
@@ -0,0 +1,435 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => {
if (typeof fallback !== 'string') {
return key;
}
return fallback.replace(/\{(\w+)\}/g, (match, name) =>
params && params[name] != null ? String(params[name]) : match
);
});
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(),
};
const downloadVersionWithDefaultsMock = vi.fn(() => Promise.resolve());
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(),
getSessionItem: vi.fn(() => null),
getStorageItem: vi.fn(() => null),
setStorageItem: vi.fn(),
removeStorageItem: vi.fn(),
}));
const fetchRecipeDetailsMock = vi.fn(() => Promise.resolve({}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipeDetails: fetchRecipeDetailsMock,
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
sendRecipeWorkflow: vi.fn(),
extractRecipeId: (filePath) => {
if (!filePath) return null;
const basename = filePath.split('/').pop().split('\\').pop();
const dotIndex = basename.lastIndexOf('.');
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
},
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
},
}));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
downloadManager: {
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
},
}));
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 id="recipeCheckpoint"></div>
<div id="recipeResourceDivider"></div>
<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 missingLora = {
name: 'gone-lora',
modelName: 'Gone LoRA',
inLibrary: false,
modelId: 123,
id: 456,
};
const hashOnlyLora = {
name: 'hash-lora',
modelName: 'Hash LoRA',
inLibrary: false,
hash: 'deadbeefcafe',
};
const recipeWithResources = {
id: 'recipe-resources',
file_path: '/recipes/resources.json',
title: 'Resource Recipe',
tags: [],
checkpoint: {
name: 'gone-checkpoint',
inLibrary: false,
modelId: 900,
id: 901,
},
loras: [
{ name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' },
missingLora,
{ name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true },
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
hashOnlyLora,
],
};
const createdModals = [];
async function createRecipeModal() {
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
const recipeModal = new RecipeModal();
createdModals.push(recipeModal);
return recipeModal;
}
function flushWiring() {
// Item action/navigation wiring happens in a setTimeout after rendering
return new Promise(resolve => setTimeout(resolve, 150));
}
describe('RecipeModal resource item interactions', () => {
beforeEach(() => {
vi.clearAllMocks();
// clearAllMocks keeps one-off mockResolvedValue implementations; reset
// the shared mocks to their defaults explicitly.
downloadVersionWithDefaultsMock.mockReset();
downloadVersionWithDefaultsMock.mockResolvedValue(undefined);
fetchRecipeDetailsMock.mockReset();
// Hydration re-fetches the recipe right after render; resolving an empty
// object would delete currentRecipe.loras and wipe the list, so resolve
// the full recipe by default (individual tests can override afterwards).
fetchRecipeDetailsMock.mockResolvedValue(recipeWithResources);
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 badge as a pure status indicator with a tooltip', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
const badge = document.querySelector('.recipe-lora-item.missing-locally .missing-badge');
expect(badge).not.toBeNull();
expect(badge.tagName).toBe('DIV');
expect(badge.title).toBe('This model is not in your library');
expect(badge.classList.contains('reconnectable')).toBe(false);
expect(badge.textContent).toContain('Not in Library');
});
it('renders an explicit download action and an inline title link for a missing LoRA', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
const item = document.querySelector('.recipe-lora-item.missing-locally:not(.checkpoint-item)');
// The Civitai link belongs to the title, not the action row
const link = item.querySelector('.recipe-lora-title a.recipe-civitai-link');
expect(link).not.toBeNull();
expect(link.target).toBe('_blank');
expect(link.rel).toContain('noopener');
expect(link.href).toBe('https://civitai.com/models/123?modelVersionId=456');
expect(link.title).toBe('View on Civitai');
const actions = item.querySelector('.recipe-lora-actions');
expect(actions).not.toBeNull();
const downloadButton = actions.querySelector('.lora-download');
expect(downloadButton).not.toBeNull();
expect(downloadButton.tagName).toBe('BUTTON');
expect(downloadButton.type).toBe('button');
expect(downloadButton.classList.contains('resource-action')).toBe(true);
expect(downloadButton.classList.contains('primary')).toBe(true);
expect(downloadButton.title).toBe('Download this LoRA');
// The action row holds only real actions; no link icon in it
expect(actions.querySelector('.recipe-civitai-link')).toBeNull();
});
it('downloads only the clicked LoRA via the download manager', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const downloadButton = document.querySelector('.lora-download');
downloadButton.click();
await vi.waitFor(() => {
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
});
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith(
'loras',
123,
456,
expect.objectContaining({ source: 'recipe-modal' })
);
});
it('does not navigate when a missing LoRA row is clicked', async () => {
const recipeModal = await createRecipeModal();
const navigateSpy = vi
.spyOn(recipeModal, 'navigateToLorasPage')
.mockImplementation(() => {});
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const missingItem = document.querySelector('.recipe-lora-item.missing-locally');
expect(missingItem.getAttribute('role')).toBeNull();
expect(missingItem.getAttribute('tabindex')).toBeNull();
missingItem.click();
expect(navigateSpy).not.toHaveBeenCalled();
});
it('keeps in-library rows navigable and keyboard accessible', async () => {
const recipeModal = await createRecipeModal();
const navigateSpy = vi
.spyOn(recipeModal, 'navigateToLorasPage')
.mockImplementation(() => {});
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const localItem = document.querySelector('.recipe-lora-item.exists-locally:not(.checkpoint-item)');
expect(localItem.getAttribute('role')).toBe('button');
expect(localItem.getAttribute('tabindex')).toBe('0');
localItem.click();
expect(navigateSpy).toHaveBeenCalledWith(0);
navigateSpy.mockClear();
localItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
expect(navigateSpy).toHaveBeenCalledWith(0);
});
it('renders deleted LoRAs with a status badge and an explicit reconnect button', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const deletedItem = document.querySelector('.recipe-lora-item.is-deleted');
const badge = deletedItem.querySelector('.deleted-badge');
expect(badge).not.toBeNull();
expect(badge.classList.contains('reconnectable')).toBe(false);
expect(badge.title).toContain('deleted from the source');
const reconnectButton = deletedItem.querySelector('.lora-reconnect');
expect(reconnectButton).not.toBeNull();
expect(reconnectButton.tagName).toBe('BUTTON');
expect(reconnectButton.classList.contains('ghost')).toBe(true);
reconnectButton.click();
const container = deletedItem.querySelector('.lora-reconnect-container');
expect(container.classList.contains('active')).toBe(true);
});
it('renders no action row when neither identifiers nor hash are available', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
const mysteryItem = document.querySelector('[data-lora-index="3"]');
expect(mysteryItem.querySelector('.lora-download')).toBeNull();
// No actions at all -> no empty action row taking vertical space
expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull();
// The name-fallback search link still sits inline in the title
const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link');
expect(link).not.toBeNull();
expect(link.href).toContain('query=Mystery%20LoRA');
});
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
const recipeModal = await createRecipeModal();
global.fetch = vi.fn(async (url) => ({
ok: true,
json: async () =>
typeof url === 'string' && url.includes('/civitai/model/hash/')
? { id: 789, modelId: 777, name: 'Hash LoRA v1' }
: {},
}));
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const hashItem = document.querySelector('[data-lora-index="4"]');
const downloadButton = hashItem.querySelector('.lora-download');
expect(downloadButton).not.toBeNull();
downloadButton.click();
await vi.waitFor(() => {
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
});
// Hash resolution needs a network round trip; immediate feedback must
// appear while the user waits for the progress UI
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Preparing download...');
expect(loadingManagerStub.hide).toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/loras/civitai/model/hash/deadbeefcafe'
);
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith(
'loras',
777,
789,
expect.objectContaining({ source: 'recipe-modal', versionName: 'Hash LoRA v1' })
);
});
it('refreshes the resources section and the recipe card after a successful download', async () => {
const recipeModal = await createRecipeModal();
downloadVersionWithDefaultsMock.mockResolvedValue(true);
const updatedRecipe = {
...recipeWithResources,
loras: recipeWithResources.loras.map((lora, index) =>
index === 1 ? { ...lora, inLibrary: true } : lora
),
};
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
fetchRecipeDetailsMock.mockResolvedValue(updatedRecipe);
const downloadButton = document.querySelector('.lora-download');
downloadButton.click();
await vi.waitFor(() => {
expect(fetchRecipeDetailsMock).toHaveBeenCalledWith('recipe-resources');
});
// The recipe card on the listing page receives the fresh recipe data
await vi.waitFor(() => {
expect(virtualScrollerStub.updateSingleItem).toHaveBeenCalledWith(
'/recipes/resources.json',
updatedRecipe
);
});
// The modal's resources section re-renders with the flipped status
await vi.waitFor(() => {
const item = document.querySelector('[data-lora-index="1"]');
expect(item.classList.contains('exists-locally')).toBe(true);
expect(item.querySelector('.local-badge')).not.toBeNull();
expect(item.querySelector('.missing-badge')).toBeNull();
});
});
it('shows a status badge, download action and Civitai link for a missing checkpoint without row navigation', async () => {
const recipeModal = await createRecipeModal();
const navigateSpy = vi
.spyOn(recipeModal, 'navigateToCheckpointPage')
.mockImplementation(() => {});
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const checkpointItem = document.querySelector('.checkpoint-item');
expect(checkpointItem.classList.contains('missing-locally')).toBe(true);
expect(checkpointItem.getAttribute('role')).toBeNull();
expect(checkpointItem.querySelector('.missing-badge')).not.toBeNull();
expect(checkpointItem.querySelector('.checkpoint-download')).not.toBeNull();
const link = checkpointItem.querySelector('.recipe-lora-title a.recipe-civitai-link');
expect(link).not.toBeNull();
expect(link.href).toBe('https://civitai.com/models/900?modelVersionId=901');
checkpointItem.click();
expect(navigateSpy).not.toHaveBeenCalled();
});
});