mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 16:31:28 -03:00
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
This commit is contained in:
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "Alle LoRAs verfügbar - Einsatzbereit",
|
"allAvailable": "Alle LoRAs verfügbar - Einsatzbereit",
|
||||||
"missing": "{missing} von {total} LoRAs fehlen"
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Importieren",
|
"action": "Importieren",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
"missingCheckpointPath": "Checkpoint-Pfad nicht verfügbar",
|
||||||
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
"missingCheckpointInfo": "Checkpoint-Informationen fehlen",
|
||||||
"downloadCheckpointFailed": "Checkpoint-Download fehlgeschlagen: {message}",
|
"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",
|
"cannotDelete": "Kann Rezept nicht löschen: Fehlende Rezept-ID",
|
||||||
"deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung",
|
"deleteConfirmationError": "Fehler beim Anzeigen der Löschbestätigung",
|
||||||
"deletedSuccessfully": "Rezept erfolgreich gelöscht",
|
"deletedSuccessfully": "Rezept erfolgreich gelöscht",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "All LoRAs available - Ready to use",
|
"allAvailable": "All LoRAs available - Ready to use",
|
||||||
"missing": "{missing} of {total} LoRAs missing"
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Import",
|
"action": "Import",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "Checkpoint path not available",
|
"missingCheckpointPath": "Checkpoint path not available",
|
||||||
"missingCheckpointInfo": "Missing checkpoint information",
|
"missingCheckpointInfo": "Missing checkpoint information",
|
||||||
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
|
"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",
|
"cannotDelete": "Cannot delete recipe: Missing recipe ID",
|
||||||
"deleteConfirmationError": "Error showing delete confirmation",
|
"deleteConfirmationError": "Error showing delete confirmation",
|
||||||
"deletedSuccessfully": "Recipe deleted successfully",
|
"deletedSuccessfully": "Recipe deleted successfully",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "Todos los LoRAs disponibles - Listo para usar",
|
"allAvailable": "Todos los LoRAs disponibles - Listo para usar",
|
||||||
"missing": "Faltan {missing} de {total} LoRAs"
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Importar",
|
"action": "Importar",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
"missingCheckpointPath": "Ruta del checkpoint no disponible",
|
||||||
"missingCheckpointInfo": "Falta información del checkpoint",
|
"missingCheckpointInfo": "Falta información del checkpoint",
|
||||||
"downloadCheckpointFailed": "Error al descargar el checkpoint: {message}",
|
"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",
|
"cannotDelete": "No se puede eliminar receta: Falta ID de receta",
|
||||||
"deleteConfirmationError": "Error mostrando confirmación de eliminación",
|
"deleteConfirmationError": "Error mostrando confirmación de eliminación",
|
||||||
"deletedSuccessfully": "Receta eliminada exitosamente",
|
"deletedSuccessfully": "Receta eliminada exitosamente",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "Tous les LoRAs sont disponibles - Prêt à l'emploi",
|
"allAvailable": "Tous les LoRAs sont disponibles - Prêt à l'emploi",
|
||||||
"missing": "{missing} LoRA(s) manquant(s) sur {total}"
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Importer",
|
"action": "Importer",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
"missingCheckpointPath": "Chemin du checkpoint indisponible",
|
||||||
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
"missingCheckpointInfo": "Informations sur le checkpoint manquantes",
|
||||||
"downloadCheckpointFailed": "Échec du téléchargement du checkpoint : {message}",
|
"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",
|
"cannotDelete": "Impossible de supprimer la recipe : ID de recipe manquant",
|
||||||
"deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression",
|
"deleteConfirmationError": "Erreur lors de l'affichage de la confirmation de suppression",
|
||||||
"deletedSuccessfully": "Recipe supprimée avec succès",
|
"deletedSuccessfully": "Recipe supprimée avec succès",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "כל ה-LoRAs זמינים - מוכן לשימוש",
|
"allAvailable": "כל ה-LoRAs זמינים - מוכן לשימוש",
|
||||||
"missing": "{missing} מתוך {total} 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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "ייבא",
|
"action": "ייבא",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
|
"missingCheckpointPath": "נתיב ה-checkpoint אינו זמין",
|
||||||
"missingCheckpointInfo": "חסרים פרטי checkpoint",
|
"missingCheckpointInfo": "חסרים פרטי checkpoint",
|
||||||
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
|
"downloadCheckpointFailed": "הורדת checkpoint נכשלה: {message}",
|
||||||
|
"missingLoraDownloadInfo": "חסר מידע הורדה עבור LoRA זה",
|
||||||
|
"downloadLoraFailed": "הורדת ה-LoRA נכשלה: {message}",
|
||||||
"cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון",
|
"cannotDelete": "לא ניתן למחוק מתכון: חסר מזהה מתכון",
|
||||||
"deleteConfirmationError": "שגיאה בהצגת אישור המחיקה",
|
"deleteConfirmationError": "שגיאה בהצגת אישור המחיקה",
|
||||||
"deletedSuccessfully": "המתכון נמחק בהצלחה",
|
"deletedSuccessfully": "המתכון נמחק בהצלחה",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
|
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
|
||||||
"missing": "{total} 件中 {missing} 件の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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "インポート",
|
"action": "インポート",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "チェックポイントのパスがありません",
|
"missingCheckpointPath": "チェックポイントのパスがありません",
|
||||||
"missingCheckpointInfo": "チェックポイント情報が不足しています",
|
"missingCheckpointInfo": "チェックポイント情報が不足しています",
|
||||||
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
|
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
|
||||||
|
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
|
||||||
|
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
|
||||||
"cannotDelete": "レシピを削除できません:レシピIDがありません",
|
"cannotDelete": "レシピを削除できません:レシピIDがありません",
|
||||||
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
|
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
|
||||||
"deletedSuccessfully": "レシピが正常に削除されました",
|
"deletedSuccessfully": "レシピが正常に削除されました",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능",
|
"allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능",
|
||||||
"missing": "총 {total}개 중 {missing}개 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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "가져오기",
|
"action": "가져오기",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다",
|
"missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다",
|
||||||
"missingCheckpointInfo": "체크포인트 정보가 부족합니다",
|
"missingCheckpointInfo": "체크포인트 정보가 부족합니다",
|
||||||
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
|
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
|
||||||
|
"missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
|
||||||
|
"downloadLoraFailed": "LoRA 다운로드 실패: {message}",
|
||||||
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
|
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
|
||||||
"deleteConfirmationError": "삭제 확인 표시 오류",
|
"deleteConfirmationError": "삭제 확인 표시 오류",
|
||||||
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
|
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "Все LoRAs доступны - Готово к использованию",
|
"allAvailable": "Все LoRAs доступны - Готово к использованию",
|
||||||
"missing": "Отсутствует {missing} из {total} 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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "Импортировать",
|
"action": "Импортировать",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
"missingCheckpointPath": "Путь к чекпойнту недоступен",
|
||||||
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
"missingCheckpointInfo": "Отсутствуют данные о чекпойнте",
|
||||||
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
"downloadCheckpointFailed": "Не удалось скачать чекпойнт: {message}",
|
||||||
|
"missingLoraDownloadInfo": "Нет информации для скачивания этого LoRA",
|
||||||
|
"downloadLoraFailed": "Не удалось скачать LoRA: {message}",
|
||||||
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
|
"cannotDelete": "Невозможно удалить рецепт: отсутствует ID рецепта",
|
||||||
"deleteConfirmationError": "Ошибка отображения подтверждения удаления",
|
"deleteConfirmationError": "Ошибка отображения подтверждения удаления",
|
||||||
"deletedSuccessfully": "Рецепт успешно удален",
|
"deletedSuccessfully": "Рецепт успешно удален",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
|
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
|
||||||
"missing": "{total} 个 LoRA 中缺失 {missing} 个"
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "导入",
|
"action": "导入",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "缺少检查点路径",
|
"missingCheckpointPath": "缺少检查点路径",
|
||||||
"missingCheckpointInfo": "缺少检查点信息",
|
"missingCheckpointInfo": "缺少检查点信息",
|
||||||
"downloadCheckpointFailed": "下载检查点失败:{message}",
|
"downloadCheckpointFailed": "下载检查点失败:{message}",
|
||||||
|
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
|
||||||
|
"downloadLoraFailed": "下载 LoRA 失败:{message}",
|
||||||
"cannotDelete": "无法删除配方:缺少配方 ID",
|
"cannotDelete": "无法删除配方:缺少配方 ID",
|
||||||
"deleteConfirmationError": "显示删除确认出错",
|
"deleteConfirmationError": "显示删除确认出错",
|
||||||
"deletedSuccessfully": "配方删除成功",
|
"deletedSuccessfully": "配方删除成功",
|
||||||
|
|||||||
@@ -885,6 +885,22 @@
|
|||||||
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
|
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
|
||||||
"missing": "{total} 個 LoRA 中缺少 {missing} 個"
|
"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": {
|
"controls": {
|
||||||
"import": {
|
"import": {
|
||||||
"action": "匯入",
|
"action": "匯入",
|
||||||
@@ -2029,6 +2045,8 @@
|
|||||||
"missingCheckpointPath": "缺少檢查點路徑",
|
"missingCheckpointPath": "缺少檢查點路徑",
|
||||||
"missingCheckpointInfo": "缺少檢查點資訊",
|
"missingCheckpointInfo": "缺少檢查點資訊",
|
||||||
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
|
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
|
||||||
|
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
|
||||||
|
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
|
||||||
"cannotDelete": "無法刪除配方:缺少配方 ID",
|
"cannotDelete": "無法刪除配方:缺少配方 ID",
|
||||||
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
|
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
|
||||||
"deletedSuccessfully": "配方已成功刪除",
|
"deletedSuccessfully": "配方已成功刪除",
|
||||||
|
|||||||
@@ -700,26 +700,42 @@
|
|||||||
will-change: transform;
|
will-change: transform;
|
||||||
/* Create a new containing block for absolutely positioned descendants */
|
/* Create a new containing block for absolutely positioned descendants */
|
||||||
transform: translateZ(0);
|
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;
|
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);
|
transform: translateY(-1px);
|
||||||
box-shadow: var(--shadow-header);
|
box-shadow: var(--shadow-header);
|
||||||
border-color: var(--lora-accent);
|
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 {
|
.recipe-lora-item.exists-locally {
|
||||||
background: oklch(var(--lora-accent) / 0.05);
|
background: oklch(var(--lora-accent) / 0.05);
|
||||||
border-left: 4px solid var(--lora-accent);
|
border-left: 4px solid var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-lora-item.checkpoint-item {
|
.recipe-lora-item.checkpoint-item {
|
||||||
cursor: pointer;
|
cursor: default;
|
||||||
padding-top: 8px;
|
padding-top: 8px;
|
||||||
padding-bottom: 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 {
|
.recipe-lora-item.missing-locally {
|
||||||
@@ -776,12 +792,24 @@
|
|||||||
transform: translateZ(0);
|
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 {
|
.recipe-lora-content h4 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
flex: 1;
|
/* Shrink (for the 2-line clamp) but don't grow: the inline Civitai link
|
||||||
max-width: calc(100% - 120px); /* Make room for the badge */
|
should sit right after the name, not pushed to the far edge. */
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -838,16 +866,6 @@
|
|||||||
backface-visibility: hidden;
|
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 {
|
.missing-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -885,39 +903,8 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add reconnect functionality styles */
|
/* Deleted badge is a pure status indicator; the reconnect action lives on
|
||||||
.deleted-badge.reconnectable {
|
an explicit ghost button in the item's action row. */
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LoRA reconnect container */
|
/* LoRA reconnect container */
|
||||||
.lora-reconnect-container {
|
.lora-reconnect-container {
|
||||||
@@ -1131,11 +1118,10 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 110px;
|
|
||||||
z-index: 2;
|
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 .local-badge,
|
||||||
.badge-container .missing-badge,
|
.badge-container .missing-badge,
|
||||||
.badge-container .deleted-badge {
|
.badge-container .deleted-badge {
|
||||||
@@ -1143,14 +1129,25 @@
|
|||||||
transform: none; /* Remove the transform */
|
transform: none; /* Remove the transform */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ensure the tooltip is still properly positioned */
|
/* Tonal (soft) status badges: a tinted fill + colored text reads calmer
|
||||||
.badge-container .local-badge .local-path {
|
than solid blocks when several rows stack, and matches the tonal
|
||||||
position: fixed; /* Keep as fixed for Chrome */
|
"N missing" summary pill above the list. */
|
||||||
z-index: 100;
|
.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 {
|
.badge-container .missing-badge {
|
||||||
margin-left: auto;
|
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
|
/* Missing LoRAs status is a real button: the affordance must be visible at
|
||||||
@@ -1184,11 +1181,11 @@
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-checkpoint-meta .checkpoint-type {
|
/* Checkpoint type is low-information text (the entry's position above the
|
||||||
background: var(--lora-surface);
|
divider already implies "checkpoint"), so it renders as plain muted text
|
||||||
padding: 2px 8px;
|
instead of a chip competing with the base-model chip. */
|
||||||
border-radius: var(--border-radius-xs);
|
.recipe-checkpoint-meta .checkpoint-type-text {
|
||||||
color: var(--text-color);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recipe-resource-actions {
|
.recipe-resource-actions {
|
||||||
@@ -1232,3 +1229,58 @@
|
|||||||
.resource-action.primary:hover {
|
.resource-action.primary:hover {
|
||||||
background: color-mix(in oklch, var(--lora-accent), black 10%);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+368
-104
@@ -1,10 +1,11 @@
|
|||||||
// Recipe Modal Component
|
// 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 { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||||
|
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.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 { downloadManager } from '../managers/DownloadManager.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||||
@@ -52,6 +53,18 @@ const PARAM_DISPLAY_NAMES = {
|
|||||||
denoising_strength: 'Denoising Strength',
|
denoising_strength: 'Denoising Strength',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
if (value == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
class RecipeModal {
|
class RecipeModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.promptEditorState = {};
|
this.promptEditorState = {};
|
||||||
@@ -125,10 +138,6 @@ class RecipeModal {
|
|||||||
this.setupPromptEditors();
|
this.setupPromptEditors();
|
||||||
this.setupNavigationControls();
|
this.setupNavigationControls();
|
||||||
this.setupDeleteControl();
|
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
|
// Set up document click handler to close edit fields
|
||||||
document.addEventListener('click', (event) => {
|
document.addEventListener('click', (event) => {
|
||||||
@@ -162,7 +171,7 @@ class RecipeModal {
|
|||||||
reconnectContainers.forEach(container => {
|
reconnectContainers.forEach(container => {
|
||||||
if (container.classList.contains('active') &&
|
if (container.classList.contains('active') &&
|
||||||
!container.contains(event.target) &&
|
!container.contains(event.target) &&
|
||||||
!event.target.closest('.deleted-badge.reconnectable')) {
|
!event.target.closest('.lora-reconnect')) {
|
||||||
this.hideReconnectInput(container);
|
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) {
|
showRecipeDetails(recipe) {
|
||||||
const hydratedRecipe = recipe || {};
|
const hydratedRecipe = recipe || {};
|
||||||
this.resetLocalEditState();
|
this.resetLocalEditState();
|
||||||
@@ -892,28 +881,39 @@ class RecipeModal {
|
|||||||
lorasListElement.innerHTML = loras.map(lora => {
|
lorasListElement.innerHTML = loras.map(lora => {
|
||||||
const existsLocally = lora.inLibrary;
|
const existsLocally = lora.inLibrary;
|
||||||
const isDeleted = lora.isDeleted;
|
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) {
|
if (existsLocally) {
|
||||||
localStatus = `
|
statusBadge = `
|
||||||
<div class="local-badge">
|
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
|
||||||
<i class="fas fa-check"></i> In Library
|
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
|
||||||
<div class="local-path">${localPath}</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
} else if (isDeleted) {
|
} else if (isDeleted) {
|
||||||
localStatus = `
|
statusBadge = `
|
||||||
<div class="deleted-badge reconnectable" data-lora-index="${loras.indexOf(lora)}">
|
<div class="deleted-badge" title="${escapeHtml(translate('recipes.resources.deletedTooltip', {}, 'This LoRA was deleted from the source and is no longer available for download'))}">
|
||||||
<span class="badge-text"><i class="fas fa-trash-alt"></i> Deleted</span>
|
<i class="fas fa-trash-alt" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.deleted', {}, 'Deleted'))}
|
||||||
<div class="reconnect-tooltip">Click to reconnect with a local LoRA</div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
localStatus = `
|
statusBadge = `
|
||||||
<div class="missing-badge">
|
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
|
||||||
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actionsRow = this.renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted });
|
||||||
|
|
||||||
|
// The Civitai link belongs to the model name (it answers
|
||||||
|
// "what is this"), so it sits inline in the title — the same
|
||||||
|
// pattern the versions tab uses — never in the action row.
|
||||||
|
// Skipped for deleted models: their source page is gone.
|
||||||
|
const titleLink = isDeleted
|
||||||
|
? ''
|
||||||
|
: this.renderCivitaiLink(this.getResourceCivitaiUrl(lora));
|
||||||
|
|
||||||
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
const isPreviewVideo = lora.preview_url && lora.preview_url.toLowerCase().endsWith('.mp4');
|
||||||
const previewMedia = isPreviewVideo ?
|
const previewMedia = isPreviewVideo ?
|
||||||
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
`<video class="thumbnail-video" autoplay loop muted playsinline>
|
||||||
@@ -930,22 +930,33 @@ class RecipeModal {
|
|||||||
loraItemClass += ' missing-locally';
|
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 `
|
return `
|
||||||
<div class="${loraItemClass}" data-lora-index="${loras.indexOf(lora)}">
|
<div class="${loraItemClass}" data-lora-index="${loraIndex}"${rowA11yAttributes}>
|
||||||
<div class="recipe-lora-thumbnail">
|
<div class="recipe-lora-thumbnail">
|
||||||
${previewMedia}
|
${previewMedia}
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-lora-content">
|
<div class="recipe-lora-content">
|
||||||
<div class="recipe-lora-header">
|
<div class="recipe-lora-header">
|
||||||
|
<div class="recipe-lora-title">
|
||||||
<h4>${lora.modelName}</h4>
|
<h4>${lora.modelName}</h4>
|
||||||
<div class="badge-container">${localStatus}</div>
|
${titleLink}
|
||||||
|
</div>
|
||||||
|
<div class="badge-container">${statusBadge}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-lora-info">
|
<div class="recipe-lora-info">
|
||||||
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
${lora.modelVersionName ? `<div class="recipe-lora-version">${lora.modelVersionName}</div>` : ''}
|
||||||
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
<div class="recipe-lora-weight">Weight: ${lora.strength || 1.0}</div>
|
||||||
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
${lora.baseModel ? `<div class="base-model">${lora.baseModel}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="lora-reconnect-container" data-lora-index="${loras.indexOf(lora)}">
|
${actionsRow}
|
||||||
|
${isDeleted ? `
|
||||||
|
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||||
<div class="reconnect-instructions">
|
<div class="reconnect-instructions">
|
||||||
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
<p>Enter LoRA Syntax or Name to Reconnect:</p>
|
||||||
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
<small>Example: <code><lora:Boris_Vallejo_BV_flux_D:1></code> or just <code>Boris_Vallejo_BV_flux_D</code></small>
|
||||||
@@ -957,7 +968,7 @@ class RecipeModal {
|
|||||||
<button class="reconnect-confirm-btn">Reconnect</button>
|
<button class="reconnect-confirm-btn">Reconnect</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -965,6 +976,7 @@ class RecipeModal {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.setupReconnectButtons();
|
this.setupReconnectButtons();
|
||||||
|
this.setupLoraItemActions();
|
||||||
this.setupLoraItemsClickable();
|
this.setupLoraItemsClickable();
|
||||||
}, 100);
|
}, 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() {
|
setupReconnectButtons() {
|
||||||
// Add event listeners to all deleted badges
|
|
||||||
const deletedBadges = document.querySelectorAll('.deleted-badge.reconnectable');
|
|
||||||
deletedBadges.forEach(badge => {
|
|
||||||
badge.addEventListener('mouseenter', () => {
|
|
||||||
badge.querySelector('.badge-text').innerHTML = 'Reconnect';
|
|
||||||
});
|
|
||||||
|
|
||||||
badge.addEventListener('mouseleave', () => {
|
|
||||||
badge.querySelector('.badge-text').innerHTML = '<i class="fas fa-trash-alt"></i> Deleted';
|
|
||||||
});
|
|
||||||
|
|
||||||
badge.addEventListener('click', (e) => {
|
|
||||||
const loraIndex = badge.getAttribute('data-lora-index');
|
|
||||||
this.showReconnectInput(loraIndex);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add event listeners to reconnect cancel buttons
|
// Add event listeners to reconnect cancel buttons
|
||||||
const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn');
|
const cancelButtons = document.querySelectorAll('.reconnect-cancel-btn');
|
||||||
cancelButtons.forEach(button => {
|
cancelButtons.forEach(button => {
|
||||||
@@ -1734,49 +1731,67 @@ class RecipeModal {
|
|||||||
</video>
|
</video>
|
||||||
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
` : `<img src="${previewUrl}" alt="Checkpoint preview" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`;
|
||||||
|
|
||||||
|
// Status badge: pure indicator with a tooltip, mirroring the LoRA
|
||||||
|
// items and the versions-tab badge pattern. The header carries only
|
||||||
|
// the badge; every action lives in the bottom action row.
|
||||||
const badge = existsLocally ? `
|
const badge = existsLocally ? `
|
||||||
<div class="local-badge">
|
<div class="local-badge" title="${escapeHtml(translate('recipes.resources.inLibraryTooltip', {}, 'This model exists in your local library'))}">
|
||||||
<i class="fas fa-check"></i> In Library
|
<i class="fas fa-check" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.inLibrary', {}, 'In Library'))}
|
||||||
<div class="local-path">${localPath}</div>
|
|
||||||
</div>
|
</div>
|
||||||
` : `
|
` : `
|
||||||
<div class="missing-badge">
|
<div class="missing-badge" title="${escapeHtml(translate('recipes.resources.notInLibraryTooltip', {}, 'This model is not in your library'))}">
|
||||||
<i class="fas fa-exclamation-triangle"></i> Not in Library
|
<i class="fas fa-exclamation-triangle" aria-hidden="true"></i> ${escapeHtml(translate('recipes.resources.notInLibrary', {}, 'Not in Library'))}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
let headerAction = '';
|
const actions = [];
|
||||||
if (existsLocally && localPath) {
|
if (existsLocally && localPath) {
|
||||||
headerAction = `
|
actions.push(`
|
||||||
<button class="resource-action primary compact checkpoint-send">
|
<button class="resource-action primary compact checkpoint-send">
|
||||||
<i class="fas fa-paper-plane"></i>
|
<i class="fas fa-paper-plane"></i>
|
||||||
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
<span>${translate('recipes.actions.sendCheckpoint', {}, 'Send to ComfyUI')}</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`);
|
||||||
} else if (this.canDownloadCheckpoint(checkpoint)) {
|
} else if (!existsLocally && this.canDownloadCheckpoint(checkpoint)) {
|
||||||
headerAction = `
|
actions.push(`
|
||||||
<button class="resource-action primary compact checkpoint-download">
|
<button class="resource-action primary compact checkpoint-download">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
<span>${translate('modals.model.versions.actions.download', {}, 'Download')}</span>
|
<span>${translate('modals.model.versions.actions.download', {}, 'Download')}</span>
|
||||||
</button>
|
</button>
|
||||||
`;
|
`);
|
||||||
}
|
}
|
||||||
|
const actionsMarkup = actions.filter(Boolean).join('');
|
||||||
|
const actionsRow = actionsMarkup
|
||||||
|
? `<div class="recipe-lora-actions">${actionsMarkup}</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Civitai link lives inline with the title, same as LoRA items.
|
||||||
|
const titleLink = this.renderCivitaiLink(this.getResourceCivitaiUrl(checkpoint));
|
||||||
|
|
||||||
|
// Only in-library checkpoints are row-navigable; make it keyboard-accessible.
|
||||||
|
const rowA11yAttributes = existsLocally
|
||||||
|
? ` role="button" tabindex="0" aria-label="${escapeHtml(translate('recipes.resources.openCheckpointDetails', { name: checkpointName }, `View ${checkpointName} in the model library`))}"`
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : 'missing-locally'}">
|
<div class="recipe-lora-item checkpoint-item ${existsLocally ? 'exists-locally' : 'missing-locally'}"${rowA11yAttributes}>
|
||||||
<div class="recipe-lora-thumbnail">
|
<div class="recipe-lora-thumbnail">
|
||||||
${previewMedia}
|
${previewMedia}
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-lora-content">
|
<div class="recipe-lora-content">
|
||||||
<div class="recipe-lora-header">
|
<div class="recipe-lora-header">
|
||||||
|
<div class="recipe-lora-title">
|
||||||
<h4>${checkpointName}</h4>
|
<h4>${checkpointName}</h4>
|
||||||
<div class="badge-container">${headerAction}</div>
|
${titleLink}
|
||||||
|
</div>
|
||||||
|
<div class="badge-container">${badge}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recipe-lora-info recipe-checkpoint-meta">
|
<div class="recipe-lora-info recipe-checkpoint-meta">
|
||||||
${versionLabel ? `<div class="recipe-lora-version">${versionLabel}</div>` : ''}
|
${versionLabel ? `<div class="recipe-lora-version">${versionLabel}</div>` : ''}
|
||||||
${baseModel ? `<div class="base-model">${baseModel}</div>` : ''}
|
${baseModel ? `<div class="base-model">${baseModel}</div>` : ''}
|
||||||
${modelTypeLabel ? `<div class="checkpoint-type">${modelTypeLabel}</div>` : ''}
|
${modelTypeLabel ? `<span class="checkpoint-type-text">${modelTypeLabel}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
${actionsRow}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1801,12 +1816,27 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupCheckpointNavigation(container, checkpoint) {
|
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');
|
const checkpointItem = container.querySelector('.checkpoint-item');
|
||||||
if (!checkpointItem) return;
|
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);
|
this.navigateToCheckpointPage(checkpoint);
|
||||||
});
|
});
|
||||||
|
checkpointItem.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.navigateToCheckpointPage(checkpoint);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
canDownloadCheckpoint(checkpoint) {
|
canDownloadCheckpoint(checkpoint) {
|
||||||
@@ -1878,7 +1908,7 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await downloadManager.downloadVersionWithDefaults(
|
const success = await downloadManager.downloadVersionWithDefaults(
|
||||||
MODEL_TYPES.CHECKPOINT,
|
MODEL_TYPES.CHECKPOINT,
|
||||||
modelId,
|
modelId,
|
||||||
versionId,
|
versionId,
|
||||||
@@ -1887,6 +1917,9 @@ class RecipeModal {
|
|||||||
source: 'recipe-modal',
|
source: 'recipe-modal',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
if (success) {
|
||||||
|
await this.refreshResourcesAfterDownload();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading checkpoint:', error);
|
console.error('Error downloading checkpoint:', error);
|
||||||
showToast('toast.recipes.downloadCheckpointFailed', { message: error.message }, 'error');
|
showToast('toast.recipes.downloadCheckpointFailed', { message: error.message }, 'error');
|
||||||
@@ -1897,18 +1930,247 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
navigateToCheckpointPage(checkpoint) {
|
getResourceCivitaiUrl(resource) {
|
||||||
if (!checkpoint.inLibrary) {
|
if (!resource) {
|
||||||
const modelId = checkpoint.modelId || checkpoint.modelID || checkpoint.model_id;
|
return null;
|
||||||
const versionId = checkpoint.id || checkpoint.modelVersionId;
|
}
|
||||||
const modelName = checkpoint.name || checkpoint.modelName || checkpoint.file_name;
|
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) {
|
canDownloadLora(lora) {
|
||||||
openCivitaiByMetadata(modelId, versionId, modelName);
|
if (!lora) return false;
|
||||||
|
const modelId = lora.modelId || lora.modelID || lora.model_id;
|
||||||
|
const versionId = lora.id || lora.modelVersionId;
|
||||||
|
// Direct download needs both identifiers; a hash alone is enough
|
||||||
|
// because downloadRecipeLora resolves it to a version on demand —
|
||||||
|
// the same fallback the bulk "download missing" flow uses.
|
||||||
|
return !!((modelId && versionId) || lora.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCivitaiLink(url) {
|
||||||
|
if (!url) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const tooltip = translate('recipes.resources.viewOnCivitai', {}, 'View on Civitai');
|
||||||
|
return `
|
||||||
|
<a
|
||||||
|
class="recipe-civitai-link"
|
||||||
|
href="${escapeHtml(url)}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="${escapeHtml(tooltip)}"
|
||||||
|
aria-label="${escapeHtml(tooltip)}"
|
||||||
|
>
|
||||||
|
<i class="fas fa-arrow-up-right-from-square" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoraItemActions(lora, loraIndex, { existsLocally, isDeleted }) {
|
||||||
|
// In-library LoRAs need no remediation: the badge and the local path
|
||||||
|
// already tell the full story.
|
||||||
|
if (existsLocally) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const controls = [];
|
||||||
|
if (isDeleted) {
|
||||||
|
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
|
||||||
|
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
|
||||||
|
controls.push(`
|
||||||
|
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
|
||||||
|
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
|
||||||
|
<i class="fas fa-link" aria-hidden="true"></i>
|
||||||
|
<span>${escapeHtml(reconnectLabel)}</span>
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
if (this.canDownloadLora(lora)) {
|
||||||
|
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
||||||
|
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||||
|
controls.push(`
|
||||||
|
<button type="button" class="resource-action primary compact lora-download" data-lora-index="${loraIndex}"
|
||||||
|
title="${escapeHtml(downloadTooltip)}" aria-label="${escapeHtml(downloadTooltip)}">
|
||||||
|
<i class="fas fa-download" aria-hidden="true"></i>
|
||||||
|
<span>${escapeHtml(downloadLabel)}</span>
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const markup = controls.filter(Boolean).join('');
|
||||||
|
if (!markup) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `<div class="recipe-lora-actions">${markup}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupLoraItemActions() {
|
||||||
|
const lorasListElement = document.getElementById('recipeLorasList');
|
||||||
|
if (!lorasListElement) {
|
||||||
return;
|
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);
|
const checkpointHash = this._getCheckpointHash(checkpoint);
|
||||||
|
|
||||||
if (!checkpointHash) {
|
if (!checkpointHash) {
|
||||||
@@ -1956,17 +2218,6 @@ class RecipeModal {
|
|||||||
// If a specific LoRA index is provided, navigate to view just that one LoRA
|
// If a specific LoRA index is provided, navigate to view just that one LoRA
|
||||||
const lora = this.currentRecipe.loras[specificLoraIndex];
|
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) {
|
if (lora && lora.hash) {
|
||||||
// Set session storage to open the LoRA modal directly
|
// Set session storage to open the LoRA modal directly
|
||||||
setSessionItem('recipe_to_lora_filterLoraHash', lora.hash.toLowerCase());
|
setSessionItem('recipe_to_lora_filterLoraHash', lora.hash.toLowerCase());
|
||||||
@@ -1991,24 +2242,37 @@ class RecipeModal {
|
|||||||
window.location.href = '/loras';
|
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() {
|
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 => {
|
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
|
// Get the lora index from the data attribute
|
||||||
const loraIndex = parseInt(item.dataset.loraIndex);
|
const loraIndex = parseInt(item.dataset.loraIndex);
|
||||||
|
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
// If the click is on the reconnect container or badge, don't navigate
|
// The inline Civitai link inside the title keeps its own
|
||||||
if (e.target.closest('.lora-reconnect-container') ||
|
// navigation; don't let it trigger the row navigation.
|
||||||
e.target.closest('.deleted-badge') ||
|
if (e.target.closest('.recipe-civitai-link')) {
|
||||||
e.target.closest('.reconnect-tooltip')) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to the LoRAs page with the specific LoRA index
|
|
||||||
this.navigateToLorasPage(loraIndex);
|
this.navigateToLorasPage(loraIndex);
|
||||||
});
|
});
|
||||||
|
item.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.navigateToLorasPage(loraIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => {
|
||||||
|
if (typeof fallback !== 'string') {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
return fallback.replace(/\{(\w+)\}/g, (match, name) =>
|
||||||
|
params && params[name] != null ? String(params[name]) : match
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadingManagerStub = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
show: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const virtualScrollerStub = {
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
getNavigationState: vi.fn(() => ({
|
||||||
|
index: 0,
|
||||||
|
hasPrev: false,
|
||||||
|
hasNext: false,
|
||||||
|
loadedItems: 1,
|
||||||
|
totalItems: 1,
|
||||||
|
})),
|
||||||
|
getAdjacentItemByFilePath: vi.fn(async () => null),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateStub = {
|
||||||
|
global: { settings: {}, loadingManager: loadingManagerStub },
|
||||||
|
loadingManager: loadingManagerStub,
|
||||||
|
virtualScroller: virtualScrollerStub,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modalManagerMock = {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadVersionWithDefaultsMock = vi.fn(() => Promise.resolve());
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
sendLoraToWorkflow: vi.fn(),
|
||||||
|
sendModelPathToWorkflow: vi.fn(),
|
||||||
|
openCivitaiByMetadata: vi.fn(),
|
||||||
|
stripLoraTags: vi.fn((text) => text),
|
||||||
|
sendPromptToWorkflow: vi.fn(),
|
||||||
|
sendGenParamsToWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: stateStub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
setSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
getSessionItem: vi.fn(() => null),
|
||||||
|
getStorageItem: vi.fn(() => null),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
removeStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchRecipeDetailsMock = vi.fn(() => Promise.resolve({}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
fetchRecipeDetails: fetchRecipeDetailsMock,
|
||||||
|
updateRecipeMetadata: vi.fn(() => Promise.resolve({ success: true })),
|
||||||
|
sendRecipeWorkflow: vi.fn(),
|
||||||
|
extractRecipeId: (filePath) => {
|
||||||
|
if (!filePath) return null;
|
||||||
|
const basename = filePath.split('/').pop().split('\\').pop();
|
||||||
|
const dotIndex = basename.lastIndexOf('.');
|
||||||
|
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
MODEL_TYPES: {
|
||||||
|
LORA: 'loras',
|
||||||
|
CHECKPOINT: 'checkpoints',
|
||||||
|
EMBEDDING: 'embeddings',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||||
|
downloadManager: {
|
||||||
|
downloadVersionWithDefaults: downloadVersionWithDefaultsMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function recipeModalFixture() {
|
||||||
|
return `
|
||||||
|
<div id="recipeModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<header class="recipe-modal-header">
|
||||||
|
<h2 id="recipeModalTitle">Recipe Details</h2>
|
||||||
|
<div id="recipeTagsContainer"></div>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="recipe-media-column">
|
||||||
|
<div class="recipe-preview-container" id="recipePreviewContainer">
|
||||||
|
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-section recipe-bottom-section">
|
||||||
|
<div id="recipeCheckpoint"></div>
|
||||||
|
<div id="recipeResourceDivider"></div>
|
||||||
|
<div class="recipe-section-actions">
|
||||||
|
<span id="recipeLorasCount"></span>
|
||||||
|
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn"></button>
|
||||||
|
</div>
|
||||||
|
<div class="recipe-loras-list" id="recipeLorasList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingLora = {
|
||||||
|
name: 'gone-lora',
|
||||||
|
modelName: 'Gone LoRA',
|
||||||
|
inLibrary: false,
|
||||||
|
modelId: 123,
|
||||||
|
id: 456,
|
||||||
|
};
|
||||||
|
|
||||||
|
const hashOnlyLora = {
|
||||||
|
name: 'hash-lora',
|
||||||
|
modelName: 'Hash LoRA',
|
||||||
|
inLibrary: false,
|
||||||
|
hash: 'deadbeefcafe',
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipeWithResources = {
|
||||||
|
id: 'recipe-resources',
|
||||||
|
file_path: '/recipes/resources.json',
|
||||||
|
title: 'Resource Recipe',
|
||||||
|
tags: [],
|
||||||
|
checkpoint: {
|
||||||
|
name: 'gone-checkpoint',
|
||||||
|
inLibrary: false,
|
||||||
|
modelId: 900,
|
||||||
|
id: 901,
|
||||||
|
},
|
||||||
|
loras: [
|
||||||
|
{ name: 'present-lora', modelName: 'Present LoRA', inLibrary: true, hash: 'ABC123' },
|
||||||
|
missingLora,
|
||||||
|
{ name: 'deleted-lora', modelName: 'Deleted LoRA', inLibrary: false, isDeleted: true },
|
||||||
|
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
|
||||||
|
hashOnlyLora,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdModals = [];
|
||||||
|
|
||||||
|
async function createRecipeModal() {
|
||||||
|
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
|
||||||
|
const recipeModal = new RecipeModal();
|
||||||
|
createdModals.push(recipeModal);
|
||||||
|
return recipeModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushWiring() {
|
||||||
|
// Item action/navigation wiring happens in a setTimeout after rendering
|
||||||
|
return new Promise(resolve => setTimeout(resolve, 150));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecipeModal resource item interactions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// clearAllMocks keeps one-off mockResolvedValue implementations; reset
|
||||||
|
// the shared mocks to their defaults explicitly.
|
||||||
|
downloadVersionWithDefaultsMock.mockReset();
|
||||||
|
downloadVersionWithDefaultsMock.mockResolvedValue(undefined);
|
||||||
|
fetchRecipeDetailsMock.mockReset();
|
||||||
|
// Hydration re-fetches the recipe right after render; resolving an empty
|
||||||
|
// object would delete currentRecipe.loras and wipe the list, so resolve
|
||||||
|
// the full recipe by default (individual tests can override afterwards).
|
||||||
|
fetchRecipeDetailsMock.mockResolvedValue(recipeWithResources);
|
||||||
|
document.body.innerHTML = recipeModalFixture();
|
||||||
|
global.modalManager = modalManagerMock;
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
createdModals.forEach(recipeModal => recipeModal.cleanupNavigationShortcuts());
|
||||||
|
createdModals.length = 0;
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete global.modalManager;
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the missing badge as a pure status indicator with a tooltip', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
|
||||||
|
const badge = document.querySelector('.recipe-lora-item.missing-locally .missing-badge');
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge.tagName).toBe('DIV');
|
||||||
|
expect(badge.title).toBe('This model is not in your library');
|
||||||
|
expect(badge.classList.contains('reconnectable')).toBe(false);
|
||||||
|
expect(badge.textContent).toContain('Not in Library');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an explicit download action and an inline title link for a missing LoRA', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
|
||||||
|
const item = document.querySelector('.recipe-lora-item.missing-locally:not(.checkpoint-item)');
|
||||||
|
|
||||||
|
// The Civitai link belongs to the title, not the action row
|
||||||
|
const link = item.querySelector('.recipe-lora-title a.recipe-civitai-link');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link.target).toBe('_blank');
|
||||||
|
expect(link.rel).toContain('noopener');
|
||||||
|
expect(link.href).toBe('https://civitai.com/models/123?modelVersionId=456');
|
||||||
|
expect(link.title).toBe('View on Civitai');
|
||||||
|
|
||||||
|
const actions = item.querySelector('.recipe-lora-actions');
|
||||||
|
expect(actions).not.toBeNull();
|
||||||
|
|
||||||
|
const downloadButton = actions.querySelector('.lora-download');
|
||||||
|
expect(downloadButton).not.toBeNull();
|
||||||
|
expect(downloadButton.tagName).toBe('BUTTON');
|
||||||
|
expect(downloadButton.type).toBe('button');
|
||||||
|
expect(downloadButton.classList.contains('resource-action')).toBe(true);
|
||||||
|
expect(downloadButton.classList.contains('primary')).toBe(true);
|
||||||
|
expect(downloadButton.title).toBe('Download this LoRA');
|
||||||
|
|
||||||
|
// The action row holds only real actions; no link icon in it
|
||||||
|
expect(actions.querySelector('.recipe-civitai-link')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads only the clicked LoRA via the download manager', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const downloadButton = document.querySelector('.lora-download');
|
||||||
|
downloadButton.click();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith(
|
||||||
|
'loras',
|
||||||
|
123,
|
||||||
|
456,
|
||||||
|
expect.objectContaining({ source: 'recipe-modal' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not navigate when a missing LoRA row is clicked', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
const navigateSpy = vi
|
||||||
|
.spyOn(recipeModal, 'navigateToLorasPage')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const missingItem = document.querySelector('.recipe-lora-item.missing-locally');
|
||||||
|
expect(missingItem.getAttribute('role')).toBeNull();
|
||||||
|
expect(missingItem.getAttribute('tabindex')).toBeNull();
|
||||||
|
|
||||||
|
missingItem.click();
|
||||||
|
expect(navigateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps in-library rows navigable and keyboard accessible', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
const navigateSpy = vi
|
||||||
|
.spyOn(recipeModal, 'navigateToLorasPage')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const localItem = document.querySelector('.recipe-lora-item.exists-locally:not(.checkpoint-item)');
|
||||||
|
expect(localItem.getAttribute('role')).toBe('button');
|
||||||
|
expect(localItem.getAttribute('tabindex')).toBe('0');
|
||||||
|
|
||||||
|
localItem.click();
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(0);
|
||||||
|
|
||||||
|
navigateSpy.mockClear();
|
||||||
|
localItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||||
|
expect(navigateSpy).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders deleted LoRAs with a status badge and an explicit reconnect button', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const deletedItem = document.querySelector('.recipe-lora-item.is-deleted');
|
||||||
|
const badge = deletedItem.querySelector('.deleted-badge');
|
||||||
|
expect(badge).not.toBeNull();
|
||||||
|
expect(badge.classList.contains('reconnectable')).toBe(false);
|
||||||
|
expect(badge.title).toContain('deleted from the source');
|
||||||
|
|
||||||
|
const reconnectButton = deletedItem.querySelector('.lora-reconnect');
|
||||||
|
expect(reconnectButton).not.toBeNull();
|
||||||
|
expect(reconnectButton.tagName).toBe('BUTTON');
|
||||||
|
expect(reconnectButton.classList.contains('ghost')).toBe(true);
|
||||||
|
|
||||||
|
reconnectButton.click();
|
||||||
|
const container = deletedItem.querySelector('.lora-reconnect-container');
|
||||||
|
expect(container.classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no action row when neither identifiers nor hash are available', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
|
||||||
|
const mysteryItem = document.querySelector('[data-lora-index="3"]');
|
||||||
|
expect(mysteryItem.querySelector('.lora-download')).toBeNull();
|
||||||
|
// No actions at all -> no empty action row taking vertical space
|
||||||
|
expect(mysteryItem.querySelector('.recipe-lora-actions')).toBeNull();
|
||||||
|
|
||||||
|
// The name-fallback search link still sits inline in the title
|
||||||
|
const link = mysteryItem.querySelector('.recipe-lora-title a.recipe-civitai-link');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link.href).toContain('query=Mystery%20LoRA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
global.fetch = vi.fn(async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () =>
|
||||||
|
typeof url === 'string' && url.includes('/civitai/model/hash/')
|
||||||
|
? { id: 789, modelId: 777, name: 'Hash LoRA v1' }
|
||||||
|
: {},
|
||||||
|
}));
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const hashItem = document.querySelector('[data-lora-index="4"]');
|
||||||
|
const downloadButton = hashItem.querySelector('.lora-download');
|
||||||
|
expect(downloadButton).not.toBeNull();
|
||||||
|
|
||||||
|
downloadButton.click();
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hash resolution needs a network round trip; immediate feedback must
|
||||||
|
// appear while the user waits for the progress UI
|
||||||
|
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Preparing download...');
|
||||||
|
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/loras/civitai/model/hash/deadbeefcafe'
|
||||||
|
);
|
||||||
|
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith(
|
||||||
|
'loras',
|
||||||
|
777,
|
||||||
|
789,
|
||||||
|
expect.objectContaining({ source: 'recipe-modal', versionName: 'Hash LoRA v1' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes the resources section and the recipe card after a successful download', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
downloadVersionWithDefaultsMock.mockResolvedValue(true);
|
||||||
|
const updatedRecipe = {
|
||||||
|
...recipeWithResources,
|
||||||
|
loras: recipeWithResources.loras.map((lora, index) =>
|
||||||
|
index === 1 ? { ...lora, inLibrary: true } : lora
|
||||||
|
),
|
||||||
|
};
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
fetchRecipeDetailsMock.mockResolvedValue(updatedRecipe);
|
||||||
|
|
||||||
|
const downloadButton = document.querySelector('.lora-download');
|
||||||
|
downloadButton.click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(fetchRecipeDetailsMock).toHaveBeenCalledWith('recipe-resources');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The recipe card on the listing page receives the fresh recipe data
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(virtualScrollerStub.updateSingleItem).toHaveBeenCalledWith(
|
||||||
|
'/recipes/resources.json',
|
||||||
|
updatedRecipe
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The modal's resources section re-renders with the flipped status
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
const item = document.querySelector('[data-lora-index="1"]');
|
||||||
|
expect(item.classList.contains('exists-locally')).toBe(true);
|
||||||
|
expect(item.querySelector('.local-badge')).not.toBeNull();
|
||||||
|
expect(item.querySelector('.missing-badge')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a status badge, download action and Civitai link for a missing checkpoint without row navigation', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
const navigateSpy = vi
|
||||||
|
.spyOn(recipeModal, 'navigateToCheckpointPage')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const checkpointItem = document.querySelector('.checkpoint-item');
|
||||||
|
expect(checkpointItem.classList.contains('missing-locally')).toBe(true);
|
||||||
|
expect(checkpointItem.getAttribute('role')).toBeNull();
|
||||||
|
|
||||||
|
expect(checkpointItem.querySelector('.missing-badge')).not.toBeNull();
|
||||||
|
expect(checkpointItem.querySelector('.checkpoint-download')).not.toBeNull();
|
||||||
|
|
||||||
|
const link = checkpointItem.querySelector('.recipe-lora-title a.recipe-civitai-link');
|
||||||
|
expect(link).not.toBeNull();
|
||||||
|
expect(link.href).toBe('https://civitai.com/models/900?modelVersionId=901');
|
||||||
|
|
||||||
|
checkpointItem.click();
|
||||||
|
expect(navigateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user