feat: add toast notification system with success, error, and info variants

This commit is contained in:
Your Name
2026-02-15 20:30:17 +02:00
parent 1547ad5a70
commit 2f62dbba7c
5 changed files with 76 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { BoardList } from "@/components/boards/BoardList";
import { BoardView } from "@/components/board/BoardView"; import { BoardView } from "@/components/board/BoardView";
import { CommandPalette } from "@/components/command-palette/CommandPalette"; import { CommandPalette } from "@/components/command-palette/CommandPalette";
import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { SettingsDialog } from "@/components/settings/SettingsDialog";
import { ToastContainer } from "@/components/toast/ToastContainer";
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
export default function App() { export default function App() {
@@ -64,6 +65,7 @@ export default function App() {
</AppShell> </AppShell>
<CommandPalette onOpenSettings={handleOpenSettings} /> <CommandPalette onOpenSettings={handleOpenSettings} />
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} /> <SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
<ToastContainer />
</> </>
); );
} }

View File

@@ -21,6 +21,7 @@ import { Button } from "@/components/ui/button";
import type { BoardMeta } from "@/types/board"; import type { BoardMeta } from "@/types/board";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { useToastStore } from "@/stores/toast-store";
import { deleteBoard, loadBoard, saveBoard } from "@/lib/storage"; import { deleteBoard, loadBoard, saveBoard } from "@/lib/storage";
interface BoardCardProps { interface BoardCardProps {
@@ -31,6 +32,7 @@ interface BoardCardProps {
export function BoardCard({ board, index = 0 }: BoardCardProps) { export function BoardCard({ board, index = 0 }: BoardCardProps) {
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const prefersReducedMotion = useReducedMotion(); const prefersReducedMotion = useReducedMotion();
const addToast = useToastStore((s) => s.addToast);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
const addRecentBoard = useAppStore((s) => s.addRecentBoard); const addRecentBoard = useAppStore((s) => s.addRecentBoard);
@@ -51,6 +53,7 @@ export function BoardCard({ board, index = 0 }: BoardCardProps) {
await deleteBoard(board.id); await deleteBoard(board.id);
await refreshBoards(); await refreshBoards();
setConfirmDelete(false); setConfirmDelete(false);
addToast(`"${board.title}" deleted`, "info");
} }
async function handleDuplicate() { async function handleDuplicate() {
@@ -66,6 +69,7 @@ export function BoardCard({ board, index = 0 }: BoardCardProps) {
}; };
await saveBoard(duplicated); await saveBoard(duplicated);
await refreshBoards(); await refreshBoards();
addToast(`"${board.title}" duplicated`, "success");
} }
return ( return (

View File

@@ -9,6 +9,7 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useAppStore } from "@/stores/app-store"; import { useAppStore } from "@/stores/app-store";
import { useBoardStore } from "@/stores/board-store"; import { useBoardStore } from "@/stores/board-store";
import { useToastStore } from "@/stores/toast-store";
import { saveBoard } from "@/lib/storage"; import { saveBoard } from "@/lib/storage";
import { import {
exportBoardAsJson, exportBoardAsJson,
@@ -31,6 +32,7 @@ function downloadBlob(content: string, filename: string, mimeType: string) {
export function ImportExportButtons() { export function ImportExportButtons() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const addToast = useToastStore((s) => s.addToast);
const board = useBoardStore((s) => s.board); const board = useBoardStore((s) => s.board);
const refreshBoards = useAppStore((s) => s.refreshBoards); const refreshBoards = useAppStore((s) => s.refreshBoards);
const setView = useAppStore((s) => s.setView); const setView = useAppStore((s) => s.setView);
@@ -42,6 +44,7 @@ export function ImportExportButtons() {
const json = exportBoardAsJson(board); const json = exportBoardAsJson(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_"); const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(json, `${safeName}.json`, "application/json"); downloadBlob(json, `${safeName}.json`, "application/json");
addToast("Board exported as JSON", "success");
} }
function handleExportCsv() { function handleExportCsv() {
@@ -49,6 +52,7 @@ export function ImportExportButtons() {
const csv = exportBoardAsCsv(board); const csv = exportBoardAsCsv(board);
const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_"); const safeName = board.title.replace(/[^a-zA-Z0-9-_]/g, "_");
downloadBlob(csv, `${safeName}.csv`, "text/csv"); downloadBlob(csv, `${safeName}.csv`, "text/csv");
addToast("Board exported as CSV", "success");
} }
function handleImportClick() { function handleImportClick() {
@@ -77,9 +81,10 @@ export function ImportExportButtons() {
await openBoard(imported.id); await openBoard(imported.id);
setView({ type: "board", boardId: imported.id }); setView({ type: "board", boardId: imported.id });
addRecentBoard(imported.id); addRecentBoard(imported.id);
addToast("Board imported successfully", "success");
} catch (err) { } catch (err) {
console.error("Import failed:", err); console.error("Import failed:", err);
// Could show a toast here in the future addToast("Import failed — check file format", "error");
} }
// Reset the input so the same file can be re-imported // Reset the input so the same file can be re-imported

View File

@@ -0,0 +1,31 @@
import { AnimatePresence, motion } from "framer-motion";
import { useToastStore } from "@/stores/toast-store";
const TYPE_STYLES = {
success: "bg-pylon-accent/10 text-pylon-accent border-pylon-accent/20",
error: "bg-pylon-danger/10 text-pylon-danger border-pylon-danger/20",
info: "bg-pylon-surface text-pylon-text border-border",
} as const;
export function ToastContainer() {
const toasts = useToastStore((s) => s.toasts);
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2">
<AnimatePresence>
{toasts.map((toast) => (
<motion.div
key={toast.id}
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className={`pointer-events-auto rounded-lg border px-4 py-2 text-sm shadow-md ${TYPE_STYLES[toast.type]}`}
>
{toast.message}
</motion.div>
))}
</AnimatePresence>
</div>
);
}

33
src/stores/toast-store.ts Normal file
View File

@@ -0,0 +1,33 @@
import { create } from "zustand";
export type ToastType = "success" | "error" | "info";
interface Toast {
id: string;
message: string;
type: ToastType;
}
interface ToastState {
toasts: Toast[];
addToast: (message: string, type?: ToastType) => void;
removeToast: (id: string) => void;
}
let nextId = 0;
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
addToast: (message, type = "info") => {
const id = String(++nextId);
set((s) => ({ toasts: [...s.toasts, { id, message, type }] }));
setTimeout(() => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
}, 3000);
},
removeToast: (id) => {
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
},
}));