diff --git a/locales/de.json b/locales/de.json index d69bcf0d..47a74890 100644 --- a/locales/de.json +++ b/locales/de.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}", "previewUpdated": "Vorschau erfolgreich aktualisiert", "previewUploadFailed": "Fehler beim Hochladen des Vorschaubilds", + "previewDropInvalid": "Nicht unterstützter Dateityp: {name}. Ziehen Sie stattdessen ein Bild oder ein MP4-Video hinein.", "refreshComplete": "{action} abgeschlossen", "refreshFailed": "Fehler beim {action} der {type}s", "metadataRefreshed": "Metadaten erfolgreich aktualisiert", diff --git a/locales/en.json b/locales/en.json index 582f6633..46c809f8 100644 --- a/locales/en.json +++ b/locales/en.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "Failed to rename file: {error}", "previewUpdated": "Preview updated successfully", "previewUploadFailed": "Failed to upload preview image", + "previewDropInvalid": "Unsupported file type: {name}. Drop an image or MP4 video instead.", "refreshComplete": "{action} complete", "refreshFailed": "Failed to {action} {type}s", "metadataRefreshed": "Metadata refreshed successfully", diff --git a/locales/es.json b/locales/es.json index 82a5598c..25ddf174 100644 --- a/locales/es.json +++ b/locales/es.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "Error al renombrar archivo: {error}", "previewUpdated": "Vista previa actualizada exitosamente", "previewUploadFailed": "Error al subir imagen de vista previa", + "previewDropInvalid": "Tipo de archivo no admitido: {name}. Arrastra una imagen o un video MP4 en su lugar.", "refreshComplete": "{action} completada", "refreshFailed": "Error al {action} {type}s", "metadataRefreshed": "Metadatos actualizados exitosamente", diff --git a/locales/fr.json b/locales/fr.json index 2df53e66..b67b9fbd 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "Échec du renommage du fichier : {error}", "previewUpdated": "Aperçu mis à jour avec succès", "previewUploadFailed": "Échec du téléchargement de l'image d'aperçu", + "previewDropInvalid": "Type de fichier non pris en charge : {name}. Déposez plutôt une image ou une vidéo MP4.", "refreshComplete": "{action} terminé", "refreshFailed": "Échec de {action} des {type}s", "metadataRefreshed": "Métadonnées actualisées avec succès", diff --git a/locales/he.json b/locales/he.json index 7db93bb9..de3cafd9 100644 --- a/locales/he.json +++ b/locales/he.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "שינוי שם הקובץ נכשל: {error}", "previewUpdated": "התצוגה המקדימה עודכנה בהצלחה", "previewUploadFailed": "העלאת תמונת התצוגה המקדימה נכשלה", + "previewDropInvalid": "סוג קובץ לא נתמך: {name}. גרור במקום זאת תמונה או סרטון MP4.", "refreshComplete": "{action} הושלם", "refreshFailed": "{action} של {type}s נכשל", "metadataRefreshed": "המטא-דאטה רועננה בהצלחה", diff --git a/locales/ja.json b/locales/ja.json index 9589ac2c..1a9fab0a 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "ファイル名の変更に失敗しました:{error}", "previewUpdated": "プレビューが正常に更新されました", "previewUploadFailed": "プレビュー画像のアップロードに失敗しました", + "previewDropInvalid": "サポートされていないファイル形式:{name}。画像またはMP4ビデオをドロップしてください。", "refreshComplete": "{action} 完了", "refreshFailed": "{type}の{action}に失敗しました", "metadataRefreshed": "メタデータが正常に更新されました", diff --git a/locales/ko.json b/locales/ko.json index 676e6ae7..e6c694e7 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "파일 이름 변경 실패: {error}", "previewUpdated": "미리보기가 성공적으로 업데이트되었습니다", "previewUploadFailed": "미리보기 이미지 업로드 실패", + "previewDropInvalid": "지원되지 않는 파일 형식: {name}. 이미지 또는 MP4 동영상을 드롭하세요.", "refreshComplete": "{action} 완료", "refreshFailed": "{type} {action} 실패", "metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다", diff --git a/locales/ru.json b/locales/ru.json index da4c9721..0d637486 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "Не удалось переименовать файл: {error}", "previewUpdated": "Превью успешно обновлено", "previewUploadFailed": "Не удалось загрузить превью изображение", + "previewDropInvalid": "Неподдерживаемый тип файла: {name}. Перетащите вместо этого изображение или видео MP4.", "refreshComplete": "{action} завершено", "refreshFailed": "Не удалось {action} {type}s", "metadataRefreshed": "Метаданные успешно обновлены", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 23236470..a0e588eb 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "重命名文件失败:{error}", "previewUpdated": "预览图片更新成功", "previewUploadFailed": "上传预览图片失败", + "previewDropInvalid": "不支持的文件类型:{name}。请拖入图片或 MP4 视频。", "refreshComplete": "{action} 完成", "refreshFailed": "{action} {type} 失败", "metadataRefreshed": "元数据刷新成功", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a3c6813b..dc79aba7 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -2190,6 +2190,7 @@ "fileRenameFailed": "重新命名檔案失敗:{error}", "previewUpdated": "預覽圖片已成功更新", "previewUploadFailed": "上傳預覽圖片失敗", + "previewDropInvalid": "不支援的檔案類型:{name}。請拖入圖片或 MP4 影片。", "refreshComplete": "{action} 完成", "refreshFailed": "{action} {type} 失敗", "metadataRefreshed": "metadata 已成功刷新", diff --git a/static/css/components/card.css b/static/css/components/card.css index 6d62d7ed..be8d7ba6 100644 --- a/static/css/components/card.css +++ b/static/css/components/card.css @@ -41,6 +41,11 @@ border-color: var(--lora-accent); } +.model-card.drag-over { + outline: 2px dashed var(--lora-accent); + outline-offset: -2px; +} + .model-card:focus-visible { outline: 2px solid var(--lora-accent); outline-offset: 2px; diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index 642b41e5..3202d961 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -741,6 +741,46 @@ export function createModelCard(model, modelType) { configureModelCardVideo(videoElement, autoplayOnHover); } + // Dropping an image/video onto the card replaces the model preview via the + // existing replace-preview endpoint (overwrites file on disk, refreshes card). + const preventDragDefaults = (event) => { + event.preventDefault(); + event.stopPropagation(); + }; + + ['dragenter', 'dragover'].forEach((eventName) => { + card.addEventListener(eventName, (event) => { + preventDragDefaults(event); + card.classList.add('drag-over'); + }); + }); + + card.addEventListener('dragleave', (event) => { + preventDragDefaults(event); + card.classList.remove('drag-over'); + }); + + card.addEventListener('drop', (event) => { + preventDragDefaults(event); + card.classList.remove('drag-over'); + + const files = event.dataTransfer?.files; + if (!files || files.length === 0) return; + + const file = files[0]; + // Keep in sync with the accept list of the preview file picker (image/* + video/mp4). + if (!file.type.startsWith('image/') && file.type !== 'video/mp4') { + showToast('toast.api.previewDropInvalid', { name: file.name || '' }, 'error'); + return; + } + + const filePath = card.dataset.filepath; + if (!filePath) return; + + // uploadPreview handles loading state, card refresh and error toasts internally. + getModelApiClient().uploadPreview(filePath, file); + }); + return card; } diff --git a/tests/frontend/components/modelCard.previewDrop.test.js b/tests/frontend/components/modelCard.previewDrop.test.js new file mode 100644 index 00000000..10c9b515 --- /dev/null +++ b/tests/frontend/components/modelCard.previewDrop.test.js @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + MODEL_CARD_MODULE, + STATE_MODULE, + UI_HELPERS_MODULE, + I18N_MODULE, + API_CONFIG_MODULE, + API_FACTORY_MODULE, +} = vi.hoisted(() => ({ + MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname, + STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname, + UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname, + I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname, + API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname, + API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname, +})); + +const showToastMock = vi.fn(); +const uploadPreviewMock = vi.fn(); + +vi.mock(STATE_MODULE, () => ({ + state: { + settings: { + blur_mature_content: false, + model_name_display: 'model_name', + }, + global: { + settings: { + model_name_display: 'model_name', + group_by_model: false, + display_density: 'default', + model_card_footer_action: 'replace_preview', + }, + }, + pages: { + loras: { + previewVersions: new Map(), + sortBy: 'name', + }, + }, + bulkMode: false, + selectedLoras: new Set(), + }, + getCurrentPageState: vi.fn(() => ({ + sortBy: 'name', + previewVersions: new Map(), + })), +})); + +vi.mock(UI_HELPERS_MODULE, () => ({ + showToast: showToastMock, + openCivitai: vi.fn(), + openHuggingFace: vi.fn(), + copyToClipboard: vi.fn(), + copyLoraSyntax: vi.fn(), + sendLoraToWorkflow: vi.fn(), + sendEmbeddingToWorkflow: vi.fn(), + openExampleImagesFolder: vi.fn(), + buildLoraSyntax: vi.fn(), + sendModelPathToWorkflow: vi.fn(), +})); + +vi.mock(I18N_MODULE, () => ({ + translate: vi.fn((key) => key), +})); + +vi.mock(API_CONFIG_MODULE, () => ({ + MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' }, +})); + +vi.mock(API_FACTORY_MODULE, () => ({ + getModelApiClient: vi.fn(() => ({ uploadPreview: uploadPreviewMock })), +})); + +describe('ModelCard drag & drop preview upload', () => { + let createModelCard; + + beforeEach(async () => { + showToastMock.mockReset(); + uploadPreviewMock.mockReset(); + ({ createModelCard } = await import(MODEL_CARD_MODULE)); + }); + + function createCard() { + const model = { + sha256: 'abc123', + file_path: '/models/test_lora.safetensors', + model_name: 'Test LoRA', + file_name: 'test_lora', + folder: 'models', + modified: 1234567890, + file_size: 1024, + usage_count: 0, + notes: '', + base_model: 'SD1.5', + favorite: false, + exclude: false, + hf_url: '', + update_available: false, + skip_metadata_refresh: false, + preview_url: '', + preview_nsfw_level: 0, + tags: [], + civitai: {}, + sub_type: 'lora', + }; + return createModelCard(model, 'loras'); + } + + function dispatchDrop(card, files) { + const event = new Event('drop', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: { files } }); + card.dispatchEvent(event); + return event; + } + + it('uploads the dropped image as the model preview', () => { + const card = createCard(); + const file = new File(['data'], 'preview.png', { type: 'image/png' }); + + dispatchDrop(card, [file]); + + expect(uploadPreviewMock).toHaveBeenCalledTimes(1); + expect(uploadPreviewMock).toHaveBeenCalledWith('/models/test_lora.safetensors', file); + }); + + it('supports MP4 video files', () => { + const card = createCard(); + const file = new File(['data'], 'preview.mp4', { type: 'video/mp4' }); + + dispatchDrop(card, [file]); + + expect(uploadPreviewMock).toHaveBeenCalledTimes(1); + }); + + it('rejects unsupported file types with a toast', () => { + const card = createCard(); + const file = new File(['data'], 'notes.txt', { type: 'text/plain' }); + + dispatchDrop(card, [file]); + + expect(uploadPreviewMock).not.toHaveBeenCalled(); + expect(showToastMock).toHaveBeenCalledWith( + 'toast.api.previewDropInvalid', + { name: 'notes.txt' }, + 'error' + ); + }); + + it('ignores drops without files', () => { + const card = createCard(); + + dispatchDrop(card, []); + + expect(uploadPreviewMock).not.toHaveBeenCalled(); + }); + + it('prevents browser default and highlights the card while dragging over', () => { + const card = createCard(); + + const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true }); + card.dispatchEvent(dragOverEvent); + expect(dragOverEvent.defaultPrevented).toBe(true); + expect(card.classList.contains('drag-over')).toBe(true); + + const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true }); + card.dispatchEvent(dragLeaveEvent); + expect(dragLeaveEvent.defaultPrevented).toBe(true); + expect(card.classList.contains('drag-over')).toBe(false); + }); + + it('clears the highlight when the drop completes', () => { + const card = createCard(); + const file = new File(['data'], 'preview.png', { type: 'image/png' }); + + const event = dispatchDrop(card, [file]); + + expect(event.defaultPrevented).toBe(true); + expect(card.classList.contains('drag-over')).toBe(false); + }); +});