diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index de7b06c..7ead932 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -50,6 +50,79 @@ let showStarredOnly = $state(false); let activeTagFilter = $state(null); // null = all; string = tag value let expandedId = $state(null); + + // View toggle: "live" shows current transcripts (default), "trash" + // shows soft-deleted transcripts. Backed by `list_trashed_transcripts` + // and `restore_transcript` Tauri commands. Permanent-delete-from-trash + // is intentionally deferred — the 30-day startup purge + // (TRANSCRIPT_TRASH_RETENTION_DAYS in src-tauri/src/lib.rs) handles + // hard-removal until the UI for it lands. + let viewMode: "live" | "trash" = $state("live"); + let trashItems = $state([]); + let trashLoading = $state(false); + let trashError = $state(""); + let restoringId = $state(null); + + async function loadTrash() { + trashLoading = true; + trashError = ""; + try { + trashItems = await invoke("list_trashed_transcripts", { limit: 500, offset: 0 }); + } catch (err) { + trashError = typeof err === "string" ? err : String(err); + toasts.error("Failed to load Trash", trashError); + } finally { + trashLoading = false; + } + } + + async function restoreFromTrash(id) { + if (restoringId) return; + restoringId = id; + try { + await invoke("restore_transcript", { id: String(id) }); + // Drop the row from the trash list. The live list will pick the + // restored row up on next refresh; we deliberately do not splice + // it into `history` directly here, because that store is loaded + // by the page-store init path and inserting a partial DTO would + // drift the in-memory shape from the SQLite source of truth. + trashItems = trashItems.filter((t) => t.id !== id); + } catch (err) { + toasts.error( + "Restore failed", + typeof err === "string" ? err : String(err), + ); + } finally { + restoringId = null; + } + } + + // Formats an ISO timestamp for display in the Trash list. We surface + // the transcript's `createdAt` rather than the deletion timestamp + // because `deleted_at` is not currently in the TranscriptDto shape; + // adding it is out of scope for this fix. Falls back to the raw + // string if Date can't parse it so we never display "NaN". + function formatTrashTimestamp(iso) { + if (!iso) return ""; + try { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); + } catch { + return iso; + } + } + + // Switching to trash view loads the list once. Switching back to live + // discards the trash data so a stale list doesn't reappear on toggle. + $effect(() => { + if (viewMode === "trash") { + void loadTrash(); + } else { + trashItems = []; + trashError = ""; + } + }); // Phase 9 bulk selection. Selection state is frontend-only and resets // on page refresh by design. let selected = $state(new Set()); @@ -622,35 +695,68 @@

History

- {history.length} saved -
- - {#if history.some((i) => !i.llmTags || i.llmTags.length === 0)} + +
+
+ {#if viewMode === "live"} + - {/if} - {#if history.length > 0} + {#if history.some((i) => !i.llmTags || i.llmTags.length === 0)} + + {/if} + {#if history.length > 0} + + {/if} + {:else} {/if} @@ -726,6 +832,7 @@ {/if} + {#if viewMode === "live"}
@@ -1059,4 +1166,60 @@ {/if}
+ {:else} + +
+
+ Trashed items are kept for 30 days, then permanently deleted on next app start. +
+
+
+ + {#if trashLoading && trashItems.length === 0} + + {:else if trashError && trashItems.length === 0} + + {:else if trashItems.length === 0} + + {:else} +
+ {#each trashItems as item (item.id)} +
+
+
+ {item.title || (item.text ? item.text.slice(0, 80) : "Untitled")} +
+
+ Created {formatTrashTimestamp(item.createdAt)} +
+
+ +
+ {/each} +
+ {/if} +
+
+ {/if}