diff --git a/locales/de.json b/locales/de.json
index 479ca126..17d24aa9 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -885,6 +885,22 @@
"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",
@@ -2029,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",
diff --git a/locales/en.json b/locales/en.json
index a1e553a8..2006a378 100644
--- a/locales/en.json
+++ b/locales/en.json
@@ -885,6 +885,22 @@
"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",
@@ -2029,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",
diff --git a/locales/es.json b/locales/es.json
index 5eab91d1..80d4c4e5 100644
--- a/locales/es.json
+++ b/locales/es.json
@@ -885,6 +885,22 @@
"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",
@@ -2029,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",
diff --git a/locales/fr.json b/locales/fr.json
index 771401ac..df22bb5b 100644
--- a/locales/fr.json
+++ b/locales/fr.json
@@ -885,6 +885,22 @@
"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",
@@ -2029,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",
diff --git a/locales/he.json b/locales/he.json
index 23ab84ba..52a42a92 100644
--- a/locales/he.json
+++ b/locales/he.json
@@ -885,6 +885,22 @@
"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": "ייבא",
@@ -2029,6 +2045,8 @@
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
"missingCheckpointInfo": "חסרים פרטי checkpoint",
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
+ "missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה",
+ "downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
"cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון",
"deleteConfirmationError": "שגיאה בהצגת אישור המחיקה",
"deletedSuccessfully": "המתכון נמחק בהצלחה",
diff --git a/locales/ja.json b/locales/ja.json
index 9edb534f..b19664dd 100644
--- a/locales/ja.json
+++ b/locales/ja.json
@@ -885,6 +885,22 @@
"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": "インポート",
@@ -2029,6 +2045,8 @@
"missingCheckpointPath": "チェックポイントのパスがありません",
"missingCheckpointInfo": "チェックポイント情報が不足しています",
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
+ "missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
+ "downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
"cannotDelete": "レシピを削除できません:レシピIDがありません",
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
"deletedSuccessfully": "レシピが正常に削除されました",
diff --git a/locales/ko.json b/locales/ko.json
index e9194474..9951a293 100644
--- a/locales/ko.json
+++ b/locales/ko.json
@@ -885,6 +885,22 @@
"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": "가져오기",
@@ -2029,6 +2045,8 @@
"missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다",
"missingCheckpointInfo": "체크포인트 정보가 부족합니다",
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
+ "missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
+ "downloadLoraFailed": "LoRA 다운로드 실패: {message}",
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
"deleteConfirmationError": "삭제 확인 표시 오류",
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
diff --git a/locales/ru.json b/locales/ru.json
index dc052b22..fd89309c 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -885,6 +885,22 @@
"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": "Импортировать",
@@ -2029,6 +2045,8 @@
"missingCheckpointPath": "Путь к чекпойнту недоступен",
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
+ "missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
+ "downloadLoraFailed": "Не удалось скачать LoRA: {message}",
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
"deleteConfirmationError": "Ошибка отображения подтверждения удаления",
"deletedSuccessfully": "Рецепт успешно удален",
diff --git a/locales/zh-CN.json b/locales/zh-CN.json
index a2b45225..fc8cd036 100644
--- a/locales/zh-CN.json
+++ b/locales/zh-CN.json
@@ -885,6 +885,22 @@
"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": "导入",
@@ -2029,6 +2045,8 @@
"missingCheckpointPath": "缺少检查点路径",
"missingCheckpointInfo": "缺少检查点信息",
"downloadCheckpointFailed": "下载检查点失败:{message}",
+ "missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
+ "downloadLoraFailed": "下载 LoRA 失败:{message}",
"cannotDelete": "无法删除配方:缺少配方 ID",
"deleteConfirmationError": "显示删除确认出错",
"deletedSuccessfully": "配方删除成功",
diff --git a/locales/zh-TW.json b/locales/zh-TW.json
index ad79bee8..ea56ad75 100644
--- a/locales/zh-TW.json
+++ b/locales/zh-TW.json
@@ -885,6 +885,22 @@
"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": "匯入",
@@ -2029,6 +2045,8 @@
"missingCheckpointPath": "缺少檢查點路徑",
"missingCheckpointInfo": "缺少檢查點資訊",
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
+ "missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
+ "downloadLoraFailed": "下載 LoRA 失敗:{message}",
"cannotDelete": "無法刪除配方:缺少配方 ID",
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
"deletedSuccessfully": "配方已成功刪除",
diff --git a/static/css/components/recipe-modal.css b/static/css/components/recipe-modal.css
index 32300415..6078d3b6 100644
--- a/static/css/components/recipe-modal.css
+++ b/static/css/components/recipe-modal.css
@@ -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,14 +1129,25 @@
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);
+}
+
+.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
@@ -1184,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 {
@@ -1232,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);
+}
diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js
index aedff055..c6882560 100644
--- a/static/js/components/RecipeModal.js
+++ b/static/js/components/RecipeModal.js
@@ -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, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
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,26 +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';
- }
- }
- }, true);
- }
-
showRecipeDetails(recipe) {
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
@@ -892,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 = `
-
-
In Library
-
${localPath}
+ statusBadge = `
+
+ ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
`;
} else if (isDeleted) {
- localStatus = `
-
-
Deleted
-
Click to reconnect with a local LoRA
+ statusBadge = `
+
+ ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
`;
} else {
- localStatus = `
-
-
Not in Library
+ statusBadge = `
+
+ ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
`;
}
+ 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 ?
`
@@ -930,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 `
-
+
${previewMedia}
${lora.modelVersionName ? `
${lora.modelVersionName}
` : ''}
Weight: ${lora.strength || 1.0}
${lora.baseModel ? `
${lora.baseModel}
` : ''}
-
+ ${actionsRow}
+ ${isDeleted ? `
+
Enter LoRA Syntax or Name to Reconnect:
Example: <lora:Boris_Vallejo_BV_flux_D:1> or just Boris_Vallejo_BV_flux_D
@@ -957,7 +968,7 @@ class RecipeModal {
Reconnect
-
+
` : ''}
`;
@@ -965,6 +976,7 @@ class RecipeModal {
setTimeout(() => {
this.setupReconnectButtons();
+ this.setupLoraItemActions();
this.setupLoraItemsClickable();
}, 100);
@@ -1578,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 = ' 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 => {
@@ -1734,49 +1731,67 @@ class RecipeModal {
` : `
`;
+ // 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 ? `
-
-
In Library
-
${localPath}
+
+ ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
` : `
-
-
Not in Library
+
+ ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
`;
- let headerAction = '';
+ const actions = [];
if (existsLocally && localPath) {
- headerAction = `
+ actions.push(`
${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}
- `;
- } else if (this.canDownloadCheckpoint(checkpoint)) {
- headerAction = `
+ `);
+ } else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) {
+ actions.push(`
${translate('modals.model.versions.actions.download', {}, 'Download')}
- `;
+ `);
}
+ const actionsMarkup = actions.filter(Boolean).join('');
+ const actionsRow = actionsMarkup
+ ? `
${actionsMarkup}
`
+ : '';
+
+ // 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 `
-
+
`;
@@ -1801,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) {
@@ -1878,7 +1908,7 @@ class RecipeModal {
}
try {
- await downloadManager.downloadVersionWithDefaults(
+ const success = await downloadManager.downloadVersionWithDefaults(
MODEL_TYPES.CHECKPOINT,
modelId,
versionId,
@@ -1887,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');
@@ -1897,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 `
+
+
+
+ `;
+ }
+
+ 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(`
+
+
+ ${escapeHtml(reconnectLabel)}
+
+ `);
+ } else {
+ if (this.canDownloadLora(lora)) {
+ const downloadLabel = translate('recipes.resources.download', {}, 'Download');
+ const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
+ controls.push(`
+
+
+ ${escapeHtml(downloadLabel)}
+
+ `);
}
}
+ const markup = controls.filter(Boolean).join('');
+ if (!markup) {
+ return '';
+ }
+ return `
${markup}
`;
+ }
+
+ 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) {
@@ -1956,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());
@@ -1991,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);
+ }
+ });
});
}
}
diff --git a/tests/frontend/components/recipeModal.resourceItems.test.js b/tests/frontend/components/recipeModal.resourceItems.test.js
new file mode 100644
index 00000000..e7b423eb
--- /dev/null
+++ b/tests/frontend/components/recipeModal.resourceItems.test.js
@@ -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 `
+
+ `;
+}
+
+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();
+ });
+});