diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index 50e13f5..de7b06c 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -70,21 +70,45 @@ // user who walked away doesn't return to a primed delete button. // Replaces the native window.confirm() dialog, which broke the warm // UI tone with an OS-styled modal. + // + // NOTE: clearAll (delete-everything) is gated by a stronger + // type-the-word-DELETE modal — see CLEAR_ALL_CONFIRM_WORD below. + // The arm-confirm pattern is retained for bulkDelete (selection + // subset) where the failure mode is smaller. const CONFIRM_TIMEOUT_MS = 4000; - let clearAllArmed = $state(false); let bulkDeleteArmed = $state(false); - let clearAllArmTimer: ReturnType | null = null; let bulkDeleteArmTimer: ReturnType | null = null; - function armClearAll() { - clearAllArmed = true; - if (clearAllArmTimer) clearTimeout(clearAllArmTimer); - clearAllArmTimer = setTimeout(() => { clearAllArmed = false; }, CONFIRM_TIMEOUT_MS); + // Type-the-word modal state for clearAll. Replaces the 4-second + // arm-confirm because Clear All wipes every transcript at once; + // mis-clicks under the prior pattern soft-deleted the entire + // history. The user must type the word DELETE exactly + // (case-sensitive) before the Confirm button activates. + const CLEAR_ALL_CONFIRM_WORD = "DELETE"; + let clearAllModalOpen = $state(false); + let clearAllConfirmInput = $state(""); + let clearAllInputEl = $state(null); + let clearAllInProgress = $state(false); + let clearAllConfirmValid = $derived( + clearAllConfirmInput === CLEAR_ALL_CONFIRM_WORD, + ); + + function openClearAllModal() { + clearAllConfirmInput = ""; + clearAllModalOpen = true; + // Focus the input on the next tick — Svelte 5 binds run after the + // {#if} branch mounts, so a microtask is enough. + queueMicrotask(() => { + if (clearAllInputEl) clearAllInputEl.focus(); + }); } - function disarmClearAll() { - clearAllArmed = false; - if (clearAllArmTimer) { clearTimeout(clearAllArmTimer); clearAllArmTimer = null; } + + function closeClearAllModal() { + if (clearAllInProgress) return; + clearAllModalOpen = false; + clearAllConfirmInput = ""; } + function armBulkDelete() { bulkDeleteArmed = true; if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer); @@ -100,16 +124,13 @@ // this derived feeds an aria-live region near the page root so the // armed state is announced as it arms and disarms. let confirmAnnouncement = $derived( - clearAllArmed - ? "Clear all armed. Click confirm to delete all history, or cancel to dismiss." - : bulkDeleteArmed - ? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.` - : "" + bulkDeleteArmed + ? `Delete ${selected.size} ${selected.size === 1 ? 'transcript' : 'transcripts'} armed. Click confirm or cancel to dismiss.` + : "" ); onDestroy(() => { stopPlayback(); - if (clearAllArmTimer) clearTimeout(clearAllArmTimer); if (bulkDeleteArmTimer) clearTimeout(bulkDeleteArmTimer); }); @@ -273,8 +294,13 @@ return items; }); + // Wipe every live transcript. Soft-delete via the SQLite backend so + // the user has a 30-day window to restore from Trash. Gated behind + // the type-the-word-DELETE modal — the prior 4-second arm-confirm + // let a single mis-click destroy the whole history. async function clearAll() { - disarmClearAll(); + if (!clearAllConfirmValid || clearAllInProgress) return; + clearAllInProgress = true; const ids = history.map((entry) => entry.id); history.splice(0); expandedId = null; @@ -294,6 +320,9 @@ `${failed} of ${ids.length} failed to delete from disk and may reappear after restart.`, ); } + clearAllInProgress = false; + clearAllModalOpen = false; + clearAllConfirmInput = ""; } function toggleExpand(id) { @@ -617,27 +646,86 @@ {/if} {#if history.length > 0} - {#if clearAllArmed} - Delete all history? This can't be undone. - - - {:else} - - {/if} + {/if} + + {#if clearAllModalOpen} + + + {/if} +