From 9baa9217c74126e796b8174258d6ea30f024a391 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 20 Jul 2026 13:15:19 -0400 Subject: [PATCH] ui: undo toast after trash / archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A global ToastHost (mounted in the app shell) shows a transient toast with an Undo action, driven by the ui store (showToast/dismiss/runAction, 5s auto-dismiss). The notes store fires it centrally: trash → Undo restores; archive → Undo unarchives — so it works from card, editor, and keyboard. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm --- frontend/src/components/AppShell.vue | 3 +++ frontend/src/components/ToastHost.vue | 38 +++++++++++++++++++++++++++ frontend/src/stores/notes.ts | 7 ++++- frontend/src/stores/ui.ts | 35 +++++++++++++++++++++++- 4 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/ToastHost.vue diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index cc49789..b58d307 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -8,6 +8,7 @@ import { useUiStore } from "../stores/ui"; import CommandPalette from "./CommandPalette.vue"; import Icon from "./Icon.vue"; import LabelsModal from "./LabelsModal.vue"; +import ToastHost from "./ToastHost.vue"; import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors"; const route = useRoute(); @@ -261,6 +262,8 @@ async function signOut() { + +
+import { useUiStore } from "../stores/ui"; + +const ui = useUiStore(); + + + diff --git a/frontend/src/stores/notes.ts b/frontend/src/stores/notes.ts index 49fe918..331b63f 100644 --- a/frontend/src/stores/notes.ts +++ b/frontend/src/stores/notes.ts @@ -1,6 +1,7 @@ import { defineStore } from "pinia"; import { ref } from "vue"; import { api } from "../api/client"; +import { useUiStore } from "./ui"; import type { NoteColor } from "../notes/colors"; export type NoteView = "active" | "archived" | "trash"; @@ -103,7 +104,10 @@ export const useNotesStore = defineStore("notes", () => { } const setPinned = (id: string, pinned: boolean) => mutate(id, { pinned }); - const setArchived = (id: string, archived: boolean) => mutate(id, { archived }); + const setArchived = async (id: string, archived: boolean): Promise => { + await mutate(id, { archived }); + if (archived) useUiStore().showToast("Note archived", "Undo", () => void setArchived(id, false)); + }; const setColor = (id: string, color: NoteColor) => mutate(id, { color }); const setKind = (id: string, kind: NoteKind) => mutate(id, { kind }); const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt }); @@ -172,6 +176,7 @@ export const useNotesStore = defineStore("notes", () => { async function trash(id: string): Promise { reconcile(await api.post(`/api/notes/${id}/trash`)); + useUiStore().showToast("Note moved to trash", "Undo", () => void restore(id)); } async function restore(id: string): Promise { diff --git a/frontend/src/stores/ui.ts b/frontend/src/stores/ui.ts index 0c49810..2e470a3 100644 --- a/frontend/src/stores/ui.ts +++ b/frontend/src/stores/ui.ts @@ -1,6 +1,13 @@ import { defineStore } from "pinia"; import { ref } from "vue"; +interface Toast { + id: number; + message: string; + actionLabel: string; + action: () => void; +} + // Cross-component UI signals that don't belong to any single view (e.g. a // global shortcut in the app shell asking the board to act). export const useUiStore = defineStore("ui", () => { @@ -10,5 +17,31 @@ export const useUiStore = defineStore("ui", () => { composeTick.value++; } - return { composeTick, requestCompose }; + // Transient undo toast (e.g. after trash/archive). + const toast = ref(null); + let toastTimer: ReturnType | undefined; + let toastSeq = 0; + + function showToast(message: string, actionLabel: string, action: () => void) { + toastSeq += 1; + const id = toastSeq; + toast.value = { id, message, actionLabel, action }; + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + if (toast.value?.id === id) toast.value = null; + }, 5000); + } + + function dismissToast() { + clearTimeout(toastTimer); + toast.value = null; + } + + function runToastAction() { + const action = toast.value?.action; + dismissToast(); + action?.(); + } + + return { composeTick, requestCompose, toast, showToast, dismissToast, runToastAction }; });