mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-14 09:43:22 -03:00
feat(delete): add undo toasts and harden delete modals
This commit is contained in:
@@ -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`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user