feat(ui): add delete button and Del shortcut to model and recipe modals

This commit is contained in:
Will Miao
2026-08-24 09:27:00 +08:00
parent 90be5799e4
commit 8ca66e72eb
15 changed files with 258 additions and 140 deletions
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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": "מיקום הקובץ נפתח בהצלחה",
+4 -2
View File
@@ -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": "ファイルの場所を正常に開きました",
+4 -2
View File
@@ -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": "파일 위치가 성공적으로 열렸습니다",
+4 -2
View File
@@ -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": "Расположение файла успешно открыто",
+4 -2
View File
@@ -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": "文件位置已成功打开",
+4 -2
View File
@@ -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": "檔案位置已成功開啟",
@@ -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;
+132 -115
View File
@@ -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 = `
<div class="modal-content delete-modal-content">
<h2>Delete Recipe</h2>
<p class="delete-message">Are you sure you want to delete this recipe?</p>
<div class="delete-model-info">
<div class="delete-preview">
${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
</div>
<div class="delete-info">
<h3>${this.recipe.title}</h3>
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
</div>
</div>
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
<div class="modal-actions">
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
</div>
</div>
`;
// 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 = `
<div class="modal-content delete-modal-content">
<h2>Delete Recipe</h2>
<p class="delete-message">Are you sure you want to delete this recipe?</p>
<div class="delete-model-info">
<div class="delete-preview">
${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
</div>
<div class="delete-info">
<h3>${recipe.title}</h3>
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
</div>
</div>
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
<div class="modal-actions">
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
</div>
</div>
`;
// 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 };
+23 -2
View File
@@ -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);
}
}
+31
View File
@@ -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 = `
<button class="modal-delete-btn" data-action="delete-model" title="${deleteModelTitle}" aria-label="${deleteModelTitle}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
`.trim();
headerActionItems.push(indentMarkup(deleteModelButton, 20));
if (licenseIcons) {
headerActionItems.push(indentMarkup(licenseIcons.trim(), 20));
}
const headerActionsMarkup = headerActionItems.length
? [
' <div class="modal-header-actions">',
@@ -944,6 +958,9 @@ function setupEventHandlers(filePath, modelType) {
case 'send-to-workflow':
handleSendToWorkflow(target, modelType);
break;
case 'delete-model':
handleDeleteModel();
break;
case 'copy-hash':
if (target.dataset.hash) {
copyToClipboard(target.dataset.hash, 'Hash copied to clipboard');
@@ -1218,12 +1235,26 @@ function setupNavigationShortcuts(modelType) {
} else if (event.key === 'ArrowRight') {
event.preventDefault();
handleDirectionalNavigation('next', navigationModelType);
} else if (event.key === 'Delete') {
event.preventDefault();
handleDeleteModel();
}
};
document.addEventListener('keydown', navigationKeyHandler);
}
/**
* Open the shared delete confirmation for the model currently shown in the
* modal. Showing the delete modal replaces this modal (ModalManager only
* keeps one modal open), which also unregisters these shortcuts.
*/
function handleDeleteModel() {
const filePath = getModalFilePath();
if (!filePath) return;
showDeleteModal(filePath);
}
async function handleDirectionalNavigation(direction, modelType) {
if (navigationInProgress) return;
+3
View File
@@ -19,6 +19,9 @@
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
</button>
<button class="modal-delete-btn" id="deleteRecipeBtn" title="{{ t('recipes.actions.deleteRecipeWithShortcut') }}" aria-label="{{ t('recipes.actions.deleteRecipeWithShortcut') }}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
<div id="recipeTagsContainer"></div>