diff --git a/locales/de.json b/locales/de.json index e3cce8dd..725339c1 100644 --- a/locales/de.json +++ b/locales/de.json @@ -854,13 +854,55 @@ "tabs": { "examples": "Beispiele", "description": "Modellbeschreibung", - "recipes": "Rezepte" + "recipes": "Rezepte", + "versions": "Versionen" }, "loading": { "exampleImages": "Beispielbilder werden geladen...", "description": "Modellbeschreibung wird geladen...", "recipes": "Rezepte werden geladen...", - "examples": "Beispiele werden geladen..." + "examples": "Beispiele werden geladen...", + "versions": "Versionen werden geladen..." + }, + "versions": { + "heading": "Modellversionen", + "copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.", + "media": { + "placeholder": "Keine Vorschau" + }, + "labels": { + "unnamed": "Unbenannte Version", + "noDetails": "Keine zusätzlichen Details" + }, + "badges": { + "current": "Aktuelle Version", + "inLibrary": "In der Bibliothek", + "newer": "Neuere Version", + "ignored": "Ignoriert" + }, + "actions": { + "download": "Herunterladen", + "delete": "Löschen", + "ignore": "Ignorieren", + "unignore": "Ignorierung aufheben", + "resumeModelUpdates": "Aktualisierungen für dieses Modell fortsetzen", + "ignoreModelUpdates": "Aktualisierungen für dieses Modell ignorieren", + "viewLocalVersions": "Alle lokalen Versionen anzeigen", + "viewLocalTooltip": "Demnächst verfügbar" + }, + "empty": "Noch keine Versionshistorie für dieses Modell vorhanden.", + "error": "Versionen konnten nicht geladen werden.", + "missingModelId": "Für dieses Modell ist keine Civitai-Model-ID vorhanden.", + "confirm": { + "delete": "Diese Version aus Ihrer Bibliothek löschen?" + }, + "toast": { + "modelIgnored": "Aktualisierungen für dieses Modell werden ignoriert", + "modelResumed": "Aktualisierungen für dieses Modell werden wieder geprüft", + "versionIgnored": "Aktualisierungen für diese Version werden ignoriert", + "versionUnignored": "Version wurde wieder aktiviert", + "versionDeleted": "Version gelöscht" + } } } }, diff --git a/locales/en.json b/locales/en.json index 285e013e..a22e4d57 100644 --- a/locales/en.json +++ b/locales/en.json @@ -853,13 +853,55 @@ "tabs": { "examples": "Examples", "description": "Model Description", - "recipes": "Recipes" + "recipes": "Recipes", + "versions": "Versions" }, "loading": { "exampleImages": "Loading example images...", "description": "Loading model description...", "recipes": "Loading recipes...", - "examples": "Loading examples..." + "examples": "Loading examples...", + "versions": "Loading versions..." + }, + "versions": { + "heading": "Model versions", + "copy": "Track and manage every version of this model in one place.", + "media": { + "placeholder": "No preview" + }, + "labels": { + "unnamed": "Untitled Version", + "noDetails": "No additional details" + }, + "badges": { + "current": "Current Version", + "inLibrary": "In Library", + "newer": "Newer Version", + "ignored": "Ignored" + }, + "actions": { + "download": "Download", + "delete": "Delete", + "ignore": "Ignore", + "unignore": "Unignore", + "resumeModelUpdates": "Resume updates for this model", + "ignoreModelUpdates": "Ignore updates for this model", + "viewLocalVersions": "View all local versions", + "viewLocalTooltip": "Coming soon" + }, + "empty": "No version history available for this model yet.", + "error": "Failed to load versions.", + "missingModelId": "This model is missing a Civitai model id.", + "confirm": { + "delete": "Delete this version from your library?" + }, + "toast": { + "modelIgnored": "Updates ignored for this model", + "modelResumed": "Update tracking resumed", + "versionIgnored": "Updates ignored for this version", + "versionUnignored": "Version re-enabled", + "versionDeleted": "Version deleted" + } } } }, diff --git a/locales/es.json b/locales/es.json index 8e6a3adf..54edf26a 100644 --- a/locales/es.json +++ b/locales/es.json @@ -853,13 +853,55 @@ "tabs": { "examples": "Ejemplos", "description": "Descripción del modelo", - "recipes": "Recetas" + "recipes": "Recetas", + "versions": "Versiones" }, "loading": { "exampleImages": "Cargando imágenes de ejemplo...", "description": "Cargando descripción del modelo...", "recipes": "Cargando recetas...", - "examples": "Cargando ejemplos..." + "examples": "Cargando ejemplos...", + "versions": "Cargando versiones..." + }, + "versions": { + "heading": "Versiones del modelo", + "copy": "Administra todas las versiones de este modelo en un solo lugar.", + "media": { + "placeholder": "Sin vista previa" + }, + "labels": { + "unnamed": "Versión sin nombre", + "noDetails": "Sin detalles adicionales" + }, + "badges": { + "current": "Versión actual", + "inLibrary": "En la biblioteca", + "newer": "Versión más reciente", + "ignored": "Ignorada" + }, + "actions": { + "download": "Descargar", + "delete": "Eliminar", + "ignore": "Ignorar", + "unignore": "Dejar de ignorar", + "resumeModelUpdates": "Reanudar actualizaciones para este modelo", + "ignoreModelUpdates": "Ignorar actualizaciones para este modelo", + "viewLocalVersions": "Ver todas las versiones locales", + "viewLocalTooltip": "Disponible pronto" + }, + "empty": "Aún no hay historial de versiones para este modelo.", + "error": "No se pudieron cargar las versiones.", + "missingModelId": "Este modelo no tiene un ID de modelo de Civitai.", + "confirm": { + "delete": "¿Eliminar esta versión de tu biblioteca?" + }, + "toast": { + "modelIgnored": "Se ignoran las actualizaciones de este modelo", + "modelResumed": "Seguimiento de actualizaciones reanudado", + "versionIgnored": "Se ignoran las actualizaciones de esta versión", + "versionUnignored": "Versión habilitada nuevamente", + "versionDeleted": "Versión eliminada" + } } } }, diff --git a/locales/fr.json b/locales/fr.json index 233fa383..17b6e098 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -853,13 +853,55 @@ "tabs": { "examples": "Exemples", "description": "Description du modèle", - "recipes": "Recipes" + "recipes": "Recipes", + "versions": "Versions" }, "loading": { "exampleImages": "Chargement des images d'exemple...", "description": "Chargement de la description du modèle...", "recipes": "Chargement des recipes...", - "examples": "Chargement des exemples..." + "examples": "Chargement des exemples...", + "versions": "Chargement des versions..." + }, + "versions": { + "heading": "Versions du modèle", + "copy": "Gérez toutes les versions de ce modèle en un seul endroit.", + "media": { + "placeholder": "Aucune prévisualisation" + }, + "labels": { + "unnamed": "Version sans nom", + "noDetails": "Aucun détail supplémentaire" + }, + "badges": { + "current": "Version actuelle", + "inLibrary": "Dans la bibliothèque", + "newer": "Version plus récente", + "ignored": "Ignorée" + }, + "actions": { + "download": "Télécharger", + "delete": "Supprimer", + "ignore": "Ignorer", + "unignore": "Ne plus ignorer", + "resumeModelUpdates": "Reprendre les mises à jour pour ce modèle", + "ignoreModelUpdates": "Ignorer les mises à jour pour ce modèle", + "viewLocalVersions": "Voir toutes les versions locales", + "viewLocalTooltip": "Bientôt disponible" + }, + "empty": "Aucun historique de versions n'est disponible pour ce modèle pour le moment.", + "error": "Échec du chargement des versions.", + "missingModelId": "Ce modèle ne possède pas d'identifiant de modèle Civitai.", + "confirm": { + "delete": "Supprimer cette version de votre bibliothèque ?" + }, + "toast": { + "modelIgnored": "Les mises à jour de ce modèle sont ignorées", + "modelResumed": "Suivi des mises à jour repris", + "versionIgnored": "Les mises à jour de cette version sont ignorées", + "versionUnignored": "Version réactivée", + "versionDeleted": "Version supprimée" + } } } }, diff --git a/locales/he.json b/locales/he.json index 219c421f..64fbcc49 100644 --- a/locales/he.json +++ b/locales/he.json @@ -853,13 +853,55 @@ "tabs": { "examples": "דוגמאות", "description": "תיאור המודל", - "recipes": "מתכונים" + "recipes": "מתכונים", + "versions": "גרסאות" }, "loading": { "exampleImages": "טוען תמונות דוגמה...", "description": "טוען תיאור מודל...", "recipes": "טוען מתכונים...", - "examples": "טוען דוגמאות..." + "examples": "טוען דוגמאות...", + "versions": "טוען גרסאות..." + }, + "versions": { + "heading": "גרסאות המודל", + "copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.", + "media": { + "placeholder": "אין תצוגה מקדימה" + }, + "labels": { + "unnamed": "גרסה ללא שם", + "noDetails": "אין פרטים נוספים" + }, + "badges": { + "current": "גרסה נוכחית", + "inLibrary": "בספרייה", + "newer": "גרסה חדשה יותר", + "ignored": "התעלם" + }, + "actions": { + "download": "הורדה", + "delete": "מחיקה", + "ignore": "התעלם", + "unignore": "בטל התעלמות", + "resumeModelUpdates": "המשך עדכונים עבור מודל זה", + "ignoreModelUpdates": "התעלם מעדכונים עבור מודל זה", + "viewLocalVersions": "הצג את כל הגרסאות המקומיות", + "viewLocalTooltip": "יגיע בקרוב" + }, + "empty": "אין עדיין היסטוריית גרסאות למודל זה.", + "error": "טעינת הגרסאות נכשלה.", + "missingModelId": "למודל זה אין מזהה מודל של Civitai.", + "confirm": { + "delete": "למחוק גרסה זו מהספרייה שלך?" + }, + "toast": { + "modelIgnored": "העדכונים עבור מודל זה נוגבו", + "modelResumed": "מעקב העדכונים חודש", + "versionIgnored": "העדכונים עבור גרסה זו נוגבו", + "versionUnignored": "הגרסה הופעלה מחדש", + "versionDeleted": "הגרסה נמחקה" + } } } }, diff --git a/locales/ja.json b/locales/ja.json index 2b324aba..bb37ac04 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -853,13 +853,55 @@ "tabs": { "examples": "例", "description": "モデル説明", - "recipes": "レシピ" + "recipes": "レシピ", + "versions": "バージョン" }, "loading": { "exampleImages": "例画像を読み込み中...", "description": "モデル説明を読み込み中...", "recipes": "レシピを読み込み中...", - "examples": "例を読み込み中..." + "examples": "例を読み込み中...", + "versions": "バージョンを読み込み中..." + }, + "versions": { + "heading": "モデルバージョン", + "copy": "このモデルのすべてのバージョンを一か所で管理します。", + "media": { + "placeholder": "プレビューなし" + }, + "labels": { + "unnamed": "名前のないバージョン", + "noDetails": "追加情報なし" + }, + "badges": { + "current": "現在のバージョン", + "inLibrary": "ライブラリにあります", + "newer": "新しいバージョン", + "ignored": "無視中" + }, + "actions": { + "download": "ダウンロード", + "delete": "削除", + "ignore": "無視", + "unignore": "無視を解除", + "resumeModelUpdates": "このモデルの更新を再開", + "ignoreModelUpdates": "このモデルの更新を無視", + "viewLocalVersions": "ローカルの全バージョンを表示", + "viewLocalTooltip": "近日対応予定" + }, + "empty": "このモデルにはまだバージョン履歴がありません。", + "error": "バージョンの読み込みに失敗しました。", + "missingModelId": "このモデルにはCivitaiのモデルIDがありません。", + "confirm": { + "delete": "このバージョンをライブラリから削除しますか?" + }, + "toast": { + "modelIgnored": "このモデルの更新は無視されます", + "modelResumed": "更新の監視を再開しました", + "versionIgnored": "このバージョンの更新は無視されます", + "versionUnignored": "バージョンを再度有効にしました", + "versionDeleted": "バージョンを削除しました" + } } } }, diff --git a/locales/ko.json b/locales/ko.json index d06ea8e2..45cb597f 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -853,13 +853,55 @@ "tabs": { "examples": "예시", "description": "모델 설명", - "recipes": "레시피" + "recipes": "레시피", + "versions": "버전" }, "loading": { "exampleImages": "예시 이미지 로딩 중...", "description": "모델 설명 로딩 중...", "recipes": "레시피 로딩 중...", - "examples": "예시 로딩 중..." + "examples": "예시 로딩 중...", + "versions": "버전 로딩 중..." + }, + "versions": { + "heading": "모델 버전", + "copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.", + "media": { + "placeholder": "미리보기 없음" + }, + "labels": { + "unnamed": "이름 없는 버전", + "noDetails": "추가 정보 없음" + }, + "badges": { + "current": "현재 버전", + "inLibrary": "라이브러리에 있음", + "newer": "최신 버전", + "ignored": "무시됨" + }, + "actions": { + "download": "다운로드", + "delete": "삭제", + "ignore": "무시", + "unignore": "무시 해제", + "resumeModelUpdates": "이 모델 업데이트 재개", + "ignoreModelUpdates": "이 모델 업데이트 무시", + "viewLocalVersions": "로컬 버전 모두 보기", + "viewLocalTooltip": "곧 제공 예정" + }, + "empty": "이 모델에는 아직 버전 기록이 없습니다.", + "error": "버전을 불러오지 못했습니다.", + "missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.", + "confirm": { + "delete": "이 버전을 라이브러리에서 삭제하시겠습니까?" + }, + "toast": { + "modelIgnored": "이 모델의 업데이트가 무시됩니다", + "modelResumed": "업데이트 추적이 재개되었습니다", + "versionIgnored": "이 버전의 업데이트가 무시됩니다", + "versionUnignored": "버전이 다시 활성화되었습니다", + "versionDeleted": "버전이 삭제되었습니다" + } } } }, diff --git a/locales/ru.json b/locales/ru.json index 23f15bc9..5ef3eca6 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -853,13 +853,55 @@ "tabs": { "examples": "Примеры", "description": "Описание модели", - "recipes": "Рецепты" + "recipes": "Рецепты", + "versions": "Версии" }, "loading": { "exampleImages": "Загрузка примеров изображений...", "description": "Загрузка описания модели...", "recipes": "Загрузка рецептов...", - "examples": "Загрузка примеров..." + "examples": "Загрузка примеров...", + "versions": "Загрузка версий..." + }, + "versions": { + "heading": "Версии модели", + "copy": "Управляйте всеми версиями этой модели в одном месте.", + "media": { + "placeholder": "Нет превью" + }, + "labels": { + "unnamed": "Версия без названия", + "noDetails": "Дополнительная информация отсутствует" + }, + "badges": { + "current": "Текущая версия", + "inLibrary": "В библиотеке", + "newer": "Более новая версия", + "ignored": "Игнорируется" + }, + "actions": { + "download": "Скачать", + "delete": "Удалить", + "ignore": "Игнорировать", + "unignore": "Перестать игнорировать", + "resumeModelUpdates": "Возобновить обновления для этой модели", + "ignoreModelUpdates": "Игнорировать обновления для этой модели", + "viewLocalVersions": "Показать все локальные версии", + "viewLocalTooltip": "Скоро появится" + }, + "empty": "Для этой модели пока нет истории версий.", + "error": "Не удалось загрузить версии.", + "missingModelId": "У этой модели отсутствует идентификатор модели Civitai.", + "confirm": { + "delete": "Удалить эту версию из библиотеки?" + }, + "toast": { + "modelIgnored": "Обновления для этой модели игнорируются", + "modelResumed": "Отслеживание обновлений возобновлено", + "versionIgnored": "Обновления для этой версии игнорируются", + "versionUnignored": "Версия снова активна", + "versionDeleted": "Версия удалена" + } } } }, diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 53106e7e..fe3e9f41 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -853,13 +853,55 @@ "tabs": { "examples": "示例", "description": "模型描述", - "recipes": "配方" + "recipes": "配方", + "versions": "版本" }, "loading": { "exampleImages": "正在加载示例图片...", "description": "正在加载模型描述...", "recipes": "正在加载配方...", - "examples": "正在加载示例..." + "examples": "正在加载示例...", + "versions": "正在加载版本..." + }, + "versions": { + "heading": "模型版本", + "copy": "在一个位置管理该模型的所有版本。", + "media": { + "placeholder": "无预览" + }, + "labels": { + "unnamed": "未命名版本", + "noDetails": "暂无更多信息" + }, + "badges": { + "current": "当前版本", + "inLibrary": "已在库中", + "newer": "较新的版本", + "ignored": "已忽略" + }, + "actions": { + "download": "下载", + "delete": "删除", + "ignore": "忽略", + "unignore": "取消忽略", + "resumeModelUpdates": "继续跟踪该模型的更新", + "ignoreModelUpdates": "忽略该模型的更新", + "viewLocalVersions": "查看所有本地版本", + "viewLocalTooltip": "敬请期待" + }, + "empty": "该模型还没有版本历史。", + "error": "加载版本失败。", + "missingModelId": "该模型缺少 Civitai 模型 ID。", + "confirm": { + "delete": "从库中删除此版本?" + }, + "toast": { + "modelIgnored": "已忽略该模型的更新", + "modelResumed": "已恢复更新跟踪", + "versionIgnored": "已忽略该版本的更新", + "versionUnignored": "已重新启用该版本", + "versionDeleted": "版本已删除" + } } } }, diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 2ce3d5f4..d05aac31 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -853,13 +853,55 @@ "tabs": { "examples": "範例圖片", "description": "模型描述", - "recipes": "配方" + "recipes": "配方", + "versions": "版本" }, "loading": { "exampleImages": "載入範例圖片中...", "description": "載入模型描述中...", "recipes": "載入配方中...", - "examples": "載入範例中..." + "examples": "載入範例中...", + "versions": "載入版本中..." + }, + "versions": { + "heading": "模型版本", + "copy": "在同一位置追蹤並管理此模型的所有版本。", + "media": { + "placeholder": "無預覽" + }, + "labels": { + "unnamed": "未命名版本", + "noDetails": "沒有其他資訊" + }, + "badges": { + "current": "目前版本", + "inLibrary": "已在庫中", + "newer": "較新版本", + "ignored": "已忽略" + }, + "actions": { + "download": "下載", + "delete": "刪除", + "ignore": "忽略", + "unignore": "取消忽略", + "resumeModelUpdates": "恢復追蹤此模型的更新", + "ignoreModelUpdates": "忽略此模型的更新", + "viewLocalVersions": "檢視所有本地版本", + "viewLocalTooltip": "敬請期待" + }, + "empty": "此模型尚無版本歷史。", + "error": "載入版本失敗。", + "missingModelId": "此模型缺少 Civitai 模型 ID。", + "confirm": { + "delete": "要從庫中刪除此版本嗎?" + }, + "toast": { + "modelIgnored": "已忽略此模型的更新", + "modelResumed": "已恢復更新追蹤", + "versionIgnored": "已忽略此版本的更新", + "versionUnignored": "已重新啟用此版本", + "versionDeleted": "已刪除此版本" + } } } }, diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 905d2627..bd3a7ea8 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -1108,9 +1108,9 @@ class ModelUpdateHandler: version_id, should_ignore, ) - overrides = await self._build_preview_overrides(record) + overrides = await self._build_version_context(record) return web.json_response( - {"success": True, "record": self._serialize_record(record, preview_overrides=overrides)} + {"success": True, "record": self._serialize_record(record, version_context=overrides)} ) async def get_model_update_status(self, request: web.Request) -> web.Response: @@ -1157,9 +1157,9 @@ class ModelUpdateHandler: {"success": False, "error": "Model not tracked"}, status=404 ) - overrides = await self._build_preview_overrides(record) + overrides = await self._build_version_context(record) return web.json_response( - {"success": True, "record": self._serialize_record(record, preview_overrides=overrides)} + {"success": True, "record": self._serialize_record(record, version_context=overrides)} ) async def _get_or_refresh_record( @@ -1219,9 +1219,9 @@ class ModelUpdateHandler: self, record, *, - preview_overrides: Optional[Dict[int, Optional[str]]] = None, + version_context: Optional[Dict[int, Dict[str, Optional[str]]]] = None, ) -> Dict: - overrides = preview_overrides or {} + context = version_context or {} return { "modelType": record.model_type, "modelId": record.model_id, @@ -1232,14 +1232,16 @@ class ModelUpdateHandler: "shouldIgnore": record.should_ignore_model, "hasUpdate": record.has_update(), "versions": [ - self._serialize_version(version, overrides.get(version.version_id)) + self._serialize_version(version, context.get(version.version_id)) for version in record.versions ], } @staticmethod - def _serialize_version(version, override_preview: Optional[str]) -> Dict: - preview_url = override_preview if override_preview is not None else version.preview_url + def _serialize_version(version, context: Optional[Dict[str, Optional[str]]]) -> Dict: + context = context or {} + preview_override = context.get("preview_override") + preview_url = preview_override if preview_override is not None else version.preview_url return { "versionId": version.version_id, "name": version.name, @@ -1249,19 +1251,21 @@ class ModelUpdateHandler: "previewUrl": preview_url, "isInLibrary": version.is_in_library, "shouldIgnore": version.should_ignore, + "filePath": context.get("file_path"), + "fileName": context.get("file_name"), } - async def _build_preview_overrides(self, record) -> Dict[int, Optional[str]]: - overrides: Dict[int, Optional[str]] = {} + async def _build_version_context(self, record) -> Dict[int, Dict[str, Optional[str]]]: + context: Dict[int, Dict[str, Optional[str]]] = {} try: cache = await self._service.scanner.get_cached_data() except Exception as exc: # pragma: no cover - defensive logging self._logger.debug("Failed to load cache while building preview overrides: %s", exc) - return overrides + return context version_index = getattr(cache, "version_index", None) if not version_index: - return overrides + return context for version in record.versions: if not version.is_in_library: @@ -1269,9 +1273,15 @@ class ModelUpdateHandler: cache_entry = version_index.get(version.version_id) if isinstance(cache_entry, Mapping): preview = cache_entry.get("preview_url") + context_entry: Dict[str, Optional[str]] = { + "file_path": cache_entry.get("file_path"), + "file_name": cache_entry.get("file_name"), + "preview_override": None, + } if isinstance(preview, str) and preview: - overrides[version.version_id] = config.get_preview_static_url(preview) - return overrides + context_entry["preview_override"] = config.get_preview_static_url(preview) + context[version.version_id] = context_entry + return context @dataclass diff --git a/static/css/components/lora-modal/versions.css b/static/css/components/lora-modal/versions.css new file mode 100644 index 00000000..5af2c1e7 --- /dev/null +++ b/static/css/components/lora-modal/versions.css @@ -0,0 +1,335 @@ +.model-versions-tab { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-2) 0; +} + +.versions-toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: var(--space-2); + padding: var(--space-2); + background: color-mix(in oklch, var(--lora-surface) 70%, transparent); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); +} + +.versions-toolbar-info h3 { + margin: 0 0 4px; + font-size: 1.05rem; + font-weight: 600; + color: var(--text-color); +} + +.versions-toolbar-info p { + margin: 0; + font-size: 0.85rem; + color: var(--text-muted); +} + +.versions-toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-1); +} + +.versions-toolbar-btn { + appearance: none; + border-radius: var(--border-radius-xs); + padding: 8px 14px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + border: 1px solid transparent; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.versions-toolbar-btn-primary { + background: var(--lora-accent); + color: #fff; + border-color: color-mix(in oklch, var(--lora-accent) 70%, transparent); +} + +.versions-toolbar-btn-primary:hover:not(:disabled) { + transform: translateY(-1px); + background: color-mix(in oklch, var(--lora-accent) 85%, transparent); +} + +.versions-toolbar-btn-secondary { + background: transparent; + color: var(--text-muted); + border-color: var(--border-color); +} + +.versions-toolbar-btn-secondary:hover:not(:disabled) { + color: var(--text-color); +} + +.versions-toolbar-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.versions-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.version-divider { + height: 1px; + background: var(--border-color); + margin: var(--space-1) 0; +} + +.model-version-row { + display: grid; + grid-template-columns: 124px 1fr auto; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + background: color-mix(in oklch, var(--card-bg) 92%, var(--bg-color) 8%); + border: 1px solid var(--lora-border); + border-radius: var(--border-radius-sm); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +[data-theme="dark"] .model-version-row { + background: color-mix(in oklch, var(--card-bg) 88%, black 12%); +} + +.model-version-row:hover { + transform: translateY(-1px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); +} + +.model-version-row.is-current { + border-color: var(--lora-accent); + box-shadow: 0 0 0 1px color-mix(in oklch, var(--lora-accent) 65%, transparent), + 0 10px 22px rgba(0, 0, 0, 0.12); +} + +.version-media { + width: 124px; + height: 88px; + border-radius: var(--border-radius-xs); + overflow: hidden; + background: rgba(0, 0, 0, 0.03); + display: flex; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in oklch, var(--border-color) 70%, transparent); +} + +.version-media img, +.version-media video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.version-media video { + background: #000; +} + +.version-media-placeholder { + font-size: 0.85rem; + color: var(--text-muted); + border-style: dashed; + border-width: 1px; +} + +.version-details { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.version-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 0.95rem; + color: color-mix(in oklch, var(--text-color) 92%, black 8%); +} + +.version-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} + +.version-id { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; +} + +.version-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.version-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + border: 1px solid transparent; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.version-badge-info { + background: color-mix(in oklch, var(--badge-update-bg) 25%, transparent); + color: var(--badge-update-bg); + border-color: color-mix(in oklch, var(--badge-update-bg) 55%, transparent); +} + +.version-badge-success { + background: color-mix(in oklch, var(--lora-success) 25%, transparent); + color: var(--lora-success); + border-color: color-mix(in oklch, var(--lora-success) 50%, transparent); +} + +.version-badge-muted { + background: color-mix(in oklch, var(--text-muted) 18%, transparent); + color: var(--text-muted); + border-color: color-mix(in oklch, var(--text-muted) 40%, transparent); +} + +.version-badge-current { + background: color-mix(in oklch, var(--lora-accent) 22%, transparent); + color: var(--lora-accent); + border-color: color-mix(in oklch, var(--lora-accent) 55%, transparent); +} + +.version-meta { + font-size: 0.8rem; + color: var(--text-muted); + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.version-meta-item { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.version-meta-primary { + font-weight: 600; + color: color-mix(in oklch, var(--text-color) 88%, var(--lora-accent) 12%); +} + +.version-meta-separator { + color: color-mix(in oklch, var(--text-muted) 90%, var(--text-color) 10%); +} + +.version-actions { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-end; +} + +.version-action { + min-width: 128px; + padding: 7px 12px; + border-radius: var(--border-radius-xs); + border: 1px solid transparent; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; +} + +.version-action-primary { + background: var(--lora-accent); + color: #fff; + border-color: color-mix(in oklch, var(--lora-accent) 65%, transparent); +} + +.version-action-primary:hover { + transform: translateY(-1px); + background: color-mix(in oklch, var(--lora-accent) 85%, transparent); +} + +.version-action-danger { + background: transparent; + border-color: color-mix(in oklch, var(--lora-error) 60%, transparent); + color: var(--lora-error); +} + +.version-action-danger:hover { + background: color-mix(in oklch, var(--lora-error) 12%, transparent); +} + +.version-action-ghost { + background: transparent; + border-color: var(--border-color); + color: var(--text-color); +} + +.version-action-ghost:hover { + background: color-mix(in oklch, var(--lora-surface) 35%, transparent); +} + +.version-action:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.versions-loading-state, +.versions-empty, +.versions-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: var(--space-3); + border: 1px dashed var(--lora-border); + border-radius: var(--border-radius-sm); + color: var(--text-muted); + text-align: center; +} + +.versions-error { + border-style: solid; + border-color: color-mix(in oklch, var(--lora-error) 45%, transparent); + color: var(--lora-error); +} + +.versions-empty i, +.versions-error i { + font-size: 1.25rem; +} + +@media (max-width: 900px) { + .model-version-row { + grid-template-columns: 1fr; + align-items: stretch; + } + + .version-actions { + flex-direction: row; + justify-content: flex-end; + flex-wrap: wrap; + } + + .version-action { + min-width: 0; + } +} diff --git a/static/css/style.css b/static/css/style.css index 2e91f7c1..83b53c3c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -27,6 +27,7 @@ @import 'components/lora-modal/preset-tags.css'; @import 'components/lora-modal/showcase.css'; @import 'components/lora-modal/triggerwords.css'; +@import 'components/lora-modal/versions.css'; @import 'components/shared/edit-metadata.css'; @import 'components/search-filter.css'; @import 'components/bulk.css'; @@ -55,4 +56,4 @@ /* 使用已有的loading-spinner样式 */ .initialization-notice .loading-spinner { margin-bottom: var(--space-2); -} \ No newline at end of file +} diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index 911b792d..bac64710 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -77,6 +77,10 @@ export function getApiEndpoints(modelType) { relinkCivitai: `/api/lm/${modelType}/relink-civitai`, civitaiVersions: `/api/lm/${modelType}/civitai/versions`, refreshUpdates: `/api/lm/${modelType}/updates/refresh`, + modelUpdateStatus: `/api/lm/${modelType}/updates/status`, + modelUpdateVersions: `/api/lm/${modelType}/updates/versions`, + ignoreModelUpdate: `/api/lm/${modelType}/updates/ignore`, + ignoreVersionUpdate: `/api/lm/${modelType}/updates/ignore-version`, // Preview management replacePreview: `/api/lm/${modelType}/replace-preview`, diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 315a0895..8c6af107 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -592,6 +592,73 @@ export class BaseModelApiClient { } } + async fetchModelUpdateVersions(modelId, { refresh = false, force = false } = {}) { + try { + const params = new URLSearchParams(); + if (refresh) params.append('refresh', 'true'); + if (force) params.append('force', 'true'); + const query = params.toString(); + const requestUrl = `${this.apiConfig.endpoints.modelUpdateVersions}/${modelId}${query ? `?${query}` : ''}`; + + const response = await fetch(requestUrl); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to fetch model versions'); + } + return await response.json(); + } catch (error) { + console.error('Error fetching model update versions:', error); + throw error; + } + } + + async setModelUpdateIgnore(modelId, shouldIgnore) { + try { + const response = await fetch(this.apiConfig.endpoints.ignoreModelUpdate, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + modelId, + shouldIgnore, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to update model ignore status'); + } + + return await response.json(); + } catch (error) { + console.error('Error updating model ignore status:', error); + throw error; + } + } + + async setVersionUpdateIgnore(modelId, versionId, shouldIgnore) { + try { + const response = await fetch(this.apiConfig.endpoints.ignoreVersionUpdate, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + modelId, + versionId, + shouldIgnore, + }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to update version ignore status'); + } + + return await response.json(); + } catch (error) { + console.error('Error updating version ignore status:', error); + throw error; + } + } + async fetchModelRoots() { try { const response = await fetch(this.apiConfig.endpoints.roots); @@ -1162,4 +1229,4 @@ export class BaseModelApiClient { completionMessage: translate('loras.bulkOperations.autoOrganizeProgress.complete', {}, 'Auto-organize complete') }); } -} \ No newline at end of file +} diff --git a/static/js/components/shared/ModelDescription.js b/static/js/components/shared/ModelDescription.js index cf38baa4..4ca791ba 100644 --- a/static/js/components/shared/ModelDescription.js +++ b/static/js/components/shared/ModelDescription.js @@ -9,7 +9,8 @@ import { translate } from '../../utils/i18nHelpers.js'; /** * Set up tab switching functionality */ -export function setupTabSwitching() { +export function setupTabSwitching(options = {}) { + const { onTabChange } = options; const tabButtons = document.querySelectorAll('.showcase-tabs .tab-btn'); tabButtons.forEach(button => { @@ -31,6 +32,14 @@ export function setupTabSwitching() { if (button.dataset.tab === 'description') { await loadModelDescription(); } + + if (typeof onTabChange === 'function') { + try { + await onTabChange(button.dataset.tab); + } catch (error) { + console.error('Error handling tab change:', error); + } + } }); }); } @@ -176,4 +185,4 @@ export async function setupModelDescriptionEditing(filePath) { descContainer.classList.remove('editing'); editBtn.classList.remove('visible'); } -} \ No newline at end of file +} diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index 7fc366ce..b39b1a38 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -17,6 +17,7 @@ import { getModelApiClient } from '../../api/modelApiFactory.js'; import { renderCompactTags, setupTagTooltip, formatFileSize } from './utils.js'; import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js'; import { parsePresets, renderPresetTags } from './PresetTags.js'; +import { initVersionsTab } from './ModelVersionsTab.js'; import { loadRecipesForLora } from './RecipeTab.js'; import { translate } from '../../utils/i18nHelpers.js'; @@ -65,19 +66,26 @@ export async function showModelModal(model, modelType) { const examplesText = translate('modals.model.tabs.examples', {}, 'Examples'); const descriptionText = translate('modals.model.tabs.description', {}, 'Model Description'); const recipesText = translate('modals.model.tabs.recipes', {}, 'Recipes'); + const versionsText = translate('modals.model.tabs.versions', {}, 'Versions'); const tabsContent = modelType === 'loras' ? ` - ` : + + ` : ` - `; + + `; const loadingExampleImagesText = translate('modals.model.loading.exampleImages', {}, 'Loading example images...'); const loadingDescriptionText = translate('modals.model.loading.description', {}, 'Loading model description...'); const loadingRecipesText = translate('modals.model.loading.recipes', {}, 'Loading recipes...'); const loadingExamplesText = translate('modals.model.loading.examples', {}, 'Loading examples...'); + const loadingVersionsText = translate('modals.model.loading.versions', {}, 'Loading versions...'); + const civitaiModelId = modelWithFullData.civitai?.modelId || ''; + const civitaiVersionId = modelWithFullData.civitai?.id || ''; + const tabPanesContent = modelType === 'loras' ? `
@@ -99,6 +107,14 @@ export async function showModelModal(model, modelType) {
${loadingRecipesText}
+
+ +
+
+
+ ${loadingVersionsText} +
+
` : `
@@ -114,6 +130,14 @@ export async function showModelModal(model, modelType) {
+
+ +
+
+
+ ${loadingVersionsText} +
+
`; const content = ` @@ -232,9 +256,22 @@ export async function showModelModal(model, modelType) { }; modalManager.showModal(modalId, content, null, onCloseCallback); + const versionsTabController = initVersionsTab({ + modalId, + modelType, + modelId: civitaiModelId, + currentVersionId: civitaiVersionId, + }); setupEditableFields(modelWithFullData.file_path, modelType); setupShowcaseScroll(modalId); - setupTabSwitching(); + setupTabSwitching({ + onTabChange: async (tab) => { + if (tab === 'versions') { + await versionsTabController.load(); + } + }, + }); + versionsTabController.load({ eager: true }); setupTagTooltip(); setupTagEditMode(modelType); setupModelNameEditing(modelWithFullData.file_path); diff --git a/static/js/components/shared/ModelVersionsTab.js b/static/js/components/shared/ModelVersionsTab.js new file mode 100644 index 00000000..cc406d30 --- /dev/null +++ b/static/js/components/shared/ModelVersionsTab.js @@ -0,0 +1,563 @@ +import { getModelApiClient } from '../../api/modelApiFactory.js'; +import { downloadManager } from '../../managers/DownloadManager.js'; +import { showToast } from '../../utils/uiHelpers.js'; +import { translate } from '../../utils/i18nHelpers.js'; +import { state } from '../../state/index.js'; +import { formatFileSize } from './utils.js'; + +const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.mkv']; + +function escapeHtml(value) { + if (value == null) { + return ''; + } + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function isVideoUrl(url) { + if (!url || typeof url !== 'string') { + return false; + } + try { + const parsed = new URL(url, window.location.origin); + const pathname = parsed.pathname || ''; + const extension = pathname.slice(pathname.lastIndexOf('.')).toLowerCase(); + return VIDEO_EXTENSIONS.includes(extension); + } catch (error) { + const normalized = url.split('?')[0].toLowerCase(); + return VIDEO_EXTENSIONS.some(ext => normalized.endsWith(ext)); + } +} + +function formatDateLabel(value) { + if (!value) { + return null; + } + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function buildMetaMarkup(version) { + const segments = []; + if (version.baseModel) { + segments.push( + `${escapeHtml(version.baseModel)}` + ); + } + const releaseLabel = formatDateLabel(version.releasedAt); + if (releaseLabel) { + segments.push(escapeHtml(releaseLabel)); + } + if (typeof version.sizeBytes === 'number' && version.sizeBytes > 0) { + segments.push(escapeHtml(formatFileSize(version.sizeBytes))); + } + + if (!segments.length) { + return escapeHtml( + translate('modals.model.versions.labels.noDetails', {}, 'No additional details') + ); + } + + return segments + .map(segment => `${segment}`) + .join(''); +} + +function buildBadge(label, tone) { + return `${escapeHtml(label)}`; +} + +function getAutoplaySetting() { + try { + return Boolean(state?.global?.settings?.autoplay_on_hover); + } catch (error) { + return false; + } +} + +function renderMediaMarkup(version) { + if (!version.previewUrl) { + const placeholderText = translate('modals.model.versions.media.placeholder', {}, 'No preview'); + return `
${escapeHtml(placeholderText)}
`; + } + + if (isVideoUrl(version.previewUrl)) { + const autoplayOnHover = getAutoplaySetting(); + return ` +
+ +
+ `; + } + + return ` +
+ ${escapeHtml(version.name || 'preview')} +
+ `; +} + +function renderRow(version, options) { + const { latestLibraryVersionId, currentVersionId } = options; + const isCurrent = currentVersionId && version.versionId === currentVersionId; + const isNewer = + typeof latestLibraryVersionId === 'number' && + version.versionId > latestLibraryVersionId; + const badges = []; + + if (isCurrent) { + badges.push(buildBadge(translate('modals.model.versions.badges.current', {}, 'Current Version'), 'current')); + } + + if (version.isInLibrary) { + badges.push(buildBadge(translate('modals.model.versions.badges.inLibrary', {}, 'In Library'), 'success')); + } else if (isNewer && !version.shouldIgnore) { + badges.push(buildBadge(translate('modals.model.versions.badges.newer', {}, 'Newer Version'), 'info')); + } + + if (version.shouldIgnore) { + badges.push(buildBadge(translate('modals.model.versions.badges.ignored', {}, 'Ignored'), 'muted')); + } + + const downloadLabel = translate('modals.model.versions.actions.download', {}, 'Download'); + const deleteLabel = translate('modals.model.versions.actions.delete', {}, 'Delete'); + const ignoreLabel = translate( + version.shouldIgnore + ? 'modals.model.versions.actions.unignore' + : 'modals.model.versions.actions.ignore', + {}, + version.shouldIgnore ? 'Unignore' : 'Ignore' + ); + + const actions = []; + if (!version.isInLibrary) { + actions.push( + `` + ); + } else if (version.filePath) { + actions.push( + `` + ); + } + actions.push( + `` + ); + + return ` +
+ ${renderMediaMarkup(version)} +
+
+ ${escapeHtml(version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version'))} + #${escapeHtml(version.versionId)} +
+
${badges.join('')}
+
+ ${buildMetaMarkup(version)} +
+
+
+ ${actions.join('')} +
+
+ `; +} + +function setupMediaHoverInteractions(container) { + const autoplayOnHover = getAutoplaySetting(); + if (!autoplayOnHover) { + return; + } + container.querySelectorAll('.version-media video').forEach(video => { + if (video.dataset.autoplayOnHover !== 'true') { + return; + } + const play = () => { + try { + video.currentTime = 0; + const promise = video.play(); + if (promise && typeof promise.catch === 'function') { + promise.catch(() => {}); + } + } catch (error) { + console.debug('Failed to autoplay preview video:', error); + } + }; + const stop = () => { + video.pause(); + video.currentTime = 0; + }; + video.addEventListener('mouseenter', play); + video.addEventListener('focus', play); + video.addEventListener('mouseleave', stop); + video.addEventListener('blur', stop); + }); +} + +function getLatestLibraryVersionId(record) { + if (!record || !Array.isArray(record.inLibraryVersionIds) || !record.inLibraryVersionIds.length) { + return null; + } + return Math.max(...record.inLibraryVersionIds); +} + +function renderToolbar(record) { + const ignoreText = record.shouldIgnore + ? translate('modals.model.versions.actions.resumeModelUpdates', {}, 'Resume updates for this model') + : translate('modals.model.versions.actions.ignoreModelUpdates', {}, 'Ignore updates for this model'); + const viewLocalText = translate('modals.model.versions.actions.viewLocalVersions', {}, 'View all local versions'); + const infoText = translate( + 'modals.model.versions.copy', + { count: record.versions.length }, + 'Track and manage every version of this model in one place.' + ); + + return ` +
+
+

${translate('modals.model.versions.heading', {}, 'Model versions')}

+

${escapeHtml(infoText)}

+
+
+ + +
+
+ `; +} + +function renderEmptyState(container) { + const message = translate('modals.model.versions.empty', {}, 'No version history available for this model yet.'); + container.innerHTML = ` +
+ +

${escapeHtml(message)}

+
+ `; +} + +function renderErrorState(container, message) { + const fallback = translate('modals.model.versions.error', {}, 'Failed to load versions.'); + container.innerHTML = ` +
+ +

${escapeHtml(message || fallback)}

+
+ `; +} + +export function initVersionsTab({ + modalId, + modelType, + modelId, + currentVersionId, +}) { + const pane = document.querySelector(`#${modalId} #versions-tab`); + const container = pane ? pane.querySelector('.model-versions-tab') : null; + const normalizedCurrentVersionId = + typeof currentVersionId === 'number' + ? currentVersionId + : currentVersionId + ? Number(currentVersionId) + : null; + + if (!container) { + return { + async load() {}, + async refresh() {}, + }; + } + + let controller = { + isLoading: false, + hasLoaded: false, + record: null, + }; + + let apiClient; + + function ensureClient() { + if (apiClient) { + return apiClient; + } + try { + apiClient = getModelApiClient(modelType); + } catch (error) { + apiClient = getModelApiClient(); + } + return apiClient; + } + + function showLoading() { + const loadingText = translate('modals.model.loading.versions', {}, 'Loading versions...'); + container.innerHTML = ` +
+ ${escapeHtml(loadingText)} +
+ `; + } + + function render(record) { + controller.record = record; + controller.hasLoaded = true; + + if (!record || !Array.isArray(record.versions) || record.versions.length === 0) { + renderEmptyState(container); + return; + } + + const latestLibraryVersionId = getLatestLibraryVersionId(record); + let dividerInserted = false; + + const sortedVersions = [...record.versions].sort( + (a, b) => Number(b.versionId) - Number(a.versionId) + ); + + const rowsMarkup = sortedVersions + .map(version => { + const isNewer = + typeof latestLibraryVersionId === 'number' && + version.versionId > latestLibraryVersionId; + let markup = ''; + if ( + !dividerInserted && + typeof latestLibraryVersionId === 'number' && + !isNewer + ) { + dividerInserted = true; + markup += ''; + } + markup += renderRow(version, { + latestLibraryVersionId, + currentVersionId: normalizedCurrentVersionId, + }); + return markup; + }) + .join(''); + + container.innerHTML = ` + ${renderToolbar(record)} +
+ ${rowsMarkup} +
+ `; + + setupMediaHoverInteractions(container); + } + + async function loadVersions({ forceRefresh = false, eager = false } = {}) { + if (controller.isLoading) { + return; + } + if (!modelId) { + renderErrorState(container, translate('modals.model.versions.missingModelId', {}, 'This model is missing a Civitai model id.')); + return; + } + if (controller.hasLoaded && !forceRefresh) { + return; + } + + controller.isLoading = true; + if (!eager) { + showLoading(); + } + + try { + const client = ensureClient(); + const response = await client.fetchModelUpdateVersions(modelId, { + refresh: true, + }); + if (!response?.success) { + throw new Error(response?.error || 'Request failed'); + } + render(response.record); + } catch (error) { + console.error('Failed to load model versions:', error); + renderErrorState(container, error?.message); + } finally { + controller.isLoading = false; + } + } + + async function refresh() { + await loadVersions({ forceRefresh: true }); + } + + async function handleToggleModelIgnore(button) { + if (!controller.record) { + return; + } + const client = ensureClient(); + const nextValue = !controller.record.shouldIgnore; + button.disabled = true; + try { + const response = await client.setModelUpdateIgnore(modelId, nextValue); + if (!response?.success) { + throw new Error(response?.error || 'Request failed'); + } + render(response.record); + const toastKey = nextValue + ? 'modals.model.versions.toast.modelIgnored' + : 'modals.model.versions.toast.modelResumed'; + const toastMessage = translate( + toastKey, + {}, + nextValue ? 'Updates ignored for this model' : 'Update tracking resumed' + ); + showToast(toastMessage, {}, 'success'); + } catch (error) { + console.error('Failed to update model ignore state:', error); + showToast(error?.message || 'Failed to update ignore preference', {}, 'error'); + } finally { + button.disabled = false; + } + } + + async function handleToggleVersionIgnore(button, versionId) { + if (!controller.record) { + return; + } + const client = ensureClient(); + const targetVersion = controller.record.versions.find(v => v.versionId === versionId); + const nextValue = targetVersion ? !targetVersion.shouldIgnore : true; + button.disabled = true; + try { + const response = await client.setVersionUpdateIgnore( + modelId, + versionId, + nextValue + ); + if (!response?.success) { + throw new Error(response?.error || 'Request failed'); + } + render(response.record); + const updatedVersion = response.record.versions.find(v => v.versionId === versionId); + const toastKey = updatedVersion?.shouldIgnore + ? 'modals.model.versions.toast.versionIgnored' + : 'modals.model.versions.toast.versionUnignored'; + const toastMessage = translate( + toastKey, + {}, + updatedVersion?.shouldIgnore ? 'Updates ignored for this version' : 'Version re-enabled' + ); + showToast(toastMessage, {}, 'success'); + } catch (error) { + console.error('Failed to toggle version ignore state:', error); + showToast(error?.message || 'Failed to update version preference', {}, 'error'); + } finally { + button.disabled = false; + } + } + + async function handleDeleteVersion(button, versionId) { + if (!controller.record) { + return; + } + const version = controller.record.versions.find(item => item.versionId === versionId); + if (!version?.filePath) { + return; + } + const confirmText = translate( + 'modals.model.versions.confirm.delete', + {}, + 'Delete this version from your library?' + ); + if (!window.confirm(confirmText)) { + return; + } + button.disabled = true; + try { + const client = ensureClient(); + await client.deleteModel(version.filePath); + showToast( + translate('modals.model.versions.toast.versionDeleted', {}, 'Version deleted'), + {}, + 'success' + ); + await refresh(); + } catch (error) { + console.error('Failed to delete version:', error); + showToast(error?.message || 'Failed to delete version', {}, 'error'); + } finally { + button.disabled = false; + } + } + + function handleDownloadVersion(versionId) { + if (!controller.record) { + return; + } + downloadManager.openForModelVersion(modelType, modelId, versionId); + } + + container.addEventListener('click', async event => { + const toolbarAction = event.target.closest('[data-versions-action]'); + if (toolbarAction) { + const action = toolbarAction.dataset.versionsAction; + if (action === 'toggle-model-ignore') { + event.preventDefault(); + await handleToggleModelIgnore(toolbarAction); + } + return; + } + + const actionButton = event.target.closest('[data-version-action]'); + if (!actionButton) { + return; + } + const row = actionButton.closest('.model-version-row'); + if (!row) { + return; + } + const versionId = Number(row.dataset.versionId); + const action = actionButton.dataset.versionAction; + + switch (action) { + case 'download': + event.preventDefault(); + handleDownloadVersion(versionId); + break; + case 'delete': + event.preventDefault(); + await handleDeleteVersion(actionButton, versionId); + break; + case 'toggle-ignore': + event.preventDefault(); + await handleToggleVersionIgnore(actionButton, versionId); + break; + default: + break; + } + }); + + return { + load: options => loadVersions(options), + refresh, + }; +} diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index bfd96906..e6967014 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -140,6 +140,14 @@ export class DownloadManager { this.loadDefaultPathSetting(); } + async retrieveVersionsForModel(modelId, source = null) { + this.versions = await this.apiClient.fetchCivitaiVersions(modelId, source); + if (!this.versions || !this.versions.length) { + throw new Error(translate('modals.download.errors.noVersions')); + } + return this.versions; + } + async validateAndFetchVersions() { const url = document.getElementById('modelUrl').value.trim(); const errorElement = document.getElementById('urlError'); @@ -152,12 +160,8 @@ export class DownloadManager { throw new Error(translate('modals.download.errors.invalidUrl')); } - this.versions = await this.apiClient.fetchCivitaiVersions(this.modelId, this.source); - - if (!this.versions.length) { - throw new Error(translate('modals.download.errors.noVersions')); - } - + await this.retrieveVersionsForModel(this.modelId, this.source); + // If we have a version ID from URL, pre-select it if (this.modelVersionId) { this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); @@ -171,6 +175,27 @@ export class DownloadManager { } } + async fetchVersionsForCurrentModel() { + const errorElement = document.getElementById('urlError'); + if (errorElement) { + errorElement.textContent = ''; + } + try { + this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions')); + await this.retrieveVersionsForModel(this.modelId, this.source); + if (this.modelVersionId) { + this.currentVersion = this.versions.find(v => v.id.toString() === this.modelVersionId); + } + this.showVersionStep(); + } catch (error) { + if (errorElement) { + errorElement.textContent = error.message; + } + } finally { + this.loadingManager.hide(); + } + } + extractModelId(url) { const versionMatch = url.match(/modelVersionId=(\d+)/i); this.modelVersionId = versionMatch ? versionMatch[1] : null; @@ -191,6 +216,26 @@ export class DownloadManager { return null; } + async openForModelVersion(modelType, modelId, versionId = null) { + try { + this.apiClient = getModelApiClient(modelType); + } catch (error) { + this.apiClient = getModelApiClient(); + } + + this.showDownloadModal(); + + this.modelId = modelId ? modelId.toString() : null; + this.modelVersionId = versionId ? versionId.toString() : null; + this.source = null; + + if (!this.modelId) { + return; + } + + await this.fetchVersionsForCurrentModel(); + } + showVersionStep() { document.getElementById('urlStep').style.display = 'none'; document.getElementById('versionStep').style.display = 'block'; diff --git a/tests/routes/test_model_update_handler.py b/tests/routes/test_model_update_handler.py index b85fa83d..a378115c 100644 --- a/tests/routes/test_model_update_handler.py +++ b/tests/routes/test_model_update_handler.py @@ -41,7 +41,7 @@ class DummyUpdateService: @pytest.mark.asyncio -async def test_build_preview_overrides_uses_static_urls(): +async def test_build_version_context_includes_static_urls(): cache = SimpleNamespace(version_index={123: {"preview_url": "/tmp/previews/example.png"}}) service = DummyService(cache) handler = ModelUpdateHandler( @@ -70,9 +70,9 @@ async def test_build_preview_overrides_uses_static_urls(): should_ignore_model=False, ) - overrides = await handler._build_preview_overrides(record) + overrides = await handler._build_version_context(record) expected = config.get_preview_static_url("/tmp/previews/example.png") - assert overrides == {123: expected} + assert overrides == {123: {"file_path": None, "file_name": None, "preview_override": expected}} @pytest.mark.asyncio