diff --git a/locales/de.json b/locales/de.json index 79568314..d69bcf0d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "Gibt {size} frei", "title": "Modell löschen", "message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "Die Datei wird nach 20 Sekunden endgültig gelöscht, sofern Sie nicht rückgängig machen." }, "deleteRecipe": { - "recoverableWarning": "Diese Aktion kann 30 Sekunden lang rückgängig gemacht werden." + "recoverableWarning": "Diese Aktion kann 20 Sekunden lang rückgängig gemacht werden." }, "excludeModel": { "title": "Modell ausschließen", @@ -2120,12 +2120,12 @@ "copyFailed": "Kopieren fehlgeschlagen" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "Rückgängig", + "deleted": "Gelöscht: {name}", + "deletedBulk": "{count} Element(e) gelöscht", + "expired": "Undo-Fenster abgelaufen. Das Element wurde endgültig gelöscht.", + "failed": "Rückgängig machen fehlgeschlagen: {error}", + "restored": "Element wiederhergestellt" }, "virtual": { "loadFailed": "Fehler beim Laden der Elemente", diff --git a/locales/en.json b/locales/en.json index 2b146c0f..582f6633 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1280,10 +1280,10 @@ "freesSpace": "Frees {size}", "title": "Delete Model", "message": "Are you sure you want to delete this model and all associated files?", - "recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "This will permanently delete the file after 20 seconds unless you undo." }, "deleteRecipe": { - "recoverableWarning": "This action can be undone for 30 seconds." + "recoverableWarning": "This action can be undone for 20 seconds." }, "excludeModel": { "title": "Exclude Model", diff --git a/locales/es.json b/locales/es.json index 17e7a617..82a5598c 100644 --- a/locales/es.json +++ b/locales/es.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "Libera {size}", "title": "Eliminar modelo", "message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "El archivo se eliminará permanentemente después de 20 segundos a menos que deshaga la acción." }, "deleteRecipe": { - "recoverableWarning": "Esta acción se puede deshacer durante 30 segundos." + "recoverableWarning": "Esta acción se puede deshacer durante 20 segundos." }, "excludeModel": { "title": "Excluir modelo", @@ -2120,12 +2120,12 @@ "copyFailed": "Error al copiar" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "Deshacer", + "deleted": "Eliminado: {name}", + "deletedBulk": "{count} elemento(s) eliminado(s)", + "expired": "La ventana de deshacer ha caducado. El elemento se eliminó permanentemente.", + "failed": "No se pudo deshacer: {error}", + "restored": "Elemento restaurado" }, "virtual": { "loadFailed": "Error al cargar elementos", diff --git a/locales/fr.json b/locales/fr.json index c11e8c6e..2df53e66 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "Libère {size}", "title": "Supprimer le modèle", "message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "Le fichier sera définitivement supprimé après 20 secondes, sauf si vous annulez." }, "deleteRecipe": { - "recoverableWarning": "Cette action peut être annulée pendant 30 secondes." + "recoverableWarning": "Cette action peut être annulée pendant 20 secondes." }, "excludeModel": { "title": "Exclure le modèle", @@ -2120,12 +2120,12 @@ "copyFailed": "Échec de la copie" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "Annuler", + "deleted": "Supprimé : {name}", + "deletedBulk": "{count} élément(s) supprimé(s)", + "expired": "La fenêtre d'annulation a expiré. L'élément a été définitivement supprimé.", + "failed": "Échec de l'annulation : {error}", + "restored": "Élément restauré" }, "virtual": { "loadFailed": "Échec du chargement des éléments", diff --git a/locales/he.json b/locales/he.json index cf2bbd06..7db93bb9 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "מפנה {size}", "title": "מחק מודל", "message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "הקובץ יימחק לצמיתות לאחר 20 שניות, אלא אם תבטלו את הפעולה." }, "deleteRecipe": { - "recoverableWarning": "ניתן לבטל פעולה זו תוך 30 שניות." + "recoverableWarning": "ניתן לבטל פעולה זו תוך 20 שניות." }, "excludeModel": { "title": "החרג מודל", @@ -2120,12 +2120,12 @@ "copyFailed": "ההעתקה נכשלה" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "בטל", + "deleted": "נמחק: {name}", + "deletedBulk": "{count} פריטים נמחקו", + "expired": "חלון הביטול פג. הפריט נמחק לצמיתות.", + "failed": "הביטול נכשל: {error}", + "restored": "הפריט שוחזר" }, "virtual": { "loadFailed": "טעינת הפריטים נכשלה", diff --git a/locales/ja.json b/locales/ja.json index 815d9668..9589ac2c 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "{size} を解放します", "title": "モデルを削除", "message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "元に戻さない場合、このファイルは20秒後に完全に削除されます。" }, "deleteRecipe": { - "recoverableWarning": "この操作は30秒以内であれば元に戻せます。" + "recoverableWarning": "この操作は20秒以内であれば元に戻せます。" }, "excludeModel": { "title": "モデルを除外", @@ -2120,12 +2120,12 @@ "copyFailed": "コピーに失敗しました" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "元に戻す", + "deleted": "{name} を削除しました", + "deletedBulk": "{count} 個のアイテムを削除しました", + "expired": "元に戻せる時間が経過しました。アイテムは完全に削除されました。", + "failed": "元に戻せませんでした: {error}", + "restored": "アイテムを復元しました" }, "virtual": { "loadFailed": "アイテムの読み込みに失敗しました", diff --git a/locales/ko.json b/locales/ko.json index 72f51e35..676e6ae7 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "{size} 확보", "title": "모델 삭제", "message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "실행 취소하지 않으면 20초 후에 파일이 영구적으로 삭제됩니다." }, "deleteRecipe": { - "recoverableWarning": "이 작업은 30초 이내에 실행 취소할 수 있습니다." + "recoverableWarning": "이 작업은 20초 이내에 실행 취소할 수 있습니다." }, "excludeModel": { "title": "모델 제외", @@ -2120,12 +2120,12 @@ "copyFailed": "복사 실패" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "실행 취소", + "deleted": "{name} 삭제됨", + "deletedBulk": "{count}개 항목 삭제됨", + "expired": "실행 취소 기간이 만료되었습니다. 항목이 영구적으로 삭제되었습니다.", + "failed": "실행 취소 실패: {error}", + "restored": "항목이 복원되었습니다" }, "virtual": { "loadFailed": "항목 로딩 실패", diff --git a/locales/ru.json b/locales/ru.json index 91a30d6c..da4c9721 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "Освобождает {size}", "title": "Удалить модель", "message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "Файл будет удалён навсегда через 20 секунд, если вы не отмените действие." }, "deleteRecipe": { - "recoverableWarning": "Это действие можно отменить в течение 30 секунд." + "recoverableWarning": "Это действие можно отменить в течение 20 секунд." }, "excludeModel": { "title": "Исключить модель", @@ -2120,12 +2120,12 @@ "copyFailed": "Копирование не удалось" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "Отменить", + "deleted": "Удалено: {name}", + "deletedBulk": "Удалено: {count} шт.", + "expired": "Время отмены истекло. Элемент был удалён навсегда.", + "failed": "Не удалось отменить: {error}", + "restored": "Элемент восстановлен" }, "virtual": { "loadFailed": "Не удалось загрузить элементы", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 425c3120..23236470 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "释放 {size}", "title": "删除模型", "message": "你确定要删除此模型及所有相关文件吗?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "如果不撤销,文件将在 20 秒后被永久删除。" }, "deleteRecipe": { - "recoverableWarning": "此操作可在 30 秒内撤销。" + "recoverableWarning": "此操作可在 20 秒内撤销。" }, "excludeModel": { "title": "排除模型", @@ -2120,12 +2120,12 @@ "copyFailed": "复制失败" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "撤销", + "deleted": "已删除 {name}", + "deletedBulk": "已删除 {count} 个项目", + "expired": "撤销窗口已过期,项目已被永久删除。", + "failed": "撤销失败:{error}", + "restored": "项目已恢复" }, "virtual": { "loadFailed": "加载项目失败", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 90e120c9..a3c6813b 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1277,13 +1277,13 @@ } }, "deleteModel": { - "freesSpace": "[TODO: Translate] Frees {size}", + "freesSpace": "釋放 {size}", "title": "刪除模型", "message": "您確定要刪除此模型及所有相關檔案嗎?", - "recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo." + "recoverableWarning": "如果未復原,檔案將在 20 秒後被永久刪除。" }, "deleteRecipe": { - "recoverableWarning": "此操作可在 30 秒內復原。" + "recoverableWarning": "此操作可在 20 秒內復原。" }, "excludeModel": { "title": "排除模型", @@ -2120,12 +2120,12 @@ "copyFailed": "複製失敗" }, "undo": { - "action": "[TODO: Translate] Undo", - "deleted": "[TODO: Translate] Deleted {name}", - "deletedBulk": "[TODO: Translate] Deleted {count} item(s)", - "expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.", - "failed": "[TODO: Translate] Undo failed: {error}", - "restored": "[TODO: Translate] Item restored" + "action": "復原", + "deleted": "已刪除 {name}", + "deletedBulk": "已刪除 {count} 個項目", + "expired": "復原視窗已過期,項目已被永久刪除。", + "failed": "復原失敗:{error}", + "restored": "項目已還原" }, "virtual": { "loadFailed": "載入項目失敗", diff --git a/py/services/pending_delete_service.py b/py/services/pending_delete_service.py index 1b0dc5ae..fb56954f 100644 --- a/py/services/pending_delete_service.py +++ b/py/services/pending_delete_service.py @@ -43,7 +43,7 @@ from ..utils import settings_paths logger = logging.getLogger(__name__) # Undo window in seconds before a staged batch becomes purge-eligible. -PENDING_DELETE_TTL_SECONDS = 30 +PENDING_DELETE_TTL_SECONDS = 20 # Hidden staging directory name placed inside each deleted model's own folder # (sibling of the model artifacts) and under the settings dir for recipes. PENDING_DELETE_DIR_NAME = ".lm-pending-delete" diff --git a/static/css/components/toast.css b/static/css/components/toast.css index fbf0732f..a1cba8f7 100644 --- a/static/css/components/toast.css +++ b/static/css/components/toast.css @@ -107,6 +107,24 @@ white-space: nowrap; } +.toast-close-btn { + flex-shrink: 0; + padding: 0 4px; + background: transparent; + color: var(--text-color); + border: none; + border-radius: 4px; + font-size: 1.1em; + line-height: 1; + opacity: 0.5; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.toast-close-btn:hover { + opacity: 1; +} + /* Responsive adjustments */ @media (max-width: 768px) { .toast { diff --git a/static/js/utils/uiHelpers.js b/static/js/utils/uiHelpers.js index 5686e597..deb4b37f 100644 --- a/static/js/utils/uiHelpers.js +++ b/static/js/utils/uiHelpers.js @@ -236,11 +236,11 @@ export function showToast(key, params = {}, type = 'info', fallback = null) { * @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 {number} [options.durationMs=20000] - 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 { actionText, onAction, durationMs = 20000, countdown = true } = options; const isPlainMessage = typeof key === 'string' && /\s/.test(key); const message = isPlainMessage ? key : translate(key, params); @@ -295,6 +295,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) { } }, 1000); } + + // Manual close button: hides the toast early without firing onAction. The + // backend undo window keeps running and the batch is purged when it expires. + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'toast-close-btn'; + closeBtn.textContent = '×'; + closeBtn.setAttribute('aria-label', translate('common.actions.close')); + closeBtn.addEventListener('click', (event) => { + event.preventDefault(); + clearCountdown(); + dismiss(); + }); + toast.append(closeBtn); } export function restoreFolderFilter() { diff --git a/tests/frontend/utils/i18nHelpers.test.js b/tests/frontend/utils/i18nHelpers.test.js index 466904fc..bc83fbf4 100644 --- a/tests/frontend/utils/i18nHelpers.test.js +++ b/tests/frontend/utils/i18nHelpers.test.js @@ -67,7 +67,7 @@ describe('translate() with real en.json locale', () => { installWindowI18n(); expect(translate('modals.deleteModel.recoverableWarning')).toBe( - 'This will permanently delete the file after 30 seconds unless you undo.', + 'This will permanently delete the file after 20 seconds unless you undo.', ); expect(translate('modals.deleteModel.freesSpace', { size: '1.2 MB' })).toBe( 'Frees 1.2 MB', diff --git a/tests/frontend/utils/uiHelpers.dom.test.js b/tests/frontend/utils/uiHelpers.dom.test.js index 41c509a0..db282d9e 100644 --- a/tests/frontend/utils/uiHelpers.dom.test.js +++ b/tests/frontend/utils/uiHelpers.dom.test.js @@ -133,14 +133,14 @@ describe('UI helper DOM utilities', () => { const countdown = toast.querySelector('.toast-countdown'); expect(countdown).not.toBeNull(); - expect(countdown.textContent).toBe('(30s)'); + expect(countdown.textContent).toBe('(20s)'); // Ticking one second updates the countdown text vi.advanceTimersByTime(1000); - expect(countdown.textContent).toBe('(29s)'); + expect(countdown.textContent).toBe('(19s)'); // Drain remaining timers so no state leaks into other tests - vi.advanceTimersByTime(30000); + vi.advanceTimersByTime(20000); }); it('invokes onAction once and dismisses immediately when the button is clicked', async () => { @@ -186,6 +186,34 @@ describe('UI helper DOM utilities', () => { expect(onAction).toHaveBeenCalledTimes(1); }); + it('dismisses the toast via the close button without firing onAction', async () => { + vi.useFakeTimers(); + translateMock.mockReturnValue('Deleted Demo Model'); + + const { showActionToast } = await import(UI_HELPERS_MODULE); + + const onAction = vi.fn(); + showActionToast('toast.undo.deleted', {}, 'success', { + actionText: 'Undo', + onAction, + }); + + const toast = document.querySelector('.toast-container .toast'); + const countdown = toast.querySelector('.toast-countdown'); + toast.querySelector('.toast-close-btn').click(); + + expect(onAction).not.toHaveBeenCalled(); + expect(toast.classList.contains('show')).toBe(false); + + // Advancing past the full duration must not tick the countdown further, + // throw, or re-dismiss the already-dismissed toast + vi.advanceTimersByTime(60000); + expect(countdown.textContent).toBe('(20s)'); + + toast.dispatchEvent(new Event('transitionend', { bubbles: true })); + expect(document.querySelector('.toast-container .toast')).toBeNull(); + }); + it('dismisses the toast when the countdown reaches zero', async () => { vi.useFakeTimers(); translateMock.mockReturnValue('Deleted Demo Model');