feat: add toast notification system with success, error, and info variants
This commit is contained in:
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
31
src/components/toast/ToastContainer.tsx
Normal file
31
src/components/toast/ToastContainer.tsx
Normal 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
33
src/stores/toast-store.ts
Normal 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) }));
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user