From 8ca66e72eb3f55bf8ab4bed95ee2082d8e0ed4e1 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Mon, 24 Aug 2026 09:27:00 +0800 Subject: [PATCH] feat(ui): add delete button and Del shortcut to model and recipe modals --- locales/de.json | 6 +- locales/en.json | 6 +- locales/es.json | 6 +- locales/fr.json | 6 +- locales/he.json | 6 +- locales/ja.json | 6 +- locales/ko.json | 6 +- locales/ru.json | 6 +- locales/zh-CN.json | 6 +- locales/zh-TW.json | 6 +- .../css/components/lora-modal/lora-modal.css | 32 ++- static/js/components/RecipeCard.js | 247 ++++++++++-------- static/js/components/RecipeModal.js | 25 +- static/js/components/shared/ModelModal.js | 31 +++ templates/components/recipe_modal.html | 3 + 15 files changed, 258 insertions(+), 140 deletions(-) diff --git a/locales/de.json b/locales/de.json index 23916275..233948e0 100644 --- a/locales/de.json +++ b/locales/de.json @@ -855,7 +855,8 @@ "title": "LoRA-Rezepte", "actions": { "sendCheckpoint": "Send to ComfyUI", - "sendRecipe": "Send to ComfyUI" + "sendRecipe": "Send to ComfyUI", + "deleteRecipeWithShortcut": "Rezept löschen (Del)" }, "navigation": { "label": "Rezeptnavigation", @@ -1440,7 +1441,8 @@ "openFileLocation": "Dateispeicherort öffnen", "sendToWorkflow": "An ComfyUI senden", "sendToWorkflowText": "An ComfyUI senden", - "copyHash": "Hash kopieren" + "copyHash": "Hash kopieren", + "deleteModelWithShortcut": "Modell löschen (Del)" }, "openFileLocation": { "success": "Dateispeicherort erfolgreich geöffnet", diff --git a/locales/en.json b/locales/en.json index e715cdeb..98842270 100644 --- a/locales/en.json +++ b/locales/en.json @@ -855,7 +855,8 @@ "title": "LoRA Recipes", "actions": { "sendCheckpoint": "Send to ComfyUI", - "sendRecipe": "Send to ComfyUI" + "sendRecipe": "Send to ComfyUI", + "deleteRecipeWithShortcut": "Delete recipe (Del)" }, "navigation": { "label": "Recipe navigation", @@ -1440,7 +1441,8 @@ "openFileLocation": "Open File Location", "sendToWorkflow": "Send to ComfyUI", "sendToWorkflowText": "Send to ComfyUI", - "copyHash": "Copy hash" + "copyHash": "Copy hash", + "deleteModelWithShortcut": "Delete model (Del)" }, "openFileLocation": { "success": "File location opened successfully", diff --git a/locales/es.json b/locales/es.json index 0c340376..b6dc6782 100644 --- a/locales/es.json +++ b/locales/es.json @@ -855,7 +855,8 @@ "title": "Recetas de LoRA", "actions": { "sendCheckpoint": "Enviar a ComfyUI", - "sendRecipe": "Enviar a ComfyUI" + "sendRecipe": "Enviar a ComfyUI", + "deleteRecipeWithShortcut": "Eliminar receta (Del)" }, "navigation": { "label": "Navegación de recetas", @@ -1440,7 +1441,8 @@ "openFileLocation": "Abrir ubicación del archivo", "sendToWorkflow": "Enviar a ComfyUI", "sendToWorkflowText": "Enviar a ComfyUI", - "copyHash": "Copiar hash" + "copyHash": "Copiar hash", + "deleteModelWithShortcut": "Eliminar modelo (Del)" }, "openFileLocation": { "success": "Ubicación del archivo abierta exitosamente", diff --git a/locales/fr.json b/locales/fr.json index 7fb97081..69056059 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -855,7 +855,8 @@ "title": "LoRA Recipes", "actions": { "sendCheckpoint": "Envoyer vers ComfyUI", - "sendRecipe": "Envoyer vers ComfyUI" + "sendRecipe": "Envoyer vers ComfyUI", + "deleteRecipeWithShortcut": "Supprimer la recette (Del)" }, "navigation": { "label": "Navigation des recettes", @@ -1440,7 +1441,8 @@ "openFileLocation": "Ouvrir l'emplacement du fichier", "sendToWorkflow": "Envoyer vers ComfyUI", "sendToWorkflowText": "Envoyer vers ComfyUI", - "copyHash": "Copier le hash" + "copyHash": "Copier le hash", + "deleteModelWithShortcut": "Supprimer le modèle (Del)" }, "openFileLocation": { "success": "Emplacement du fichier ouvert avec succès", diff --git a/locales/he.json b/locales/he.json index de13c4a8..130f4e57 100644 --- a/locales/he.json +++ b/locales/he.json @@ -855,7 +855,8 @@ "title": "מתכוני LoRA", "actions": { "sendCheckpoint": "שלח ל-ComfyUI", - "sendRecipe": "שלח ל-ComfyUI" + "sendRecipe": "שלח ל-ComfyUI", + "deleteRecipeWithShortcut": "מחק מתכון (Del)" }, "navigation": { "label": "ניווט מתכונים", @@ -1440,7 +1441,8 @@ "openFileLocation": "פתח מיקום קובץ", "sendToWorkflow": "שלח ל-ComfyUI", "sendToWorkflowText": "שלח ל-ComfyUI", - "copyHash": "העתק האש" + "copyHash": "העתק האש", + "deleteModelWithShortcut": "מחק מודל (Del)" }, "openFileLocation": { "success": "מיקום הקובץ נפתח בהצלחה", diff --git a/locales/ja.json b/locales/ja.json index 8bd985d4..ca25ee1d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -855,7 +855,8 @@ "title": "LoRAレシピ", "actions": { "sendCheckpoint": "ComfyUIへ送信", - "sendRecipe": "ComfyUIへ送信" + "sendRecipe": "ComfyUIへ送信", + "deleteRecipeWithShortcut": "レシピを削除(Del)" }, "navigation": { "label": "レシピナビゲーション", @@ -1440,7 +1441,8 @@ "openFileLocation": "ファイルの場所を開く", "sendToWorkflow": "ComfyUI に送信", "sendToWorkflowText": "ComfyUI に送信", - "copyHash": "ハッシュをコピー" + "copyHash": "ハッシュをコピー", + "deleteModelWithShortcut": "モデルを削除(Del)" }, "openFileLocation": { "success": "ファイルの場所を正常に開きました", diff --git a/locales/ko.json b/locales/ko.json index acbbffa9..a98b1d2f 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -855,7 +855,8 @@ "title": "LoRA 레시피", "actions": { "sendCheckpoint": "ComfyUI로 보내기", - "sendRecipe": "ComfyUI로 보내기" + "sendRecipe": "ComfyUI로 보내기", + "deleteRecipeWithShortcut": "레시피 삭제(Del)" }, "navigation": { "label": "레시피 탐색", @@ -1440,7 +1441,8 @@ "openFileLocation": "파일 위치 열기", "sendToWorkflow": "ComfyUI로 보내기", "sendToWorkflowText": "ComfyUI로 보내기", - "copyHash": "해시 복사" + "copyHash": "해시 복사", + "deleteModelWithShortcut": "모델 삭제(Del)" }, "openFileLocation": { "success": "파일 위치가 성공적으로 열렸습니다", diff --git a/locales/ru.json b/locales/ru.json index 690e39ef..29f30215 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -855,7 +855,8 @@ "title": "Рецепты LoRA", "actions": { "sendCheckpoint": "Отправить в ComfyUI", - "sendRecipe": "Отправить в ComfyUI" + "sendRecipe": "Отправить в ComfyUI", + "deleteRecipeWithShortcut": "Удалить рецепт (Del)" }, "navigation": { "label": "Навигация по рецептам", @@ -1440,7 +1441,8 @@ "openFileLocation": "Открыть расположение файла", "sendToWorkflow": "Отправить в ComfyUI", "sendToWorkflowText": "Отправить в ComfyUI", - "copyHash": "Копировать хэш" + "copyHash": "Копировать хэш", + "deleteModelWithShortcut": "Удалить модель (Del)" }, "openFileLocation": { "success": "Расположение файла успешно открыто", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 9657fdfa..b62e5af1 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -855,7 +855,8 @@ "title": "LoRA 配方", "actions": { "sendCheckpoint": "发送到 ComfyUI", - "sendRecipe": "发送到 ComfyUI" + "sendRecipe": "发送到 ComfyUI", + "deleteRecipeWithShortcut": "删除配方(Del)" }, "navigation": { "label": "配方导航", @@ -1440,7 +1441,8 @@ "openFileLocation": "打开文件位置", "sendToWorkflow": "发送到 ComfyUI", "sendToWorkflowText": "发送到 ComfyUI", - "copyHash": "复制哈希值" + "copyHash": "复制哈希值", + "deleteModelWithShortcut": "删除模型(Del)" }, "openFileLocation": { "success": "文件位置已成功打开", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index f803a274..f5c7d836 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -855,7 +855,8 @@ "title": "LoRA 配方", "actions": { "sendCheckpoint": "傳送到 ComfyUI", - "sendRecipe": "傳送到 ComfyUI" + "sendRecipe": "傳送到 ComfyUI", + "deleteRecipeWithShortcut": "刪除配方(Del)" }, "navigation": { "label": "配方導覽", @@ -1440,7 +1441,8 @@ "openFileLocation": "開啟檔案位置", "sendToWorkflow": "傳送到 ComfyUI", "sendToWorkflowText": "傳送到 ComfyUI", - "copyHash": "複製雜湊值" + "copyHash": "複製雜湊值", + "deleteModelWithShortcut": "刪除模型(Del)" }, "openFileLocation": { "success": "檔案位置已成功開啟", diff --git a/static/css/components/lora-modal/lora-modal.css b/static/css/components/lora-modal/lora-modal.css index fd095033..a5e80656 100644 --- a/static/css/components/lora-modal/lora-modal.css +++ b/static/css/components/lora-modal/lora-modal.css @@ -68,14 +68,40 @@ font-size: 14px; } -.modal-header-actions .license-restrictions { +/* Destructive modal action: ghost icon button pushed to the far right of the + header actions row, revealing the danger color only on hover/focus. Shared + by the model modal and the recipe modal. */ +.modal-delete-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; margin-left: auto; + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + color: var(--text-secondary); + cursor: pointer; + transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease; } -.modal-header-actions .license-permissions { - margin-left: auto; +.modal-delete-btn:hover, +.modal-delete-btn:focus-visible { + color: var(--lora-error); + border-color: var(--lora-error); + background: oklch(from var(--lora-error) l c h / 0.08); } +.modal-delete-btn i { + font-size: 14px; +} + +/* License icons no longer carry their own auto margin: the delete button's + margin-left: auto right-anchors the [delete][license] cluster, keeping the + icons flush against the right edge as before the delete button existed. */ + .license-restrictions { display: flex; align-items: center; diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 531813f6..a934e9d2 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -339,124 +339,11 @@ class RecipeCard { } showDeleteConfirmation() { - try { - // Get recipe ID - const recipeId = this.recipe.id; - const filePath = this.recipe.file_path; - if (!recipeId) { - showToast('toast.recipes.cannotDelete', {}, 'error'); - return; - } - - // Create delete modal content - const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png'; - const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm'); - - const deleteModalContent = ` - - `; - - // Show the modal with custom content and setup callbacks - modalManager.showModal('deleteModal', deleteModalContent, () => { - // This is the onClose callback - const deleteModal = document.getElementById('deleteModal'); - const deleteBtn = deleteModal.querySelector('.delete-btn'); - deleteBtn.textContent = 'Delete'; - deleteBtn.disabled = false; - }); - - // Set up the delete and cancel buttons with proper event handlers - const deleteModal = document.getElementById('deleteModal'); - const cancelBtn = deleteModal.querySelector('.cancel-btn'); - const deleteBtn = deleteModal.querySelector('.delete-btn'); - - // Store recipe ID in the modal for the delete confirmation handler - deleteModal.dataset.recipeId = recipeId; - deleteModal.dataset.filePath = filePath; - - // Update button event handlers - cancelBtn.onclick = () => modalManager.closeModal('deleteModal'); - deleteBtn.onclick = () => this.confirmDeleteRecipe(); - - } catch (error) { - console.error('Error showing delete confirmation:', error); - showToast('toast.recipes.deleteConfirmationError', {}, 'error'); - } + showRecipeDeleteConfirmation(this.recipe); } confirmDeleteRecipe() { - const deleteModal = document.getElementById('deleteModal'); - const recipeId = deleteModal.dataset.recipeId; - - if (!recipeId) { - showToast('toast.recipes.cannotDelete', {}, 'error'); - modalManager.closeModal('deleteModal'); - return; - } - - // Show loading state - const deleteBtn = deleteModal.querySelector('.delete-btn'); - const originalText = deleteBtn.textContent; - deleteBtn.textContent = 'Deleting...'; - deleteBtn.disabled = true; - - // Call API to delete the recipe - fetch(`/api/lm/recipe/${recipeId}`, { - method: 'DELETE', - headers: { - 'Content-Type': 'application/json' - } - }) - .then(response => { - if (!response.ok) { - throw new Error('Failed to delete recipe'); - } - return response.json(); - }) - .then(data => { - if (data.batch_id) { - // Staged delete: offer undo instead of the plain success toast - const batchId = data.batch_id; - showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', { - actionText: translate('toast.undo.action'), - onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)), - }); - } else { - showToast('toast.recipes.deletedSuccessfully', {}, 'success'); - } - - state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath); - - modalManager.closeModal('deleteModal'); - }) - .catch(error => { - console.error('Error deleting recipe:', error); - showToast('toast.recipes.deleteFailed', { message: error.message }, 'error'); - - // Reset button state - deleteBtn.textContent = originalText; - deleteBtn.disabled = false; - }); + confirmRecipeDelete(this.recipe); } shareRecipe() { @@ -507,4 +394,134 @@ class RecipeCard { } } +/** + * Show the delete confirmation modal for a recipe. Shared by RecipeCard and + * RecipeModal so the flow stays identical regardless of where it starts. + * @param {Object} recipe - The recipe to delete + */ +export function showRecipeDeleteConfirmation(recipe) { + try { + // Get recipe ID + const recipeId = recipe.id; + const filePath = recipe.file_path; + if (!recipeId) { + showToast('toast.recipes.cannotDelete', {}, 'error'); + return; + } + + // Create delete modal content + const previewUrl = recipe.file_url || '/loras_static/images/no-preview.png'; + const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm'); + + const deleteModalContent = ` + + `; + + // Show the modal with custom content and setup callbacks + modalManager.showModal('deleteModal', deleteModalContent, () => { + // This is the onClose callback + const deleteModal = document.getElementById('deleteModal'); + const deleteBtn = deleteModal.querySelector('.delete-btn'); + deleteBtn.textContent = 'Delete'; + deleteBtn.disabled = false; + }); + + // Set up the delete and cancel buttons with proper event handlers + const deleteModal = document.getElementById('deleteModal'); + const cancelBtn = deleteModal.querySelector('.cancel-btn'); + const deleteBtn = deleteModal.querySelector('.delete-btn'); + + // Store recipe ID in the modal for the delete confirmation handler + deleteModal.dataset.recipeId = recipeId; + deleteModal.dataset.filePath = filePath; + + // Update button event handlers + cancelBtn.onclick = () => modalManager.closeModal('deleteModal'); + deleteBtn.onclick = () => confirmRecipeDelete(recipe); + + } catch (error) { + console.error('Error showing delete confirmation:', error); + showToast('toast.recipes.deleteConfirmationError', {}, 'error'); + } +} + +/** + * Execute the recipe deletion after the user confirms in the delete modal. + * @param {Object} recipe - The recipe being deleted (used for toast messaging) + */ +function confirmRecipeDelete(recipe) { + const deleteModal = document.getElementById('deleteModal'); + const recipeId = deleteModal.dataset.recipeId; + + if (!recipeId) { + showToast('toast.recipes.cannotDelete', {}, 'error'); + modalManager.closeModal('deleteModal'); + return; + } + + // Show loading state + const deleteBtn = deleteModal.querySelector('.delete-btn'); + const originalText = deleteBtn.textContent; + deleteBtn.textContent = 'Deleting...'; + deleteBtn.disabled = true; + + // Call API to delete the recipe + fetch(`/api/lm/recipe/${recipeId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json' + } + }) + .then(response => { + if (!response.ok) { + throw new Error('Failed to delete recipe'); + } + return response.json(); + }) + .then(data => { + if (data.batch_id) { + // Staged delete: offer undo instead of the plain success toast + const batchId = data.batch_id; + showActionToast('toast.undo.deleted', { name: recipe.title }, 'success', { + actionText: translate('toast.undo.action'), + onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)), + }); + } else { + showToast('toast.recipes.deletedSuccessfully', {}, 'success'); + } + + state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath); + + modalManager.closeModal('deleteModal'); + }) + .catch(error => { + console.error('Error deleting recipe:', error); + showToast('toast.recipes.deleteFailed', { message: error.message }, 'error'); + + // Reset button state + deleteBtn.textContent = originalText; + deleteBtn.disabled = false; + }); +} + export { RecipeCard }; diff --git a/static/js/components/RecipeModal.js b/static/js/components/RecipeModal.js index 55c6c3ce..6d9e0a80 100644 --- a/static/js/components/RecipeModal.js +++ b/static/js/components/RecipeModal.js @@ -8,6 +8,7 @@ import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow } from '.. import { downloadManager } from '../managers/DownloadManager.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; import { openMediaViewer } from './shared/MediaViewer.js'; +import { showRecipeDeleteConfirmation } from './RecipeCard.js'; import { renderCompactTags, setupTagTooltip } from './shared/utils.js'; import { setupTagEditMode } from './shared/ModelTags.js'; @@ -123,6 +124,7 @@ class RecipeModal { this.setupStripLoraToggle(); this.setupPromptEditors(); this.setupNavigationControls(); + this.setupDeleteControl(); // Set up tooltip positioning handlers after DOM is ready document.addEventListener('DOMContentLoaded', () => { this.setupTooltipPositioning(); @@ -180,6 +182,18 @@ class RecipeModal { this.updateNavigationControls(); } + setupDeleteControl() { + const deleteBtn = document.getElementById('deleteRecipeBtn'); + if (deleteBtn) { + deleteBtn.addEventListener('click', () => this.handleDeleteRecipe()); + } + } + + handleDeleteRecipe() { + if (!this.currentRecipe) return; + showRecipeDeleteConfirmation(this.currentRecipe); + } + shouldIgnoreNavigationKey(event) { const target = event.target; if (!target) return false; @@ -230,6 +244,9 @@ class RecipeModal { } else if (event.key === 'ArrowRight') { event.preventDefault(); this.handleDirectionalNavigation('next'); + } else if (event.key === 'Delete') { + event.preventDefault(); + this.handleDeleteRecipe(); } }; @@ -624,6 +641,10 @@ class RecipeModal { actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove()); + // Keep the delete button as the last (rightmost) header action; + // insertBefore with null falls back to appendChild if it is missing. + const deleteBtn = document.getElementById('deleteRecipeBtn'); + if (this.currentRecipe?.has_workflow === true) { const workflowBtn = document.createElement('button'); workflowBtn.className = 'recipe-source-url-btn'; @@ -633,7 +654,7 @@ class RecipeModal { workflowBtn.addEventListener('click', () => { this.sendWorkflowToComfyUI(); }); - actionsContainer.appendChild(workflowBtn); + actionsContainer.insertBefore(workflowBtn, deleteBtn); } const sourcePath = this.currentRecipe?.source_path || ''; @@ -646,7 +667,7 @@ class RecipeModal { btn.addEventListener('click', () => { window.open(sourcePath, '_blank'); }); - actionsContainer.appendChild(btn); + actionsContainer.insertBefore(btn, deleteBtn); } } diff --git a/static/js/components/shared/ModelModal.js b/static/js/components/shared/ModelModal.js index a6918b54..c9e36c14 100644 --- a/static/js/components/shared/ModelModal.js +++ b/static/js/components/shared/ModelModal.js @@ -20,6 +20,7 @@ import { parsePresets, renderPresetTags } from './PresetTags.js'; import { initVersionsTab } from './ModelVersionsTab.js'; import { loadRecipesForModel } from './RecipeTab.js'; import { translate } from '../../utils/i18nHelpers.js'; +import { showDeleteModal } from '../../utils/modalUtils.js'; import { state } from '../../state/index.js'; function getModalFilePath(fallback = '') { @@ -441,9 +442,22 @@ export async function showModelModal(model, modelType) { if (creatorActionsMarkup) { headerActionItems.push(creatorActionsMarkup); } + + // Destructive action docks immediately left of the license icons so the + // icons keep their original rightmost position; the delete button's + // auto margin right-anchors the whole cluster. + const deleteModelTitle = translate('modals.model.actions.deleteModelWithShortcut', {}, 'Delete model (Del)'); + const deleteModelButton = ` + + `.trim(); + headerActionItems.push(indentMarkup(deleteModelButton, 20)); + if (licenseIcons) { headerActionItems.push(indentMarkup(licenseIcons.trim(), 20)); } + const headerActionsMarkup = headerActionItems.length ? [ '