feat(delete): add undo toasts and harden delete modals

This commit is contained in:
Will Miao
2026-08-11 14:09:10 +08:00
parent eb0f6dd3b6
commit b2c68e6a65
27 changed files with 2555 additions and 51 deletions
+14 -4
View File
@@ -201,8 +201,13 @@ export class BaseModelApiClient {
if (state.virtualScroller) {
state.virtualScroller.removeItemByFilePath(filePath);
}
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
return true;
const batchId = data.batch_id || null;
if (!batchId) {
// Not staged (undo disabled or staging failed): keep the legacy toast.
// When staged, the caller shows the undo action toast instead.
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
return { success: true, batch_id: batchId };
} else {
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
}
@@ -1622,9 +1627,14 @@ export class BaseModelApiClient {
if (result.success) {
return {
success: true,
deleted_count: result.deleted_count,
deleted_count: result.deleted_count ?? result.total_deleted,
failed_count: result.failed_count || 0,
errors: result.errors || []
errors: result.errors || [],
// Undo batch fields — batch_id on merge success, batch_ids
// array on merge failure (same success dict for the
// status='cancelled' staged-subset path)
batch_id: result.batch_id || null,
batch_ids: result.batch_ids || null
};
} else {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
+4
View File
@@ -657,6 +657,10 @@ export class RecipeSidebarApiClient {
deleted_count: result.total_deleted,
failed_count: result.total_failed || 0,
errors: result.failed || [],
// Undo batch fields — batch_id on merge success, batch_ids
// array on merge failure
batch_id: result.batch_id || null,
batch_ids: result.batch_ids || null,
};
} finally {
state.loadingManager?.hide();
+32 -3
View File
@@ -1,5 +1,7 @@
// Duplicates Manager Component
import { showToast } from '../utils/uiHelpers.js';
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
@@ -447,6 +449,7 @@ export class DuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('duplicateDeleteModal');
armDeleteButton(document.getElementById('duplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
@@ -479,8 +482,34 @@ export class DuplicatesManager {
if (!data.success) {
throw new Error(data.error || 'Unknown error deleting recipes');
}
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
? data.batch_ids
: null;
if (data.batch_id || batchIds) {
// One undo action restores the whole selected group
const refreshFn = () => window.recipeManager.loadRecipes(true);
const onAction = data.batch_id
? () => handleUndoDelete(data.batch_id, refreshFn)
: async () => {
for (const id of batchIds) {
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
if (!succeeded) {
showToast('toast.undo.failed', { error: '' }, 'error');
return;
}
}
refreshFn();
showToast('toast.undo.restored', {}, 'success');
};
showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
actionText: translate('toast.undo.action'),
onAction,
});
} else {
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
}
// Exit duplicate mode if deletions were successful
if (data.total_deleted > 0) {
+33 -3
View File
@@ -1,5 +1,8 @@
// Model Duplicates Manager Component for LoRAs and Checkpoints
import { showToast } from '../utils/uiHelpers.js';
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js';
import { resetAndReload} from '../api/modelApiFactory.js';
@@ -700,6 +703,7 @@ export class ModelDuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('modelDuplicateDeleteModal');
armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
@@ -732,8 +736,34 @@ export class ModelDuplicatesManager {
if (!data.success) {
throw new Error(data.error || 'Unknown error deleting models');
}
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
? data.batch_ids
: null;
if (data.batch_id || batchIds) {
// One undo action restores the whole selected group
const refreshFn = () => resetAndReload(true);
const onAction = data.batch_id
? () => handleUndoDelete(data.batch_id, refreshFn)
: async () => {
for (const id of batchIds) {
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
if (!succeeded) {
showToast('toast.undo.failed', { error: '' }, 'error');
return;
}
}
refreshFn();
showToast('toast.undo.restored', {}, 'success');
};
showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
actionText: translate('toast.undo.action'),
onAction,
});
} else {
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
}
// If models were successfully deleted
if (data.total_deleted > 0) {
+21 -2
View File
@@ -1,5 +1,5 @@
// Recipe Card Component
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js';
@@ -7,6 +7,9 @@ import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
class RecipeCard {
constructor(recipe, clickHandler) {
@@ -375,8 +378,13 @@ class RecipeCard {
`;
// Show the modal with custom content and setup callbacks
let deleteArmTimer = null;
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
if (deleteArmTimer) {
clearTimeout(deleteArmTimer);
deleteArmTimer = null;
}
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete';
@@ -396,6 +404,8 @@ class RecipeCard {
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
deleteArmTimer = armDeleteButton(deleteModal);
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
@@ -432,7 +442,16 @@ class RecipeCard {
return response.json();
})
.then(data => {
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
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);
+36 -5
View File
@@ -1,5 +1,7 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -628,6 +630,7 @@ export class BulkManager {
}
modalManager.showModal('bulkDeleteModal');
armDeleteButton(document.getElementById('bulkDeleteModal'));
}
async confirmBulkDelete() {
@@ -649,10 +652,38 @@ export class BulkManager {
showToast('toast.api.operationCancelled', {}, 'info');
} else if (result.success) {
const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count,
type: currentConfig.displayName.toLowerCase()
}, 'success');
const isRecipes = state.currentPageType === 'recipes';
const refreshFn = isRecipes
? () => window.recipeManager.loadRecipes(true)
: () => resetAndReload(true);
if (result.batch_id || (result.batch_ids && result.batch_ids.length)) {
// One undo action for the whole bulk action — the backend
// merges staged per-file batches into a single batch, with
// a batch_ids fallback array when the merge failed
const onAction = result.batch_id
? () => handleUndoDelete(result.batch_id, refreshFn)
: async () => {
for (const id of result.batch_ids) {
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
if (!succeeded) {
showToast('toast.undo.failed', { error: '' }, 'error');
return;
}
}
refreshFn();
showToast('toast.undo.restored', {}, 'success');
};
showActionToast('toast.undo.deletedBulk', { count: result.deleted_count }, 'success', {
actionText: translate('toast.undo.action'),
onAction,
});
} else {
showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count,
type: currentConfig.displayName.toLowerCase()
}, 'success');
}
filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
+23
View File
@@ -434,6 +434,22 @@ export class ModalManager {
this.currentOpenModal = id; // Update currently open modal
document.body.style.top = `-${this.scrollPosition}px`;
document.body.classList.add('modal-open');
modal.restoreFocusTo = null;
if (this._isDeleteConfirmModal(modal.element)) {
const activeElement = document.activeElement;
modal.restoreFocusTo = activeElement && activeElement !== document.body
? activeElement
: null;
modal.element.querySelector('.cancel-btn')?.focus();
}
}
// Several non-delete modals share the delete-modal styling class, so an
// actual .delete-btn is required before focus is moved to Cancel.
_isDeleteConfirmModal(element) {
return element.classList.contains('delete-modal') &&
Boolean(element.querySelector('.delete-btn'));
}
closeModal(id) {
@@ -463,6 +479,13 @@ export class ModalManager {
modal.cleanupCallback();
modal.cleanupCallback = null;
}
if (modal.restoreFocusTo) {
if (modal.restoreFocusTo.isConnected) {
modal.restoreFocusTo.focus();
}
modal.restoreFocusTo = null;
}
}
handleEscape(e) {
+59 -7
View File
@@ -1,37 +1,84 @@
import { modalManager } from '../managers/ModalManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { showActionToast } from './uiHelpers.js';
import { translate } from './i18nHelpers.js';
import { handleUndoDelete } from './undoHelpers.js';
import { state } from '../state/index.js';
import { formatFileSize } from '../components/shared/utils.js';
const DELETE_BUTTON_ARM_DELAY_MS = 1500;
let pendingDeletePath = null;
let pendingDeleteName = null;
let pendingExcludePath = null;
let pendingDeleteArmTimer = null;
// Delay-activates every delete button inside a delete-confirmation modal so a
// misclick in the first moments after opening cannot confirm the deletion.
// Returns the pending timeout id so callers can cancel it when the modal closes early.
export function armDeleteButton(modalElement, delayMs = DELETE_BUTTON_ARM_DELAY_MS) {
if (!modalElement) return null;
const deleteButtons = modalElement.querySelectorAll('.delete-btn');
if (!deleteButtons.length) return null;
deleteButtons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
deleteButtons.forEach((button) => { button.disabled = false; });
}, delayMs);
}
export function showDeleteModal(filePath) {
pendingDeletePath = filePath;
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
? window.CSS.escape(filePath)
: filePath.replace(/["\\]/g, '\\$&');
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
const modelName = card ? card.dataset.name : filePath.split('/').pop();
pendingDeleteName = modelName;
const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info');
const undoEnabled = state.global?.settings?.delete_undo_enabled;
const warningKey = undoEnabled
? 'modals.deleteModel.recoverableWarning'
: 'modals.deleteModel.permanentWarning';
const fileSize = card?.dataset.file_size;
const sizeLine = fileSize
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
: '';
modelInfo.innerHTML = `
<strong>Model:</strong> ${modelName}
<br>
<strong>File:</strong> ${filePath}
<br>
${translate(warningKey)}${sizeLine}
`;
modalManager.showModal('deleteModal');
pendingDeleteArmTimer = armDeleteButton(modal);
}
export async function confirmDelete() {
if (!pendingDeletePath) return;
try {
await getModelApiClient().deleteModel(pendingDeletePath);
const modelName = pendingDeleteName;
const result = await getModelApiClient().deleteModel(pendingDeletePath);
closeDeleteModal();
if (result?.batch_id) {
const batchId = result.batch_id;
showActionToast('toast.undo.deleted', { name: modelName }, 'success', {
actionText: translate('toast.undo.action'),
onAction: () => handleUndoDelete(batchId, () => resetAndReload(true)),
});
}
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
@@ -43,7 +90,12 @@ export async function confirmDelete() {
export function closeDeleteModal() {
modalManager.closeModal('deleteModal');
if (pendingDeleteArmTimer) {
clearTimeout(pendingDeleteArmTimer);
pendingDeleteArmTimer = null;
}
pendingDeletePath = null;
pendingDeleteName = null;
}
// Functions for the exclude modal
+132 -27
View File
@@ -133,15 +133,28 @@ export async function copyToClipboard(text, successMessage = null) {
}
}
export function showToast(key, params = {}, type = 'info', fallback = null) {
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
// to avoid spurious "Translation key not found" warnings from i18next
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params, fallback);
/**
* Build a toast element (internal — not exported).
* @param {string} message - Already-resolved message text
* @param {string} type - Toast type (info/success/warning/error)
* @returns {HTMLElement} The toast element (not yet attached to the DOM)
*/
function createToastElement(message, type) {
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
return toast;
}
/**
* Attach a toast to the shared container, position it, and schedule its
* dismissal (internal — not exported).
* @param {HTMLElement} toast - The toast element to display
* @param {number} durationMs - How long the toast stays visible
* @param {Function} [onDismiss] - Optional callback fired once when dismissal begins
* @returns {Function} Manual dismiss function (idempotent)
*/
function appendToast(toast, durationMs, onDismiss = null) {
// Get or create toast container
let toastContainer = document.querySelector('.toast-container');
if (!toastContainer) {
@@ -161,35 +174,127 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
// Set position based on existing toasts
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
requestAnimationFrame(() => {
toast.classList.add('show');
let dismissed = false;
const dismiss = () => {
if (dismissed) return;
dismissed = true;
// Set timeout based on type
let timeout = 2000; // Default (info)
if (type === 'warning' || type === 'error') {
timeout = 5000;
if (typeof onDismiss === 'function') {
onDismiss();
}
setTimeout(() => {
toast.classList.remove('show');
toast.addEventListener('transitionend', () => {
toast.remove();
toast.classList.remove('show');
toast.addEventListener('transitionend', () => {
toast.remove();
// Reposition remaining toasts
if (toastContainer) {
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
remainingToasts.forEach((t, index) => {
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
});
// Reposition remaining toasts
if (toastContainer) {
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
remainingToasts.forEach((t, index) => {
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
});
// Remove container if empty
if (remainingToasts.length === 0) {
toastContainer.remove();
}
// Remove container if empty
if (remainingToasts.length === 0) {
toastContainer.remove();
}
});
}, timeout);
}
});
};
requestAnimationFrame(() => {
toast.classList.add('show');
setTimeout(dismiss, durationMs);
});
return dismiss;
}
export function showToast(key, params = {}, type = 'info', fallback = null) {
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
// to avoid spurious "Translation key not found" warnings from i18next
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params, fallback);
const toast = createToastElement(message, type);
// Set timeout based on type
let duration = 2000; // Default (info)
if (type === 'warning' || type === 'error') {
duration = 5000;
}
appendToast(toast, duration);
}
/**
* Show a toast with an action button (e.g. Undo) and an optional countdown.
* The message accepts the same key/plain-string contract as showToast, so
* callers may pass either an i18n key or an already-translated string.
* @param {string} key - i18n key or plain message
* @param {Object} [params] - i18n interpolation params
* @param {string} [type] - Toast type (info/success/warning/error)
* @param {Object} [options]
* @param {string} [options.actionText] - Label for the action button (button omitted when empty)
* @param {Function} [options.onAction] - Callback invoked at most once on button click
* @param {number} [options.durationMs=30000] - How long the toast stays visible
* @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
*/
export function showActionToast(key, params = {}, type = 'info', options = {}) {
const { actionText, onAction, durationMs = 30000, countdown = true } = options;
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params);
const toast = createToastElement(message, type);
let countdownInterval = null;
const clearCountdown = () => {
if (countdownInterval !== null) {
clearInterval(countdownInterval);
countdownInterval = null;
}
};
// The interval must be cleared on EVERY dismiss path (timeout, countdown end,
// manual button click) — the onDismiss hook covers the appendToast timeout path.
const dismiss = appendToast(toast, durationMs, clearCountdown);
let actionFired = false;
if (actionText) {
const button = document.createElement('button');
button.type = 'button';
button.className = 'toast-action-btn';
button.textContent = actionText;
button.addEventListener('click', (event) => {
event.preventDefault();
// Guard against double-click firing the action twice
if (actionFired) return;
actionFired = true;
clearCountdown();
if (typeof onAction === 'function') {
onAction();
}
dismiss();
});
toast.append(button);
}
if (countdown) {
const countdownEl = document.createElement('span');
countdownEl.className = 'toast-countdown';
let remainingSeconds = Math.max(0, Math.ceil(durationMs / 1000));
countdownEl.textContent = `(${remainingSeconds}s)`;
toast.append(countdownEl);
countdownInterval = setInterval(() => {
remainingSeconds -= 1;
countdownEl.textContent = `(${Math.max(remainingSeconds, 0)}s)`;
if (remainingSeconds <= 0) {
clearCountdown();
dismiss();
}
}, 1000);
}
}
export function restoreFolderFilter() {
+55
View File
@@ -0,0 +1,55 @@
import { showToast } from './uiHelpers.js';
/**
* Undo a staged delete batch via the pending-delete endpoint.
* @param {string} batchId - The batch id returned by a staged delete response
* @param {Function|null} refreshFn - Called once after a successful restore (unless options.refresh is false)
* @param {Object} [options]
* @param {boolean} [options.showToast=true] - Suppress toasts (used by sequential multi-batch undo loops)
* @param {boolean} [options.refresh=true] - Suppress the refresh call (used by sequential multi-batch undo loops)
* @returns {Promise<boolean>} Whether the undo succeeded
*/
export async function handleUndoDelete(batchId, refreshFn, options = {}) {
const { showToast: showToastEnabled = true, refresh: refreshEnabled = true } = options;
try {
const response = await fetch('/api/lm/undo-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ batch_id: batchId }),
});
if (response.ok) {
if (refreshEnabled && typeof refreshFn === 'function') {
refreshFn();
}
if (showToastEnabled) {
showToast('toast.undo.restored', {}, 'success');
}
return true;
}
// Read the error body to distinguish an expired batch from other failures
let errorMessage = '';
try {
const body = await response.json();
errorMessage = body?.error || '';
} catch {
errorMessage = '';
}
if (showToastEnabled) {
if (response.status === 404 && errorMessage.toLowerCase().includes('expired')) {
showToast('toast.undo.expired', {}, 'error');
} else {
showToast('toast.undo.failed', { error: errorMessage || response.statusText }, 'error');
}
}
return false;
} catch (error) {
if (showToastEnabled) {
showToast('toast.undo.failed', { error: error.message }, 'error');
}
return false;
}
}