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' ? `
${escapeHtml(message)}
+${escapeHtml(message || fallback)}
+