From a7d65fe84a5d6036ed6b272d61a027f7f9063850 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Fri, 28 Aug 2026 09:30:37 +0800 Subject: [PATCH] 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 --- locales/de.json | 18 + locales/en.json | 18 + locales/es.json | 18 + locales/fr.json | 18 + locales/he.json | 18 + locales/ja.json | 18 + locales/ko.json | 18 + locales/ru.json | 18 + locales/zh-CN.json | 18 + locales/zh-TW.json | 18 + static/css/components/recipe-modal.css | 176 ++++--- static/js/components/RecipeModal.js | 478 ++++++++++++++---- .../recipeModal.resourceItems.test.js | 435 ++++++++++++++++ 13 files changed, 1100 insertions(+), 169 deletions(-) create mode 100644 tests/frontend/components/recipeModal.resourceItems.test.js 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 ? ` ` : `Checkpoint preview`; + // 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(` - `; - } else if (this.canDownloadCheckpoint(checkpoint)) { - headerAction = ` + `); + } else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) { + actions.push(` - `; + `); } + 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 ` -
+
${previewMedia}
-

${checkpointName}

-
${headerAction}
+
+

${checkpointName}

+ ${titleLink} +
+
${badge}
${versionLabel ? `
${versionLabel}
` : ''} ${baseModel ? `
${baseModel}
` : ''} - ${modelTypeLabel ? `
${modelTypeLabel}
` : ''} + ${modelTypeLabel ? `${modelTypeLabel}` : ''}
+ ${actionsRow}
`; @@ -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(` + + `); + } else { + if (this.canDownloadLora(lora)) { + const downloadLabel = translate('recipes.resources.download', {}, 'Download'); + const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA'); + controls.push(` + + `); } } + 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(); + }); +});