ui: undo toast after trash / archive
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
@@ -8,6 +8,7 @@ import { useUiStore } from "../stores/ui";
|
|||||||
import CommandPalette from "./CommandPalette.vue";
|
import CommandPalette from "./CommandPalette.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelsModal from "./LabelsModal.vue";
|
import LabelsModal from "./LabelsModal.vue";
|
||||||
|
import ToastHost from "./ToastHost.vue";
|
||||||
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
import { NOTE_SWATCH_CLASSES, type NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -261,6 +262,8 @@ async function signOut() {
|
|||||||
|
|
||||||
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
||||||
|
|
||||||
|
<ToastHost />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showShortcuts"
|
v-if="showShortcuts"
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useUiStore } from "../stores/ui";
|
||||||
|
|
||||||
|
const ui = useUiStore();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-150"
|
||||||
|
enter-from-class="translate-y-2 opacity-0"
|
||||||
|
leave-active-class="transition duration-150"
|
||||||
|
leave-to-class="translate-y-2 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="ui.toast"
|
||||||
|
class="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-lg bg-neutral-900 px-4 py-2.5 text-sm text-white shadow-lg dark:bg-neutral-100 dark:text-neutral-900"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<span>{{ ui.toast.message }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="font-semibold text-brand hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
||||||
|
@click="ui.runToastAction()"
|
||||||
|
>
|
||||||
|
{{ ui.toast.actionLabel }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-white/60 hover:text-white dark:text-neutral-900/60 dark:hover:text-neutral-900"
|
||||||
|
aria-label="Dismiss"
|
||||||
|
@click="ui.dismissToast()"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { useUiStore } from "./ui";
|
||||||
import type { NoteColor } from "../notes/colors";
|
import type { NoteColor } from "../notes/colors";
|
||||||
|
|
||||||
export type NoteView = "active" | "archived" | "trash";
|
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 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<void> => {
|
||||||
|
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 setColor = (id: string, color: NoteColor) => mutate(id, { color });
|
||||||
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
const setKind = (id: string, kind: NoteKind) => mutate(id, { kind });
|
||||||
const setReminder = (id: string, remindAt: string | null) => mutate(id, { remind_at: remindAt });
|
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<void> {
|
async function trash(id: string): Promise<void> {
|
||||||
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
|
reconcile(await api.post<Note>(`/api/notes/${id}/trash`));
|
||||||
|
useUiStore().showToast("Note moved to trash", "Undo", () => void restore(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restore(id: string): Promise<void> {
|
async function restore(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { ref } from "vue";
|
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
|
// 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).
|
// global shortcut in the app shell asking the board to act).
|
||||||
export const useUiStore = defineStore("ui", () => {
|
export const useUiStore = defineStore("ui", () => {
|
||||||
@@ -10,5 +17,31 @@ export const useUiStore = defineStore("ui", () => {
|
|||||||
composeTick.value++;
|
composeTick.value++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { composeTick, requestCompose };
|
// Transient undo toast (e.g. after trash/archive).
|
||||||
|
const toast = ref<Toast | null>(null);
|
||||||
|
let toastTimer: ReturnType<typeof setTimeout> | 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 };
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user