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
+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;
}
}